21 Commits

Author SHA1 Message Date
b255fc01e9 recipes: use emoji font for favorites button
Some checks failed
CI / update (push) Failing after 20s
2026-02-18 21:06:51 +01:00
b90a42b1aa recipes: add shared "to try" list for external recipes
Some checks failed
CI / update (push) Failing after 20s
Household-shared list of external recipes to try, with name, multiple
links, and optional notes. Includes add/edit/delete with confirmation.
Linked from the favorites page via a styled pill button.
2026-02-18 21:01:24 +01:00
7ba0995bf8 recipes: hide image-wrap background color during view transition morph
Some checks failed
CI / update (push) Failing after 21s
2026-02-18 20:37:22 +01:00
9177164ddf recipes: hero image view transition, skip transitions for recipe-to-recipe
Some checks failed
CI / update (push) Failing after 20s
2026-02-18 10:07:42 +01:00
207efcc38e recipes: view transitions for recipe detail navigation
All checks were successful
CI / update (push) Successful in 1m31s
Image morphs between CompactCard thumbnail and hero, title block
slides up from bottom, header persists across transitions. Only
activates for recipe detail navigations, not between list pages.
2026-02-17 18:59:24 +01:00
f074c0af08 recipes: drop opacity transition from TitleImgParallax hero image
All checks were successful
CI / update (push) Successful in 1m30s
Remove the opacity 0→1 fade-in transition — it's annoying when the
image is already cached. The dominant color background handles the
loading state, so no transition needed.
2026-02-17 18:34:58 +01:00
d0a01a75e7 recipes: sharpen Gaussian kernel for dominant color extraction
All checks were successful
CI / update (push) Successful in 1m36s
Reduce sigma from 0.3 to 0.15 * dimension so edge pixels contribute
under 1% weight, heavily biasing the color toward the image center.
2026-02-17 18:25:24 +01:00
53da9ad26d recipes: replace placeholder images with OKLAB dominant color backgrounds
Instead of generating/serving 20px placeholder images with blur CSS, extract
a perceptually accurate dominant color (Gaussian-weighted OKLAB average) and
use it as a solid background-color while the full image loads. Removes
placeholder image generation, blur CSS/JS, and placeholder directory references
across upload flows, API routes, service worker, and all card/hero components.
Adds admin bulk tool to backfill colors for existing recipes.
2026-02-17 18:25:17 +01:00
0ea09e424e recipes: two-column card grid on mobile, compact card sizing
All checks were successful
CI / update (push) Successful in 1m29s
2026-02-17 16:11:57 +01:00
716c6cc6e6 fix: use python3 for emoji codepoint extraction in font subsetting
All checks were successful
CI / update (push) Successful in 1m39s
grep -oP '.' splits multi-byte emoji into individual bytes when the
locale is not UTF-8 (e.g. CI runners with LANG=C), causing pyftsubset
to fail on invalid codepoints.
2026-02-17 16:05:55 +01:00
eeb3030186 fix: emoji font on recipe hero link, orange OR toggle for better contrast
All checks were successful
CI / update (push) Successful in 8s
2026-02-17 16:02:22 +01:00
16d891fc2f fix: render desktop nav at all widths when no links, fix profile menu positioning
All checks were successful
CI / update (push) Successful in 8s
Skip mobile sidebar/hamburger entirely when no links snippet is provided.
The nav with .no-links class stays in desktop layout at all screen widths.
Override UserHeader mobile styles from .no-links context to keep dropdown
opening downward with tail centered below the profile picture.
2026-02-17 15:59:13 +01:00
cf73e6b62f fix: language selector speech bubble, profile menu on mobile, hide redundant hamburger
All checks were successful
CI / update (push) Successful in 8s
- LanguageSelector: add speech bubble tail, replace green active with
  nord8 blue + dark text, remove floating gap
- Header: hide hamburger menu on mobile when no links, show profile
  picture directly in top bar instead
- UserHeader: center mobile dropdown, fix tail color/position, add
  profile picture overlay to tuck tail behind, add drop shadow
- Main layout: stop passing empty links snippet
2026-02-17 13:22:20 +01:00
8db7ca6bcc fix: LinksGrid lock icons use muted color, shrink on mobile, keep images larger
All checks were successful
CI / update (push) Successful in 9s
Decouple lock-icon fill from nth-child color cycling via :not(.lock-icon),
use subtle --nord3 fill in both themes, add responsive lock sizing, and
bump mobile image heights (72→90px, 48→64px).
2026-02-17 13:06:52 +01:00
13fd2143d9 recipes: compact tag/category pills with fluid scaling, add tag search
All checks were successful
CI / update (push) Successful in 8s
Shrink TagBall font/padding and TagCloud gap using clamp() for
fluid sizing across viewports. Add search input on the tags page
to filter through keywords.
2026-02-17 13:01:12 +01:00
a074fdc7e3 refactor: slimmer header, JS-less hamburger menu, bottom-aligned mobile nav
All checks were successful
CI / update (push) Successful in 9s
- Reduce header height to 3rem with CSS variable --header-h
- Scale logo via --symbol-size variable, decrease nav link font sizes
- Replace JS-driven sidebar toggle with checkbox hack (:has selector)
- Separate drop shadow into own element for correct z-index layering
  (top bar > sidebar > shadow)
- Bottom-align mobile nav links via ::before flex spacer
- Slide-in transition scoped to :has(:checked) to prevent resize artifacts
2026-02-17 10:32:02 +01:00
dbf6744479 fix: footer hidden behind recipe hero parallax section
The hero-section's scaleY transform created a stacking context that
painted over the footer, and margin-bottom: -20vh over-compensated
for the parallax gap, pulling the footer into the recipe cards.
Derive margin-bottom from actual parallax parameters and make the
footer position: relative so it paints above the transform layer.
2026-02-17 09:03:15 +01:00
28057e88d5 fix: LinksGrid shows 2 columns on mobile, scale down icons/text
Use CSS min() in grid minmax to guarantee 2 tiles side-by-side at
any viewport width. Add responsive breakpoints (560px, 410px) to
progressively shrink SVG height, font size, and spacing.
2026-02-17 08:44:04 +01:00
e58c8e46ef fonts: consolidate font-family to global stack, self-host subset emoji font
All checks were successful
CI / update (push) Successful in 8s
Remove redundant `font-family: sans-serif` from 18 component-level
declarations — they now inherit the Helvetica/Arial/Noto Sans stack
from the global `*` selector in app.css.

Add self-hosted NotoColorEmoji subset (56 KB, down from 11 MB) as
fallback for systems without the Noto Color Emoji font installed.
The subset is generated at prebuild time via pyftsubset with a fixed
list of the ~32 emojis actually used on the site.
2026-02-16 21:34:12 +01:00
2024551e0e fix: remove build warnings (unused CSS, a11y labels, npmrc)
All checks were successful
CI / update (push) Successful in 1m30s
- settings: remove unused form p/h4 CSS selectors
- prayers: remove unused .postcommunio-section/links CSS selectors
- RosarySvg: add aria-label to all bead hitbox anchors
- .npmrc: remove pnpm-only resolution-mode setting
2026-02-16 18:54:10 +01:00
c53aee7123 recipes: replace Card with CompactCard + CSS grid on all sub-pages
Migrate all recipe sub-pages from the old fixed-size Card component
inside flex-wrap Recipes wrapper to CompactCard with responsive CSS
grid for visual consistency with the main recipes page.
2026-02-16 18:47:12 +01:00
74 changed files with 1792 additions and 385 deletions

1
.npmrc
View File

@@ -1,2 +1 @@
engine-strict=true
resolution-mode=highest

View File

@@ -5,7 +5,7 @@
"type": "module",
"scripts": {
"dev": "vite dev",
"prebuild": "npx vite-node scripts/generate-mystery-verses.ts",
"prebuild": "bash scripts/subset-emoji-font.sh && npx vite-node scripts/generate-mystery-verses.ts",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",

54
scripts/subset-emoji-font.sh Executable file
View File

@@ -0,0 +1,54 @@
#!/usr/bin/env bash
# Subset NotoColorEmoji to only the emojis we actually use.
# Requires: fonttools (provides pyftsubset) and woff2 (provides woff2_compress)
#
# Source font: system-installed NotoColorEmoji.ttf
# Output: static/fonts/NotoColorEmoji.woff2 + .ttf
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
OUT_DIR="$PROJECT_ROOT/static/fonts"
SRC_FONT="/usr/share/fonts/noto/NotoColorEmoji.ttf"
if [ ! -f "$SRC_FONT" ]; then
echo "Error: Source font not found at $SRC_FONT" >&2
exit 1
fi
# ─── Fixed list of emojis to include ────────────────────────────────
# Recipe icons (from database + hardcoded)
# Season/liturgical: ☀️ ✝️ ❄️ 🌷 🍂 🎄 🐇
# Food/recipe: 🍽️ 🥫
# UI/cospend categories: 🛒 🛍️ 🚆 ⚡ 🎉 🤝 💸
# Status/feedback: ❤️ 🖤 ✅ ❌ 🚀 ⚠️ ✨ 🔄
# Features: 📋 🖼️ 📖 🤖 🌐 🔐 🔍 🚫
EMOJIS="☀✝❄🌷🍂🎄🐇🍽🥫🛒🛍🚆⚡🎉🤝💸❤🖤✅❌🚀⚠✨🔄📋🖼📖🤖🌐🔐🔍🚫"
# ────────────────────────────────────────────────────────────────────
# Build Unicode codepoint list from the emoji string (Python for reliable Unicode handling)
UNICODES=$(python3 -c "print(','.join(f'U+{ord(c):04X}' for c in '$EMOJIS'))")
GLYPH_COUNT=$(python3 -c "print(len('$EMOJIS'))")
echo "Subsetting NotoColorEmoji with $GLYPH_COUNT glyphs..."
# Subset to TTF
pyftsubset "$SRC_FONT" \
--unicodes="$UNICODES" \
--output-file="$OUT_DIR/NotoColorEmoji.ttf" \
--no-ignore-missing-unicodes
# Convert to WOFF2
woff2_compress "$OUT_DIR/NotoColorEmoji.ttf"
ORIG_SIZE=$(stat -c%s "$SRC_FONT" 2>/dev/null || stat -f%z "$SRC_FONT")
TTF_SIZE=$(stat -c%s "$OUT_DIR/NotoColorEmoji.ttf" 2>/dev/null || stat -f%z "$OUT_DIR/NotoColorEmoji.ttf")
WOFF2_SIZE=$(stat -c%s "$OUT_DIR/NotoColorEmoji.woff2" 2>/dev/null || stat -f%z "$OUT_DIR/NotoColorEmoji.woff2")
echo "Done!"
echo " Original: $(numfmt --to=iec "$ORIG_SIZE")"
echo " TTF: $(numfmt --to=iec "$TTF_SIZE")"
echo " WOFF2: $(numfmt --to=iec "$WOFF2_SIZE")"

View File

@@ -15,6 +15,15 @@
url(/fonts/crosses.ttf) format('truetype');
}
@font-face {
font-family: 'Noto Color Emoji Subset';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/NotoColorEmoji.woff2) format('woff2'),
url(/fonts/NotoColorEmoji.ttf) format('truetype');
}
/* ============================================
COLOR SYSTEM
Based on Nord Theme with semantic naming
@@ -294,7 +303,7 @@ a:focus-visible {
/* Icon badge (circular icon container) */
.g-icon-badge {
font-family: "Noto Color Emoji", emoji, sans-serif;
font-family: "Noto Color Emoji", "Noto Color Emoji Subset", emoji, sans-serif;
display: flex;
align-items: center;
justify-content: center;
@@ -344,3 +353,35 @@ a:focus-visible {
color: var(--nord0);
}
}
/* ============================================
RECIPE GRID
Responsive card grid used across recipe pages
============================================ */
.recipe-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.8em;
padding: 0 0.8em;
max-width: 1400px;
margin: 0 auto 2em;
}
@media (max-width: 250px) {
.recipe-grid {
grid-template-columns: 1fr;
}
}
@media (min-width: 600px) {
.recipe-grid {
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 1.5em;
padding: 0 1.5em;
}
}
@media (min-width: 1024px) {
.recipe-grid {
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 1.8em;
}
}

View File

@@ -41,6 +41,7 @@
<style>
.favorite-button {
all: unset;
font-family: "Noto Color Emoji", "Noto Color Emoji Subset", emoji, sans-serif;
font-size: 1.5rem;
cursor: pointer;
transition: var(--transition-fast);

View File

@@ -23,10 +23,9 @@ let underlineWidth = $state(0);
let disableTransition = $state(false);
function toggle_sidebar(state){
// state: force hidden state (optional)
const nav_el = document.querySelector("nav")
if(state === undefined) nav_el.hidden = !nav_el.hidden
else nav_el.hidden = state
const checkbox = document.getElementById('nav-toggle')
if(state === undefined) checkbox.checked = !checkbox.checked
else checkbox.checked = !state
}
function updateUnderline() {
@@ -93,16 +92,17 @@ nav{
background-color: var(--nord0);
top: 0;
z-index: 10;
display: flex !important;
display: flex;
flex-direction: row;
justify-content: space-between !important;
align-items: center;
box-shadow: 0 1em 1rem 0rem rgba(0,0,0,0.4);
height: 4rem;
height: var(--header-h);
padding-left: 0.5rem;
view-transition-name: site-header;
}
nav[hidden]{
display:block;
.nav-toggle{
display: none;
}
:global(.site_header li),
@@ -116,22 +116,20 @@ nav[hidden]{
:global(.site_header li>a)
{
text-decoration: none;
font-family: sans-serif;
font-size: 1.2rem;
font-size: 1rem;
color: inherit;
border-radius: var(--radius-pill);
padding: 0.5rem 0.75rem;
padding: 0.4rem 0.6rem;
}
:global(a.entry),
:global(a.entry:link),
:global(a.entry:visited)
{
text-decoration: none;
font-family: sans-serif;
font-size: 1.2rem;
font-size: 1rem;
color: white !important;
border-radius: var(--radius-pill);
padding: 0.5rem 0.75rem;
padding: 0.4rem 0.6rem;
}
:global(.site_header li:hover),
@@ -178,6 +176,9 @@ nav[hidden]{
display: none;
padding-inline: 0.5rem;
}
.header-shadow{
display: none;
}
.right-buttons{
display: flex;
align-items: center;
@@ -189,9 +190,10 @@ nav[hidden]{
gap: 0.5rem;
}
:global(svg.symbol){
height: 4rem;
width: 4rem;
--symbol-size: calc(var(--header-h) - 1rem);
width: var(--symbol-size);
border-radius: 10000px;
margin: 0.25rem;
}
/*:global(a:has(svg.symbol)){
padding: 0 !important;
@@ -200,6 +202,8 @@ nav[hidden]{
margin-left: 1rem;
}*/
.wrapper{
--header-h: 3rem;
--symbol-size: calc(var(--header-h) - 1rem);
display:flex;
flex-direction: column;
min-height: 100svh;
@@ -208,95 +212,111 @@ footer{
padding-block: 1rem;
text-align: center;
margin-top: auto;
position: relative;
}
@media screen and (max-width: 800px) {
.button_wrapper{
box-shadow: 0 1em 1rem 0rem rgba(0,0,0,0.4);
display: flex;
justify-content: space-between;
align-items: center;
position: sticky;
background-color: var(--nord0);
width: 100%;
height: 4rem;
height: var(--header-h);
top: 0;
z-index: 9999;
}
.nav_button{
border: unset;
background-color: unset;
.header-shadow{
display: block;
position: sticky;
top: 0;
width: 100%;
height: var(--header-h);
margin-top: calc(-1 * var(--header-h));
box-shadow: 0 1em 1rem 0rem rgba(0,0,0,0.4);
z-index: 9997;
pointer-events: none;
}
.nav_button{
display: inline-flex;
align-items: center;
justify-content: center;
fill: white;
margin-inline: 0.5rem;
width: 2rem;
aspect-ratio: 1;
width: 1.25rem;
height: 1.25rem;
cursor: pointer;
}
.nav_button svg{
width: 100%;
height: 100%;
transition: var(--transition-fast);
}
.nav_button:focus{
fill: var(--red);
.nav_button:hover,
.nav_button:active,
.nav-toggle:focus-visible + .nav_button{
fill: var(--nord8);
scale: 0.9;
}
.nav_site{
.nav_site:not(.no-links){
position: fixed;
top: 0;
right: 0;
height: 100vh; /* dvh does not work, breaks because of transition and only being applied after scroll ends*/
margin-bottom: 50vh;
width: min(95svw, 25em);
transition: transform 100ms;
z-index: 10;
z-index: 9998;
flex-direction: column;
justify-content: flex-start !important;
align-items: left;
justify-content: space-between!important;
padding-inline: 0.5rem;
}
:global(.nav_site ul){
.nav_site:not(.no-links)::before{
content: '';
flex: 1;
}
:global(.nav_site:not(.no-links) ul){
width: 100% ;
}
.nav_site :first-child{
.nav_site:not(.no-links) :first-child{
display:none;
}
.nav_site[hidden]{
.nav_site:not(.no-links){
transform: translateX(100%);
}
:global(.nav_site a:last-child){
.wrapper:has(.nav-toggle:checked) .nav_site:not(.no-links){
transform: translateX(0);
transition: transform 100ms;
}
:global(.nav_site:not(.no-links) a:last-child){
margin-bottom: 2rem;
}
.nav_site .links-wrapper {
align-self: flex-start;
.nav_site:not(.no-links) .links-wrapper {
width: 100%;
margin: 2rem;
padding: 0 2rem;
}
:global(.site_header){
flex-direction: column;
padding-top: min(10rem, 10vh);
align-items: flex-start;
}
:global(.site_header li, .site_header a){
font-size: 4rem;
font-size: 1.5rem;
}
:global(.site_header li > a, .site_header a){
font-size: 2rem;
font-size: 1.3rem;
}
:global(.site_header li:hover),
:global(.site_header li:focus-within){
transform: unset;
}
.nav_site .header-right{
.nav_site:not(.no-links) .header-right{
flex-direction: column;
position: absolute;
bottom: 2rem;
left: 50%;
transform: translateX(-50%);
}
.language-selector-desktop{
.nav_site:not(.no-links) .language-selector-desktop{
display: none;
}
.active-underline {
@@ -309,17 +329,44 @@ footer{
text-underline-offset: 0.3rem;
}
}
.no-links :global(button) {
margin-bottom: 0 !important;
}
.no-links :global(#options) {
top: calc(100% + 10px) !important;
bottom: unset !important;
right: 0 !important;
left: unset !important;
transform: none !important;
}
.no-links :global(.top.speech::after) {
border: 20px solid transparent !important;
border-bottom-color: var(--nord3) !important;
border-top: 0 !important;
top: -10px !important;
bottom: unset !important;
left: unset !important;
right: 0.25rem !important;
margin-left: 0 !important;
}
.no-links :global(button::before) {
display: none;
}
</style>
<div class=wrapper lang=de>
<div>
{#if links}
<div class=button_wrapper>
<a href="/" aria-label="Home"><Symbol></Symbol></a>
<div class="right-buttons">
{@render language_selector_mobile?.()}
<button class=nav_button onclick={() => {toggle_sidebar()}} aria-label="Toggle navigation menu"><svg xmlns="http://www.w3.org/2000/svg" height="0.5em" viewBox="0 0 448 512"><!--! Font Awesome Free 6.4.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. --><path d="M0 96C0 78.3 14.3 64 32 64H416c17.7 0 32 14.3 32 32s-14.3 32-32 32H32C14.3 128 0 113.7 0 96zM0 256c0-17.7 14.3-32 32-32H416c17.7 0 32 14.3 32 32s-14.3 32-32 32H32c-17.7 0-32-14.3-32-32zM448 416c0 17.7-14.3 32-32 32H32c-17.7 0-32-14.3-32-32s14.3-32 32-32H416c17.7 0 32 14.3 32 32z"/></svg></button>
<input type="checkbox" id="nav-toggle" class="nav-toggle" aria-label="Toggle navigation menu" />
<label for="nav-toggle" class=nav_button aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" height="0.5em" viewBox="0 0 448 512"><!--! Font Awesome Free 6.4.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. --><path d="M0 96C0 78.3 14.3 64 32 64H416c17.7 0 32 14.3 32 32s-14.3 32-32 32H32C14.3 128 0 113.7 0 96zM0 256c0-17.7 14.3-32 32-32H416c17.7 0 32 14.3 32 32s-14.3 32-32 32H32c-17.7 0-32-14.3-32-32zM448 416c0 17.7-14.3 32-32 32H32c-17.7 0-32-14.3-32-32s14.3-32 32-32H416c17.7 0 32 14.3 32 32z"/></svg></label>
</div>
</div>
<nav hidden class=nav_site>
<div class="header-shadow"></div>
{/if}
<nav class=nav_site class:no-links={!links}>
<a href="/" aria-label="Home"><Symbol></Symbol></a>
<div class="links-wrapper">
{@render links?.()}

View File

@@ -194,6 +194,15 @@
z-index: 1000;
display: none;
}
.language-options::after {
content: "";
border: 10px solid transparent;
border-bottom-color: var(--bg_color);
border-top: 0;
position: absolute;
top: -10px;
right: 1rem;
}
/* Show via JS toggle */
.language-options.open {
display: block;
@@ -222,7 +231,9 @@
background-color: var(--nord2);
}
.language-options a.active{
background-color: var(--nord14);
background-color: var(--nord8);
color: var(--nord0);
font-weight: 700;
}
</style>

View File

@@ -1,12 +1,12 @@
<style>
:global(.links_grid a:nth-child(4n)),
:global(.links_grid a:nth-child(4n) svg){
:global(.links_grid a:nth-child(4n) svg:not(.lock-icon)){
background-color: var(--nord4);
fill: var(--nord11);
}
:global(.links_grid a:nth-child(4n+1)),
:global(.links_grid a:nth-child(4n+1) svg){
:global(.links_grid a:nth-child(4n+1) svg:not(.lock-icon)){
background-color: var(--nord6);
fill: var(--nord10);
}
@@ -30,7 +30,7 @@
}
.links_grid{
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
grid-template-columns: repeat(auto-fit, minmax(min(250px, calc(50% - 1rem)), 1fr));
gap: 2rem;
max-width: 1000px;
margin-inline: auto;
@@ -64,8 +64,50 @@
right: 0.5rem;
width: 1.5rem;
height: 1.5rem;
fill: var(--nord0);
opacity: 0.6;
fill: var(--nord3);
opacity: 0.5;
}
@media (max-width: 560px) {
.links_grid {
gap: 1rem;
padding: 1.5rem 0.75rem;
}
:global(.links_grid a :is(svg, img)) {
height: 90px;
}
:global(.links_grid h3) {
font-size: 1.2rem;
}
:global(.links_grid a) {
padding: 0.75rem;
}
:global(.links_grid a .lock-icon) {
width: 1.2rem;
height: 1.2rem;
}
}
@media (max-width: 410px) {
.links_grid {
gap: 0.5rem;
padding: 1rem 0.5rem;
}
:global(.links_grid a :is(svg, img)) {
height: 64px;
}
:global(.links_grid h3) {
font-size: 0.95rem;
}
:global(.links_grid a) {
padding: 0.5rem;
}
:global(.links_grid a .lock-icon) {
width: 1rem;
height: 1rem;
top: 0.3rem;
right: 0.3rem;
}
}
@media (prefers-color-scheme: dark){
@@ -73,26 +115,25 @@
color: white;
}
:global(.links_grid a .lock-icon){
fill: white;
fill: var(--nord3);
}
:global(.links_grid a:nth-child(4n)),
:global(.links_grid a:nth-child(4n) svg){
:global(.links_grid a:nth-child(4n) svg:not(.lock-icon)){
background-color: var(--nord6-dark);
fill: var(--nord11);
}
:global(.links_grid a:nth-child(4n+1)),
:global(.links_grid a:nth-child(4n+1) svg){
:global(.links_grid a:nth-child(4n+1) svg:not(.lock-icon)){
background-color: var(--accent-dark);
fill: var(--nord9);
}
:global(.links_grid a:nth-child(4n+2)),
:global(.links_grid a:nth-child(4n+2) svg){
:global(.links_grid a:nth-child(4n+2) svg:not(.lock-icon)){
background-color: var(--nord1);
fill: var(--nord8);
}
:global(.links_grid a:nth-child(4n+3)),
:global(.links_grid a:nth-child(4n+3) svg){
:global(.links_grid a:nth-child(4n+3) svg:not(.lock-icon)){
background-color: var(--background-dark);
fill: var(--nord7);
}

View File

@@ -16,7 +16,6 @@
input {
all: unset;
box-sizing: border-box;
font-family: sans-serif;
background: var(--nord0);
color: #fff;
padding: 0.7rem 2rem;

View File

@@ -6,7 +6,7 @@
}
svg{
transition: var(--transition-fast);
height: 3em;
height: var(--symbol-size, 3em);
}
svg:hover,
svg:focus-visible

View File

@@ -5,10 +5,10 @@ let { tag, ref } = $props<{ tag: string, ref: string }>();
a{
background-color: var(--blue);
text-decoration: none;
padding: 2rem;
padding: clamp(0.4rem, 0.8vw, 0.8rem) clamp(0.8rem, 1.5vw, 1.5rem);
border-radius: 1000000px;
transition: var(--transition-fast);
font-size: 2rem;
font-size: clamp(0.85rem, 1.8vw, 1.5rem);
color: white;
}
a:hover{

View File

@@ -5,9 +5,8 @@ div{
flex-direction: row;
flex-wrap: wrap;
margin-inline:auto;
gap: 1rem;
gap: clamp(0.4rem, 1vw, 1rem);
justify-content: space-evenly;
font-family: sans-serif;
}
</style>

View File

@@ -81,6 +81,7 @@
background-color: var(--bg_color);
width: 30ch;
padding: 1rem;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
}
#options ul{
color: white;
@@ -116,22 +117,31 @@ h2 + p{
#options{
top: unset;
bottom: calc(100% + 15px);
right: -200%;
z-index: 99999999999999999999;
left: 50%;
right: unset;
transform: translateX(-50%);
z-index: 10;
}
.top.speech::after {
/* (B2-1) DOWN TRIANGLE */
border-top-color: #a53d38;
border-bottom: 0;
z-index: 99999999999999999999;
/* (B2-2) POSITION AT BOTTOM */
bottom: -20px; left: 50%;
border: 20px solid transparent;
border-top-color: var(--bg_color);
border-bottom-width: 0;
top: unset;
bottom: -20px;
left: 50%;
margin-left: -20px;
}
button{
margin-bottom: 2rem;
}
button::before{
content: "";
position: absolute;
inset: 0;
border-radius: 50%;
background: inherit;
z-index: 20;
}
}
</style>

View File

@@ -108,7 +108,6 @@ dialog[open]::backdrop {
dialog h2 {
font-size: 3rem;
font-family: sans-serif;
color: white;
text-align: center;
margin-top: 30vh;

View File

@@ -36,6 +36,8 @@ const img_name = $derived(
const img_alt = $derived(
recipe.images?.[0]?.alt || recipe.name
);
const img_color = $derived(recipe.images?.[0]?.color || '');
</script>
<style>
.card-main-link {
@@ -62,7 +64,6 @@ const img_alt = $derived(
transition: var(--transition-normal);
text-decoration: none;
box-sizing: border-box;
font-family: sans-serif;
cursor: pointer;
height: 525px;
width: 300px;
@@ -94,21 +95,16 @@ const img_alt = $derived(
transition: var(--transition-normal);
border-top-left-radius: inherit;
border-top-right-radius: inherit;
opacity: 0;
}
.blur{
filter: blur(10px);
}
.backdrop_blur{
backdrop-filter: blur(10px);
.image.loaded{
opacity: 1;
}
.card-image{
width: 300px;
height: 255px;
position: absolute;
top: 0;
background-repeat: no-repeat;
background-size: cover;
background-position: center;
overflow: hidden;
border-top-left-radius: inherit;
border-top-right-radius: inherit;
@@ -233,11 +229,11 @@ const img_alt = $derived(
<a href="{routePrefix}/{recipe.short_name}" class="card-main-link" aria-label="View recipe: {recipe.name}">
<span class="visually-hidden">View recipe: {recipe.name}</span>
</a>
<div class="card-image" style="background-image:url(https://bocken.org/static/rezepte/placeholder/{img_name})">
<div class="card-image" style:background-color={img_color}>
<noscript>
<img class="image backdrop_blur" src="https://bocken.org/static/rezepte/thumb/{img_name}" loading={loading_strat} alt="{img_alt}"/>
<img class="image loaded" src="https://bocken.org/static/rezepte/thumb/{img_name}" loading={loading_strat} alt="{img_alt}"/>
</noscript>
<img class="image backdrop_blur" class:blur={!isloaded} src={'https://bocken.org/static/rezepte/thumb/' + img_name} loading={loading_strat} alt="{img_alt}" onload={() => isloaded=true}/>
<img class="image" class:loaded={isloaded} src={'https://bocken.org/static/rezepte/thumb/' + img_name} loading={loading_strat} alt="{img_alt}" onload={() => isloaded=true}/>
</div>
{#if showFavoriteIndicator && isFavorite}
<div class="favorite-indicator">❤️</div>

View File

@@ -130,7 +130,6 @@ function remove_on_enter(event: KeyboardEvent, tag: string) {
text-decoration: none;
position: relative;
box-sizing: border-box;
font-family: sans-serif;
width: var(--card-width);
aspect-ratio: 4/7;
border-radius: var(--radius-card);

View File

@@ -134,7 +134,6 @@
input {
all: unset;
box-sizing: border-box;
font-family: sans-serif;
background: var(--nord0);
color: var(--nord6);
padding: 0.5rem 0.7rem;

View File

@@ -4,6 +4,7 @@
let {
recipe,
current_month = 0,
icon_override = false,
isFavorite = false,
showFavoriteIndicator = false,
loading_strat = "lazy",
@@ -19,7 +20,14 @@
recipe.images?.[0]?.alt || recipe.name
);
const isInSeason = $derived(recipe.season?.includes(current_month));
const img_color = $derived(recipe.images?.[0]?.color || '');
const isInSeason = $derived(icon_override || recipe.season?.includes(current_month));
function activateTransitions(event) {
const img = event.currentTarget.querySelector('.img-wrap img');
if (img) img.style.viewTransitionName = `recipe-${recipe.short_name}-img`;
}
</script>
<style>
.compact-card {
@@ -60,18 +68,27 @@
height: 100%;
object-fit: cover;
transition: transform 0.4s ease;
border-radius: var(--radius-card) var(--radius-card) 0 0;
}
.info {
position: relative;
padding: 0.8em 0.9em 0.7em;
padding: 0.5em 0.6em 0.5em;
flex: 1;
}
.name {
font-size: 1.1rem;
font-size: 0.85rem;
font-weight: 600;
line-height: 1.3;
margin: 0;
}
@media (min-width: 600px) {
.info {
padding: 0.8em 0.9em 0.7em;
}
.name {
font-size: 1.1rem;
}
}
.tags {
display: flex;
flex-wrap: wrap;
@@ -81,8 +98,8 @@
z-index: 2;
}
.tag {
font-size: 0.9rem;
padding: 0.15rem 0.55rem;
font-size: 0.7rem;
padding: 0.1rem 0.4rem;
border-radius: var(--radius-pill);
background-color: var(--nord5);
color: var(--nord3);
@@ -100,6 +117,12 @@
box-shadow: var(--shadow-hover);
color: var(--nord0);
}
@media (min-width: 600px) {
.tag {
font-size: 0.9rem;
padding: 0.15rem 0.55rem;
}
}
@media (prefers-color-scheme: dark) {
.tag,
.tag:visited,
@@ -119,11 +142,16 @@
right: 0.6em;
width: 2em;
height: 2em;
font-size: 1.2rem;
font-size: 1rem;
background-color: var(--nord0);
color: white;
z-index: 3;
}
@media (min-width: 600px) {
.icon {
font-size: 1.2rem;
}
}
.favorite {
position: absolute;
top: 0.5em;
@@ -135,16 +163,19 @@
}
</style>
<div class="compact-card">
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="compact-card" onclick={activateTransitions}>
<a href="{routePrefix}/{recipe.short_name}" class="card-link" aria-label={recipe.name}></a>
{#if showFavoriteIndicator && isFavorite}
<span class="favorite">❤️</span>
{/if}
<div class="img-wrap">
<div class="img-wrap" style:background-color={img_color}>
<img
src="https://bocken.org/static/rezepte/thumb/{img_name}"
alt={img_alt}
loading={loading_strat}
data-recipe={recipe.short_name}
/>
</div>
<div class="info">

View File

@@ -450,7 +450,6 @@ input.heading:hover{
--font_size: 1.5rem;
top: -1em;
left: -1em;
font-family: sans-serif;
font-size: 1.5rem;
background-color: var(--nord0);
color: var(--nord4);
@@ -471,7 +470,6 @@ input.heading:hover{
}
.add_ingredient{
font-family: sans-serif;
width: 100%;
display: flex;
flex-direction: row;
@@ -537,7 +535,6 @@ dialog .adder{
}
dialog h2{
font-size: 3rem;
font-family: sans-serif;
color: white;
text-align: center;
margin-top: 30vh;

View File

@@ -496,7 +496,6 @@ dialog .adder{
--font_size: 1.5rem;
top: -1em;
left: -1em;
font-family: sans-serif;
font-size: 1.5rem;
background-color: var(--nord0);
color: var(--nord4);
@@ -519,7 +518,6 @@ dialog .adder{
}
.add_step p{
font-family: sans-serif;
width: 100%;
font-size: 1.2rem;
border-radius: var(--radius-card);
@@ -550,7 +548,6 @@ dialog .adder{
}
dialog h2{
font-size: 3rem;
font-family: sans-serif;
color: white;
text-align: center;
margin-top: 30vh;

View File

@@ -25,7 +25,6 @@ textarea {
font-size: 1rem;
resize: vertical;
margin-top: 0.5em;
font-family: sans-serif;
background-color: transparent;
}
textarea::placeholder {

View File

@@ -4,7 +4,7 @@
</script>
<style>
a{
font-family: "Noto Color Emoji", emoji;
font-family: "Noto Color Emoji", "Noto Color Emoji Subset", emoji;
font-size: 2rem;
text-decoration: none;
padding: 0.5em;

View File

@@ -126,7 +126,7 @@
input {
all: unset;
box-sizing: border-box;
font-family: "Noto Color Emoji", emoji, sans-serif;
font-family: "Noto Color Emoji", "Noto Color Emoji Subset", emoji, sans-serif;
background: var(--nord0);
color: var(--nord6);
padding: 0.5rem 0.7rem;
@@ -146,7 +146,6 @@
input::placeholder {
color: var(--nord4);
font-family: sans-serif;
}
input:hover {

View File

@@ -26,7 +26,7 @@
<style>
a{
font-family: "Noto Color Emoji", emoji, sans-serif;
font-family: "Noto Color Emoji", "Noto Color Emoji Subset", emoji, sans-serif;
font-size: 2rem;
text-decoration: none;
padding: 0.5em;

View File

@@ -308,9 +308,6 @@ function adjust_amount(string, multiplier){
// No need for complex yeast toggle handling - everything is calculated server-side now
</script>
<style>
*{
font-family: sans-serif;
}
.ingredients{
flex-basis: 0;
flex-grow: 1;

View File

@@ -100,9 +100,6 @@ const labels = $derived({
});
</script>
<style>
*{
font-family: sans-serif;
}
ol li::marker{
font-weight: bold;
color: var(--blue);

View File

@@ -86,7 +86,7 @@
}
.toggle-switch.or-mode {
background: var(--nord13);
background: var(--nord12);
}
.toggle-knob {
@@ -121,7 +121,7 @@
}
.toggle-switch.or-mode + .mode-label.or {
color: var(--nord13);
color: var(--nord12);
}
</style>

View File

@@ -1,7 +1,6 @@
<script lang="ts">
import CardAdd from '$lib/components/recipes/CardAdd.svelte';
import MediaScroller from '$lib/components/recipes/MediaScroller.svelte';
import Card from '$lib/components/recipes/Card.svelte';
import Search from '$lib/components/recipes/Search.svelte';
import SeasonSelect from '$lib/components/recipes/SeasonSelect.svelte';
import CreateIngredientList from '$lib/components/recipes/CreateIngredientList.svelte';

View File

@@ -307,7 +307,6 @@
input#search {
all: unset;
box-sizing: border-box;
font-family: sans-serif;
background: var(--nord0);
color: #fff;
padding: 0.7rem 2rem;

View File

@@ -120,7 +120,6 @@
input {
all: unset;
box-sizing: border-box;
font-family: sans-serif;
background: var(--nord0);
color: var(--nord6);
padding: 0.5rem 0.7rem;

View File

@@ -28,7 +28,6 @@
<style>
a.month{
text-decoration: unset;
font-family: sans-serif;
border-radius: var(--radius-pill);
background-color: var(--blue);
color: var(--nord5);

View File

@@ -117,7 +117,6 @@
input {
all: unset;
box-sizing: border-box;
font-family: sans-serif;
background: var(--nord0);
color: var(--nord6);
padding: 0.5rem 0.7rem;

View File

@@ -1,16 +1,11 @@
<script>
import { onMount } from "svelte";
let { src, placeholder_src, alt = "", children } = $props();
let { src, color = '', alt = "", transitionName = '', children } = $props();
let isloaded = $state(false);
let isredirected = $state(false);
onMount(() => {
const el = document.querySelector("img")
if(el?.complete){
isloaded = true
}
fetch(src, { method: 'HEAD' })
.then(response => {
isredirected = response.redirected
@@ -21,10 +16,8 @@
if(isredirected){
return
}
if(document.querySelector("img").complete){
document.querySelector("#img_carousel").showModal();
}
}
function close_dialog_img(){
document.querySelector("#img_carousel").close();
}
@@ -79,21 +72,25 @@
margin: 0;
}
.image-wrap {
position: absolute;
top: 0;
left: 0;
right: 0;
margin-inline: auto;
width: min(1000px, 100dvw);
height: max(60dvh,600px);
overflow: hidden;
}
.image{
display: block;
position: absolute;
top: 0;
width: min(1000px, 100dvw);
z-index: -1;
opacity: 0;
transition: var(--transition-normal);
height: max(60dvh,600px);
object-fit: cover;
object-position: 50% 20%;
backdrop-filter: blur(20px);
filter: blur(20px);
z-index: -10;
}
.image-container::after {
@@ -106,34 +103,6 @@
:global(h1){
width: 100%;
}
.placeholder{
background-repeat: no-repeat;
background-size: cover;
background-position: 50% 20%;
position: absolute;
width: min(1000px, 100dvw);
height: max(60dvh,600px);
z-index: -2;
}
.placeholder_blur{
width: inherit;
height: inherit;
backdrop-filter: blur(20px);
}
div:has(.placeholder){
position: absolute;
top: 0;
left: 0;
right: 0;
margin-inline: auto;
width: min(1000px, 100dvw);
height: max(60dvh,600px);
overflow: hidden;
}
.unblur.image{
filter: blur(0px) !important;
opacity: 1;
}
/* DIALOG */
dialog{
@@ -174,15 +143,13 @@ dialog button{
<figure class="image-container">
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class:zoom-in={isloaded && !isredirected} onclick={show_dialog_img}>
<div class=placeholder style="background-image:url({placeholder_src})" >
<div class=placeholder_blur>
<img class="image" class:unblur={isloaded} {src} onload={() => {isloaded=true}} {alt}/>
</div>
<div class:zoom-in={!isredirected} onclick={show_dialog_img}>
<div class="image-wrap" style:background-color={color}>
<img class="image" {src} {alt} style:view-transition-name={transitionName || null}/>
</div>
<noscript>
<div class=placeholder style="background-image:url({placeholder_src})" >
<img class="image unblur" {src} onload={() => {isloaded=true}} {alt}/>
<div class="image-wrap" style:background-color={color}>
<img class="image" {src} {alt}/>
</div>
</noscript>
</div>
@@ -191,7 +158,7 @@ dialog button{
</section>
<dialog id=img_carousel>
<img class:unblur={isloaded} {src} {alt}>
<img {src} {alt}>
<button class=action_button onkeydown={(event) => do_on_key(event, 'Enter', false, close_dialog_img)} onclick={close_dialog_img}>
<Cross fill=white width=2rem height=2rem></Cross>
</button>

View File

@@ -0,0 +1,162 @@
<script>
let { item, ondelete, onedit, isEnglish = false } = $props();
function getDomain(url) {
try {
return new URL(url).hostname.replace(/^www\./, '');
} catch {
return url;
}
}
</script>
<style>
.card {
position: relative;
display: flex;
flex-direction: column;
border-radius: var(--radius-card);
overflow: hidden;
background: var(--color-surface);
box-shadow: 0 1px 4px rgba(0,0,0,0.08);
transition: transform var(--transition-normal), box-shadow var(--transition-normal);
}
.card:hover,
.card:focus-within {
transform: translateY(-5px);
box-shadow: 0 8px 24px rgba(0,0,0,0.12);
}
.accent {
height: 6px;
background: linear-gradient(90deg, var(--nord10), var(--nord9));
}
.body {
padding: 0.8em 0.9em 0.6em;
flex: 1;
display: flex;
flex-direction: column;
gap: 0.5em;
}
.name {
font-size: 1.1rem;
font-weight: 600;
line-height: 1.3;
margin: 0;
}
.links {
display: flex;
flex-wrap: wrap;
gap: 0.35em;
}
.link-pill {
font-size: 0.78rem;
padding: 0.15rem 0.55rem;
border-radius: var(--radius-pill);
background-color: var(--nord5);
color: var(--nord3);
text-decoration: none;
box-shadow: var(--shadow-sm);
transition: transform var(--transition-fast), background-color var(--transition-fast), box-shadow var(--transition-fast), color var(--transition-fast);
}
.link-pill:hover,
.link-pill:focus-visible {
transform: scale(1.05);
background-color: var(--nord8);
box-shadow: var(--shadow-hover);
color: var(--nord0);
}
@media (prefers-color-scheme: dark) {
.link-pill {
background-color: var(--nord0);
color: var(--nord4);
}
.link-pill:hover,
.link-pill:focus-visible {
background-color: var(--nord8);
color: var(--nord0);
}
}
.notes {
font-size: 0.85rem;
color: var(--nord3);
margin: 0;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
@media (prefers-color-scheme: dark) {
.notes {
color: var(--nord4);
}
}
.footer {
font-size: 0.72rem;
color: var(--nord3);
margin-top: auto;
padding-top: 0.3em;
}
@media (prefers-color-scheme: dark) {
.footer {
color: var(--nord4);
}
}
.card-btn {
position: absolute;
top: 0.5em;
background: var(--nord11);
color: white;
border: none;
border-radius: var(--radius-pill);
width: 1.6em;
height: 1.6em;
font-size: 0.85rem;
cursor: pointer;
display: grid;
place-items: center;
opacity: 0;
transition: opacity var(--transition-fast);
z-index: 2;
}
.card:hover .card-btn,
.card:focus-within .card-btn {
opacity: 1;
}
.delete-btn {
right: 0.5em;
}
.delete-btn:hover {
background: var(--nord12);
}
.edit-btn {
right: 2.4em;
background: var(--nord10);
}
.edit-btn:hover {
background: var(--nord9);
}
</style>
<div class="card">
<div class="accent"></div>
<button class="card-btn edit-btn" onclick={() => onedit(item)} aria-label={isEnglish ? 'Edit' : 'Bearbeiten'}></button>
<button class="card-btn delete-btn" onclick={() => ondelete(item._id)} aria-label={isEnglish ? 'Delete' : 'Löschen'}></button>
<div class="body">
<p class="name">{item.name}</p>
{#if item.links?.length}
<div class="links">
{#each item.links as link (link.url)}
<a class="link-pill g-pill" href={link.url} target="_blank" rel="noopener noreferrer">
{link.label || getDomain(link.url)}
</a>
{/each}
</div>
{/if}
{#if item.notes}
<p class="notes">{item.notes}</p>
{/if}
<div class="footer">
{isEnglish ? 'Added by' : 'Hinzugefügt von'} {item.addedBy}
</div>
</div>
</div>

View File

@@ -37,7 +37,7 @@ export function toBrief(recipe: any, recipeLang: string): BriefRecipeType {
name: recipe.translations.en.name,
short_name: recipe.translations.en.short_name,
images: recipe.images?.[0]
? [{ alt: recipe.images[0].alt, mediapath: recipe.images[0].mediapath }]
? [{ alt: recipe.images[0].alt, mediapath: recipe.images[0].mediapath, color: recipe.images[0].color }]
: [],
tags: recipe.translations.en.tags || [],
category: recipe.translations.en.category,
@@ -51,7 +51,7 @@ export function toBrief(recipe: any, recipeLang: string): BriefRecipeType {
return {
...recipe,
images: recipe.images?.[0]
? [{ alt: recipe.images[0].alt, mediapath: recipe.images[0].mediapath }]
? [{ alt: recipe.images[0].alt, mediapath: recipe.images[0].mediapath, color: recipe.images[0].color }]
: [],
} as BriefRecipeType;
}

View File

@@ -12,6 +12,7 @@ const RecipeSchema = new mongoose.Schema(
mediapath: {type: String, required: true}, // filename with hash for cache busting: e.g., "maccaroni.a1b2c3d4.webp"
alt: String,
caption: String,
color: String, // dominant color hex e.g. "#a1b2c3", used as loading placeholder
}],
description: {type: String, required: true},
note: {type: String},

18
src/models/ToTryRecipe.ts Normal file
View File

@@ -0,0 +1,18 @@
import mongoose from 'mongoose';
const ToTryRecipeSchema = new mongoose.Schema(
{
name: { type: String, required: true, trim: true },
links: [
{
url: { type: String, required: true },
label: { type: String, default: '' }
}
],
notes: { type: String, default: '' },
addedBy: { type: String, required: true }
},
{ timestamps: true }
);
export const ToTryRecipe = mongoose.model('ToTryRecipe', ToTryRecipeSchema);

View File

@@ -8,11 +8,6 @@ let user = $derived(data.session?.user);
</script>
<Header>
{#snippet links()}
<ul class=site_header>
</ul>
{/snippet}
{#snippet language_selector_mobile()}
<LanguageSelector />
{/snippet}

View File

@@ -36,8 +36,6 @@ form button:hover, form button:focus-visible {
scale: 1.1;
}
form button:active { background-color: var(--color-accent-active); }
form p { max-width: 400px; margin-top: 0; }
form h4 { margin-bottom: 0; }
@media screen and (max-width: 600px) {
form { margin-top: 0; }
}

View File

@@ -428,33 +428,6 @@ h1{
background-color: var(--nord5);
}
}
.postcommunio-section h2 {
text-align: center;
padding-bottom: 0.5em;
}
.postcommunio-links {
list-style: none;
padding: 0;
margin: 0;
text-align: center;
}
.postcommunio-links li {
margin: 0.75em 0;
}
.postcommunio-links a {
color: var(--nord8);
text-decoration: none;
font-size: 1.15em;
}
.postcommunio-links a:hover {
text-decoration: underline;
}
@media(prefers-color-scheme: light) {
.postcommunio-links a {
color: var(--nord10);
}
}
/* Seasonal badge */
.seasonal-badge {
display: inline-block;

View File

@@ -131,31 +131,31 @@
<!-- Invisible hitboxes for larger tap targets (anchor links for no-JS fallback) -->
<g class="hitboxes">
<!-- Cross hitbox -->
<a href="#cross"><rect x="-15" y="-30" width="80" height="80" data-section="cross" /></a>
<a href="#cross" aria-label="Cross"><rect x="-15" y="-30" width="80" height="80" data-section="cross" /></a>
<!-- Individual bead hitboxes -->
<a href="#lbead1"><circle cx="25" cy={pos.lbead1} r="25" data-section="lbead1" /></a>
<a href="#start1"><circle cx="25" cy={pos.start1} r="20" data-section="start1" /></a>
<a href="#start2"><circle cx="25" cy={pos.start2} r="20" data-section="start2" /></a>
<a href="#start3"><circle cx="25" cy={pos.start3} r="20" data-section="start3" /></a>
<a href="#lbead2"><circle cx="25" cy={pos.lbead2} r="25" data-section="lbead2" /></a>
<a href="#lbead1" aria-label="First large bead"><circle cx="25" cy={pos.lbead1} r="25" data-section="lbead1" /></a>
<a href="#start1" aria-label="First small bead"><circle cx="25" cy={pos.start1} r="20" data-section="start1" /></a>
<a href="#start2" aria-label="Second small bead"><circle cx="25" cy={pos.start2} r="20" data-section="start2" /></a>
<a href="#start3" aria-label="Third small bead"><circle cx="25" cy={pos.start3} r="20" data-section="start3" /></a>
<a href="#lbead2" aria-label="Second large bead"><circle cx="25" cy={pos.lbead2} r="25" data-section="lbead2" /></a>
<!-- Decade hitboxes -->
{#each [1, 2, 3, 4, 5] as d (d)}
{@const decadePos = pos[`secret${d}`]}
<a href={`#secret${d}`}><rect x="-15" y={decadePos - 2} width="80" height={DECADE_OFFSET + 9 * BEAD_SPACING + 12} data-section={`secret${d}`} /></a>
<a href={`#secret${d}`} aria-label={`Decade ${d}`}><rect x="-15" y={decadePos - 2} width="80" height={DECADE_OFFSET + 9 * BEAD_SPACING + 12} data-section={`secret${d}`} /></a>
{/each}
<!-- Transition bead hitboxes -->
{#each [1, 2, 3, 4] as d (d)}
<a href={`#secret${d}_transition`}><circle cx="25" cy={pos[`secret${d}_transition`]} r="25" data-section={`secret${d}_transition`} /></a>
<a href={`#secret${d}_transition`} aria-label={`Transition after decade ${d}`}><circle cx="25" cy={pos[`secret${d}_transition`]} r="25" data-section={`secret${d}_transition`} /></a>
{/each}
<a href="#final_transition"><circle cx="25" cy={pos.final_transition} r="25" data-section="final_transition" /></a>
<a href="#final_salve"><circle cx="25" cy={pos.final_salve} r="20" data-section="final_salve" /></a>
<a href="#final_schlussgebet"><circle cx="25" cy={pos.final_schlussgebet} r="20" data-section="final_schlussgebet" /></a>
<a href="#final_michael"><circle cx="25" cy={pos.final_michael} r="20" data-section="final_michael" /></a>
<a href="#final_paternoster"><circle cx="25" cy={pos.final_paternoster} r="25" data-section="final_paternoster" /></a>
<a href="#final_cross"><rect x="-15" y={pos.final_cross - 50} width="80" height="80" data-section="final_cross" /></a>
<a href="#final_transition" aria-label="Final transition"><circle cx="25" cy={pos.final_transition} r="25" data-section="final_transition" /></a>
<a href="#final_salve" aria-label="Salve Regina"><circle cx="25" cy={pos.final_salve} r="20" data-section="final_salve" /></a>
<a href="#final_schlussgebet" aria-label="Closing prayer"><circle cx="25" cy={pos.final_schlussgebet} r="20" data-section="final_schlussgebet" /></a>
<a href="#final_michael" aria-label="St. Michael prayer"><circle cx="25" cy={pos.final_michael} r="20" data-section="final_michael" /></a>
<a href="#final_paternoster" aria-label="Final Our Father"><circle cx="25" cy={pos.final_paternoster} r="25" data-section="final_paternoster" /></a>
<a href="#final_cross" aria-label="Final cross"><rect x="-15" y={pos.final_cross - 50} width="80" height="80" data-section="final_cross" /></a>
</g>
</svg>

View File

@@ -1,7 +1,47 @@
<script>
import '$lib/css/recipe-links.css';
import { page } from '$app/stores';
import { onNavigate } from '$app/navigation';
import Header from '$lib/components/Header.svelte'
onNavigate((navigation) => {
if (!document.startViewTransition) return;
// Only use view transitions when navigating to/from a recipe detail page
const toRecipe = navigation.to?.params?.name;
const fromRecipe = navigation.from?.params?.name;
if (!toRecipe && !fromRecipe) return;
if (fromRecipe && toRecipe) return; // recipe-to-recipe: no view transition
// Measure title block position so the slide animation covers exactly the right distance
const title = document.querySelector('[style*="view-transition-name: recipe-title"]');
if (title) {
const dist = window.innerHeight - title.getBoundingClientRect().top;
document.documentElement.style.setProperty('--title-slide', `${dist}px`);
}
return new Promise((resolve) => {
const vt = document.startViewTransition(async () => {
resolve();
await navigation.complete;
// Hide .image-wrap background so the color box doesn't show behind the morphing image
const wrap = document.querySelector('.image-wrap');
if (wrap) wrap.style.backgroundColor = 'transparent';
// Set view-transition-name on the matching CompactCard/hero image for reverse morph
if (fromRecipe) {
const card = document.querySelector(`img[data-recipe="${fromRecipe}"]`);
if (card) card.style.viewTransitionName = `recipe-${fromRecipe}-img`;
}
});
// Restore background color once transition finishes
vt.finished.then(() => {
const wrap = document.querySelector('.image-wrap');
if (wrap) wrap.style.backgroundColor = '';
});
});
});
import UserHeader from '$lib/components/UserHeader.svelte';
import LanguageSelector from '$lib/components/LanguageSelector.svelte';
import OfflineSyncButton from '$lib/components/OfflineSyncButton.svelte';

View File

@@ -37,6 +37,9 @@
const heroImg = $derived(
heroRecipe ? heroRecipe.images[0].mediapath : ''
);
const heroColor = $derived(
heroRecipe ? (heroRecipe.images[0].color || '') : ''
);
// Category chip state: 'all', 'season', or a category name
let activeChip = $state('all');
@@ -130,7 +133,7 @@
/* ─── Hero parallax (same scaleY technique as TitleImgParallax) ─── */
.hero-section {
--parallax-scale: 0.3;
margin-bottom: -20vh;
margin-bottom: calc(var(--parallax-scale) * (20vh - min(60vh, 520px)));
transform-origin: center top;
transform: translateY(-1rem) scaleY(calc(1 - var(--parallax-scale)));
}
@@ -222,6 +225,9 @@
.hero-featured .recipe-name {
font-weight: 600;
}
.hero-featured .recipe-icon {
font-family: "Noto Color Emoji", "Noto Color Emoji Subset", emoji, sans-serif;
}
.hero-featured .arrow-icon {
width: 0.7em;
height: 0.7em;
@@ -306,27 +312,6 @@
z-index: 10;
}
/* ─── Recipe grid ─── */
.recipe-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 1.5em;
padding: 0 1.5em;
max-width: 1400px;
margin: 0 auto 2em;
}
@media (min-width: 600px) {
.recipe-grid {
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
}
}
@media (min-width: 1024px) {
.recipe-grid {
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 1.8em;
}
}
.sentinel {
height: 1px;
}
@@ -361,12 +346,13 @@
{#if heroRecipe}
<section class="hero-section">
<figure class="hero">
<figure class="hero" style:background-color={heroColor}>
<img
class="hero-img"
src="https://bocken.org/static/rezepte/full/{heroImg}"
alt=""
loading="eager"
data-recipe={heroRecipe.short_name}
/>
<div class="hero-overlay"></div>
</figure>
@@ -375,8 +361,12 @@
<div class="hero-text">
<h1>{labels.title}</h1>
<p class="subheading">{labels.subheading}</p>
<a href="/{data.recipeLang}/{heroRecipe.short_name}" class="hero-featured">
<span class="recipe-name">{heroRecipe.icon} {@html heroRecipe.name}</span>
<a href="/{data.recipeLang}/{heroRecipe.short_name}" class="hero-featured"
onclick={() => {
const img = document.querySelector('.hero-img');
if (img) img.style.viewTransitionName = `recipe-${heroRecipe.short_name}-img`;
}}>
<span class="recipe-name"><span class="recipe-icon">{heroRecipe.icon}</span> {@html heroRecipe.name}</span>
<svg class="arrow-icon" xmlns="http://www.w3.org/2000/svg" viewBox="-10 -197 535 410"><path d="M503 31c12-13 12-33 0-46L343-175c-13-12-33-12-46 0-12 13-12 33 0 46L403-24H32C14-24 0-10 0 8s14 32 32 32h371L297 145c-12 13-12 33 0 46 13 12 33 12 46 0L503 31z"/></svg>
</a>

View File

@@ -40,7 +40,7 @@
`${data.germanShortName || data.short_name}.webp`
);
const hero_img_src = $derived("https://bocken.org/static/rezepte/full/" + img_filename);
const placeholder_src = $derived("https://bocken.org/static/rezepte/placeholder/" + img_filename);
const img_color = $derived(data.images?.[0]?.color || '');
// Get alt text from images array
const img_alt = $derived(data.images?.[0]?.alt || '');
@@ -106,9 +106,6 @@
});
</script>
<style>
*{
font-family: sans-serif;
}
h1{
text-align: center;
padding-block: 0.5em;
@@ -285,6 +282,20 @@ h2{
margin-bottom: 0;
}
/* View transition: slide title block up from bottom */
:global(::view-transition-new(recipe-title)) {
animation: slide-up 0.35s ease both;
}
:global(::view-transition-old(recipe-title)) {
animation: slide-down 0.25s ease both;
}
@keyframes slide-up {
from { transform: translateY(var(--title-slide, 100vh)); }
}
@keyframes slide-down {
to { transform: translateY(var(--title-slide, 100vh)); }
}
</style>
<svelte:head>
<title>{data.strippedName} - {labels.title}</title>
@@ -302,8 +313,8 @@ h2{
<link rel="alternate" hreflang="x-default" href="https://bocken.org/rezepte/{data.germanShortName}" />
</svelte:head>
<TitleImgParallax src={hero_img_src} {placeholder_src} alt={img_alt}>
<div class=title>
<TitleImgParallax src={hero_img_src} color={img_color} alt={img_alt} transitionName="recipe-{data.short_name}-img">
<div class=title style="view-transition-name: recipe-title">
{#if data.category}
<a class="category g-pill g-btn-dark" href='/{data.recipeLang}/category/{data.category}'>{data.category}</a>
{/if}

View File

@@ -69,7 +69,7 @@ export const actions = {
try {
console.log('[RecipeAdd] Starting image processing...');
// Process and save the image
const { filename } = await processAndSaveRecipeImage(
const { filename, color } = await processAndSaveRecipeImage(
recipeImage,
recipeData.short_name,
IMAGE_DIR
@@ -79,7 +79,8 @@ export const actions = {
recipeData.images = [{
mediapath: filename,
alt: '',
caption: ''
caption: '',
color
}];
} catch (imageError: any) {
console.error('[RecipeAdd] Image processing error:', imageError);

View File

@@ -0,0 +1,19 @@
import type { PageServerLoad } from './$types';
import { redirect, error } from '@sveltejs/kit';
export const load: PageServerLoad = async ({ locals, url }) => {
const session = await locals.auth();
if (!session?.user?.nickname) {
const callbackUrl = encodeURIComponent(url.pathname);
throw redirect(302, `/login?callbackUrl=${callbackUrl}`);
}
if (!session.user.groups?.includes('rezepte_users')) {
throw error(403, 'Zugriff verweigert. Du hast keine Berechtigung für diesen Bereich.');
}
return {
user: session.user
};
};

View File

@@ -0,0 +1,243 @@
<script>
import { onMount } from 'svelte';
let stats = $state({
totalWithImages: 0,
missingColor: 0,
withColor: 0,
});
let processing = $state(false);
let filter = $state('missing');
let limit = $state(50);
let results = $state([]);
let errorMsg = $state('');
onMount(async () => {
await fetchStats();
});
async function fetchStats() {
try {
const response = await fetch('/api/recalculate-image-colors');
if (response.ok) {
stats = await response.json();
}
} catch (err) {
console.error('Failed to fetch stats:', err);
}
}
async function processBatch() {
processing = true;
errorMsg = '';
results = [];
try {
const response = await fetch('/api/recalculate-image-colors', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filter, limit }),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.message || 'Failed to process batch');
}
results = data.results || [];
await fetchStats();
} catch (err) {
errorMsg = err instanceof Error ? err.message : 'An error occurred';
} finally {
processing = false;
}
}
</script>
<style>
.container {
max-width: 1200px;
margin: 2rem auto;
padding: 2rem;
}
h1 {
color: var(--nord0);
margin-bottom: 2rem;
}
@media (prefers-color-scheme: dark) {
h1 { color: white; }
}
.stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
margin-bottom: 2rem;
}
.stat-card {
padding: 1.5rem;
background-color: var(--nord6);
border-radius: 0.5rem;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
@media (prefers-color-scheme: dark) {
.stat-card { background-color: var(--nord0); }
}
.stat-label {
font-size: 0.9rem;
color: var(--nord3);
margin-bottom: 0.5rem;
}
.stat-value {
font-size: 2rem;
font-weight: bold;
color: var(--nord10);
}
.controls {
background-color: var(--nord6);
padding: 1.5rem;
border-radius: 0.5rem;
margin-bottom: 2rem;
}
@media (prefers-color-scheme: dark) {
.controls { background-color: var(--nord1); }
}
.control-group {
margin-bottom: 1rem;
}
label {
display: block;
margin-bottom: 0.5rem;
font-weight: bold;
}
select, input {
padding: 0.5rem;
border-radius: 0.25rem;
border: 1px solid var(--nord4);
background-color: white;
}
@media (prefers-color-scheme: dark) {
select, input {
background-color: var(--nord0);
color: white;
border-color: var(--nord2);
}
}
button {
padding: 0.75rem 1.5rem;
background-color: var(--nord8);
color: white;
border: none;
border-radius: 0.25rem;
cursor: pointer;
font-size: 1rem;
font-weight: bold;
transition: background-color 0.2s;
}
button:hover { background-color: var(--nord7); }
button:disabled {
background-color: var(--nord3);
cursor: not-allowed;
}
.results {
margin-top: 2rem;
}
.result-item {
padding: 1rem;
background-color: var(--nord6);
border-radius: 0.25rem;
margin-bottom: 0.5rem;
display: flex;
align-items: center;
gap: 1rem;
}
@media (prefers-color-scheme: dark) {
.result-item { background-color: var(--nord1); }
}
.color-swatch {
width: 2.5rem;
height: 2.5rem;
border-radius: 0.25rem;
border: 1px solid var(--nord4);
flex-shrink: 0;
}
.result-info {
flex: 1;
}
.result-error {
color: var(--nord11);
font-size: 0.85rem;
}
.error {
padding: 1rem;
background-color: var(--nord11);
color: white;
border-radius: 0.25rem;
margin-bottom: 1rem;
}
</style>
<div class="container">
<h1>Image Dominant Colors</h1>
<div class="stats">
<div class="stat-card">
<div class="stat-label">Recipes with Images</div>
<div class="stat-value">{stats.totalWithImages}</div>
</div>
<div class="stat-card">
<div class="stat-label">Missing Color</div>
<div class="stat-value">{stats.missingColor}</div>
</div>
<div class="stat-card">
<div class="stat-label">With Color</div>
<div class="stat-value">{stats.withColor}</div>
</div>
</div>
<div class="controls">
<div class="control-group">
<label for="filter">Filter:</label>
<select id="filter" bind:value={filter}>
<option value="missing">Only Missing Colors</option>
<option value="all">All Recipes (Recalculate)</option>
</select>
</div>
<div class="control-group">
<label for="limit">Batch Size:</label>
<input id="limit" type="number" bind:value={limit} min="1" max="500" />
</div>
<button onclick={processBatch} disabled={processing}>
{processing ? 'Processing...' : 'Extract Colors'}
</button>
</div>
{#if errorMsg}
<div class="error">{errorMsg}</div>
{/if}
{#if results.length > 0}
<div class="results">
<h2>Results ({results.filter(r => r.status === 'ok').length} ok, {results.filter(r => r.status === 'error').length} failed)</h2>
{#each results as result}
<div class="result-item">
{#if result.status === 'ok'}
<div class="color-swatch" style="background-color: {result.color}"></div>
{/if}
<div class="result-info">
<strong>{result.name}</strong> ({result.shortName})
{#if result.status === 'ok'}
<code>{result.color}</code>
{/if}
{#if result.status === 'error'}
<div class="result-error">{result.error}</div>
{/if}
</div>
</div>
{/each}
</div>
{/if}
</div>

View File

@@ -1,7 +1,6 @@
<script lang="ts">
import type { PageData } from './$types';
import Recipes from '$lib/components/recipes/Recipes.svelte';
import Card from '$lib/components/recipes/Card.svelte';
import CompactCard from '$lib/components/recipes/CompactCard.svelte';
let { data } = $props<{ data: PageData }>();
let current_month = new Date().getMonth() + 1;
@@ -79,6 +78,31 @@ h1 {
color: var(--nord2);
}
}
.card-wrapper {
position: relative;
}
.translation-badge {
position: absolute;
top: 0.5rem;
right: 0.5rem;
padding: 0.4rem 0.8rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 600;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
z-index: 3;
color: var(--nord0);
pointer-events: none;
}
.translation-badge.none {
background-color: var(--nord14);
}
.translation-badge.pending {
background-color: var(--nord13);
}
.translation-badge.needs_update {
background-color: var(--nord12);
}
.empty-state {
text-align: center;
margin-top: 3rem;
@@ -121,16 +145,26 @@ h1 {
</div>
</div>
<Recipes>
{#each data.untranslated as recipe}
<Card
<div class="recipe-grid">
{#each data.untranslated as recipe (recipe._id)}
<div class="card-wrapper">
<CompactCard
{recipe}
{current_month}
routePrefix="/{data.recipeLang}"
translationStatus={recipe.translationStatus}
></Card>
/>
<div class="translation-badge {recipe.translationStatus || 'none'}">
{#if recipe.translationStatus === 'pending'}
Freigabe ausstehend
{:else if recipe.translationStatus === 'needs_update'}
Aktualisierung erforderlich
{:else}
Keine Übersetzung
{/if}
</div>
</div>
{/each}
</Recipes>
</div>
{:else}
<div class="empty-state">
<p>Alle Rezepte sind übersetzt!</p>

View File

@@ -25,6 +25,14 @@
: 'Alternativtext für Rezeptbilder mit KI generieren',
href: `/${data.recipeLang}/admin/alt-text-generator`,
icon: '🖼️'
},
{
title: isEnglish ? 'Image Colors' : 'Bildfarben',
description: isEnglish
? 'Extract dominant colors from recipe images for loading placeholders'
: 'Dominante Farben aus Rezeptbildern für Ladeplatzhalter extrahieren',
href: `/${data.recipeLang}/admin/image-colors`,
icon: '🎨'
}
];
</script>

View File

@@ -17,7 +17,7 @@
<style>
h1 {
text-align: center;
font-size: 3rem;
font-size: 1.5rem;
}
</style>
<h1>{labels.title}</h1>

View File

@@ -1,8 +1,7 @@
<script lang="ts">
import type { PageData } from './$types';
import Recipes from '$lib/components/recipes/Recipes.svelte';
import Search from '$lib/components/recipes/Search.svelte';
import Card from '$lib/components/recipes/Card.svelte';
import CompactCard from '$lib/components/recipes/CompactCard.svelte';
import { rand_array } from '$lib/js/randomize';
import { createSearchFilter } from '$lib/js/searchFilter.svelte';
@@ -28,10 +27,8 @@
<h1>{label} <q>{data.category}</q>:</h1>
<Search category={data.category} lang={data.lang} recipes={data.recipes} isLoggedIn={!!data.session?.user} onSearchResults={handleSearchResults}></Search>
<section>
<Recipes>
{#each rand_array(filteredRecipes) as recipe}
<Card {recipe} {current_month} isFavorite={recipe.isFavorite} showFavoriteIndicator={!!data.session?.user} routePrefix="/{data.recipeLang}"></Card>
<div class="recipe-grid">
{#each rand_array(filteredRecipes) as recipe (recipe._id)}
<CompactCard {recipe} {current_month} isFavorite={recipe.isFavorite} showFavoriteIndicator={!!data.session?.user} routePrefix="/{data.recipeLang}" />
{/each}
</Recipes>
</section>
</div>

View File

@@ -22,7 +22,7 @@ import {
async function deleteRecipeImage(filename: string): Promise<void> {
if (!filename) return;
const imageDirectories = ['full', 'thumb', 'placeholder'];
const imageDirectories = ['full', 'thumb'];
// Extract basename to handle both hashed and unhashed versions
const basename = filename
@@ -119,7 +119,7 @@ export const actions = {
if (recipeImage && recipeImage.size > 0) {
try {
// Process and save the new image
const { filename } = await processAndSaveRecipeImage(
const { filename, color } = await processAndSaveRecipeImage(
recipeImage,
recipeData.short_name,
IMAGE_DIR
@@ -133,7 +133,8 @@ export const actions = {
recipeData.images = [{
mediapath: filename,
alt: existingImagePath ? (recipeData.images?.[0]?.alt || '') : '',
caption: existingImagePath ? (recipeData.images?.[0]?.caption || '') : ''
caption: existingImagePath ? (recipeData.images?.[0]?.caption || '') : '',
color
}];
} catch (imageError: any) {
console.error('Image processing error:', imageError);
@@ -161,7 +162,7 @@ export const actions = {
// Handle short_name change (rename images)
if (originalShortName !== recipeData.short_name) {
const imageDirectories = ['full', 'thumb', 'placeholder'];
const imageDirectories = ['full', 'thumb'];
for (const dir of imageDirectories) {
const oldPath = join(IMAGE_DIR, 'rezepte', dir, `${originalShortName}.webp`);

View File

@@ -1,7 +1,6 @@
<script lang="ts">
import type { PageData } from './$types';
import Recipes from '$lib/components/recipes/Recipes.svelte';
import Card from '$lib/components/recipes/Card.svelte';
import CompactCard from '$lib/components/recipes/CompactCard.svelte';
import Search from '$lib/components/recipes/Search.svelte';
import { createSearchFilter } from '$lib/js/searchFilter.svelte';
@@ -26,7 +25,8 @@
emptyState2: isEnglish
? 'Visit a recipe and click the heart icon to add it to your favorites.'
: 'Besuche ein Rezept und klicke auf das Herz-Symbol, um es zu deinen Favoriten hinzuzufügen.',
recipesLink: isEnglish ? 'recipe' : 'Rezept'
recipesLink: isEnglish ? 'recipe' : 'Rezept',
toTry: isEnglish ? 'Recipes to try' : 'Zum Ausprobieren'
});
const { filtered: filteredFavorites, handleSearchResults } = createSearchFilter(() => data.favorites);
@@ -48,6 +48,28 @@ h1{
margin-top: 3rem;
color: var(--nord3);
}
.to-try-link{
text-align: center;
margin-bottom: 1.5em;
}
.to-try-link a{
display: inline-block;
padding: 0.4em 1.2em;
border-radius: var(--radius-pill);
background: var(--nord10);
color: var(--nord6);
text-decoration: none;
font-size: 0.95rem;
font-weight: 500;
box-shadow: var(--shadow-sm);
transition: transform var(--transition-fast), background-color var(--transition-fast), box-shadow var(--transition-fast);
}
.to-try-link a:hover,
.to-try-link a:focus-visible{
transform: scale(1.05);
background: var(--nord9);
box-shadow: var(--shadow-hover);
}
</style>
<svelte:head>
@@ -64,16 +86,18 @@ h1{
{/if}
</p>
<p class="to-try-link"><a href="/{data.recipeLang}/to-try">{labels.toTry} &rarr;</a></p>
<Search favoritesOnly={true} lang={data.lang} recipes={data.favorites} isLoggedIn={!!data.session?.user} onSearchResults={handleSearchResults}></Search>
{#if data.error}
<p class="empty-state">{labels.errorLoading} {data.error}</p>
{:else if filteredFavorites.length > 0}
<Recipes>
{#each filteredFavorites as recipe}
<Card {recipe} {current_month} isFavorite={true} showFavoriteIndicator={true} routePrefix="/{data.recipeLang}"></Card>
<div class="recipe-grid">
{#each filteredFavorites as recipe (recipe._id)}
<CompactCard {recipe} {current_month} isFavorite={true} showFavoriteIndicator={true} routePrefix="/{data.recipeLang}" />
{/each}
</Recipes>
</div>
{:else if data.favorites.length > 0}
<div class="empty-state">
<p>{isEnglish ? 'No matching favorites found.' : 'Keine passenden Favoriten gefunden.'}</p>

View File

@@ -1,10 +1,5 @@
<script lang="ts">
import type { PageData } from './$types';
import Recipes from '$lib/components/recipes/Recipes.svelte';
import MediaScroller from '$lib/components/recipes/MediaScroller.svelte';
import SeasonLayout from '$lib/components/recipes/SeasonLayout.svelte'
import Card from '$lib/components/recipes/Card.svelte';
import Search from '$lib/components/recipes/Search.svelte';
let { data } = $props<{ data: PageData }>();
const isEnglish = $derived(data.lang === 'en');
@@ -19,7 +14,7 @@
</svelte:head>
<style>
a{
font-family: "Noto Color Emoji", emoji, sans-serif;
font-family: "Noto Color Emoji", "Noto Color Emoji Subset", emoji, sans-serif;
--padding: 0.5em;
font-size: 3rem;
text-decoration: none;

View File

@@ -1,10 +1,7 @@
<script lang="ts">
import type { PageData } from './$types';
import Recipes from '$lib/components/recipes/Recipes.svelte';
import IconLayout from '$lib/components/recipes/IconLayout.svelte';
import MediaScroller from '$lib/components/recipes/MediaScroller.svelte';
import Card from '$lib/components/recipes/Card.svelte';
import Search from '$lib/components/recipes/Search.svelte';
import CompactCard from '$lib/components/recipes/CompactCard.svelte';
let { data } = $props<{ data: PageData }>();
import { rand_array } from '$lib/js/randomize';
@@ -36,10 +33,10 @@
<IconLayout icons={data.icons} active_icon={data.icon} routePrefix="/{data.recipeLang}" lang={data.lang} recipes={data.season} isLoggedIn={!!data.session?.user} onSearchResults={handleSearchResults}>
{#snippet recipesSlot()}
<Recipes>
{#each rand_array(filteredRecipes) as recipe}
<Card {recipe} icon_override=true isFavorite={recipe.isFavorite} showFavoriteIndicator={!!data.session?.user} routePrefix="/{data.recipeLang}"></Card>
<div class="recipe-grid">
{#each rand_array(filteredRecipes) as recipe (recipe._id)}
<CompactCard {recipe} icon_override={true} isFavorite={recipe.isFavorite} showFavoriteIndicator={!!data.session?.user} routePrefix="/{data.recipeLang}" />
{/each}
</Recipes>
</div>
{/snippet}
</IconLayout>

View File

@@ -1,8 +1,7 @@
<script lang="ts">
import type { PageData } from './$types';
import Recipes from '$lib/components/recipes/Recipes.svelte';
import Search from '$lib/components/recipes/Search.svelte';
import Card from '$lib/components/recipes/Card.svelte';
import CompactCard from '$lib/components/recipes/CompactCard.svelte';
let { data } = $props<{ data: PageData }>();
let current_month = new Date().getMonth() + 1;
@@ -115,11 +114,11 @@
{/if}
{#if displayedRecipes.length > 0}
<Recipes>
{#each displayedRecipes as recipe}
<Card {recipe} {current_month} isFavorite={recipe.isFavorite} showFavoriteIndicator={true} routePrefix="/{data.recipeLang}"></Card>
<div class="recipe-grid">
{#each displayedRecipes as recipe (recipe._id)}
<CompactCard {recipe} {current_month} isFavorite={recipe.isFavorite} showFavoriteIndicator={true} routePrefix="/{data.recipeLang}" />
{/each}
</Recipes>
</div>
{:else if (data.query || hasActiveSearch) && !data.error}
<div class="search-info">
<p>{labels.noResults}</p>

View File

@@ -1,10 +1,7 @@
<script lang="ts">
import type { PageData } from './$types';
import Recipes from '$lib/components/recipes/Recipes.svelte';
import MediaScroller from '$lib/components/recipes/MediaScroller.svelte';
import SeasonLayout from '$lib/components/recipes/SeasonLayout.svelte'
import Card from '$lib/components/recipes/Card.svelte';
import Search from '$lib/components/recipes/Search.svelte';
import CompactCard from '$lib/components/recipes/CompactCard.svelte';
let { data } = $props<{ data: PageData }>();
let current_month = new Date().getMonth() + 1
import { rand_array } from '$lib/js/randomize';
@@ -43,10 +40,10 @@
<SeasonLayout active_index={current_month-1} {months} routePrefix="/{data.recipeLang}" lang={data.lang} recipes={data.season} isLoggedIn={!!data.session?.user} onSearchResults={handleSearchResults}>
{#snippet recipesSlot()}
<Recipes>
{#each rand_array(filteredRecipes) as recipe}
<Card {recipe} {current_month} isFavorite={recipe.isFavorite} showFavoriteIndicator={!!data.session?.user} routePrefix="/{data.recipeLang}"></Card>
<div class="recipe-grid">
{#each rand_array(filteredRecipes) as recipe (recipe._id)}
<CompactCard {recipe} {current_month} isFavorite={recipe.isFavorite} showFavoriteIndicator={!!data.session?.user} routePrefix="/{data.recipeLang}" />
{/each}
</Recipes>
</div>
{/snippet}
</SeasonLayout>

View File

@@ -1,10 +1,7 @@
<script lang="ts">
import type { PageData } from './$types';
import Recipes from '$lib/components/recipes/Recipes.svelte';
import SeasonLayout from '$lib/components/recipes/SeasonLayout.svelte';
import MediaScroller from '$lib/components/recipes/MediaScroller.svelte';
import Card from '$lib/components/recipes/Card.svelte';
import Search from '$lib/components/recipes/Search.svelte';
import CompactCard from '$lib/components/recipes/CompactCard.svelte';
let { data } = $props<{ data: PageData }>();
const isEnglish = $derived(data.lang === 'en');
@@ -41,10 +38,10 @@
<SeasonLayout active_index={data.month -1} {months} routePrefix="/{data.recipeLang}" lang={data.lang} recipes={data.season} onSearchResults={handleSearchResults}>
{#snippet recipesSlot()}
<Recipes>
{#each rand_array(filteredRecipes) as recipe}
<Card {recipe} icon_override=true isFavorite={recipe.isFavorite} showFavoriteIndicator={!!data.session?.user} routePrefix="/{data.recipeLang}"></Card>
<div class="recipe-grid">
{#each rand_array(filteredRecipes) as recipe (recipe._id)}
<CompactCard {recipe} icon_override={true} isFavorite={recipe.isFavorite} showFavoriteIndicator={!!data.session?.user} routePrefix="/{data.recipeLang}" />
{/each}
</Recipes>
</div>
{/snippet}
</SeasonLayout>

View File

@@ -7,8 +7,16 @@
const isEnglish = $derived(data.lang === 'en');
const labels = $derived({
title: isEnglish ? 'Keywords' : 'Stichwörter',
siteTitle: isEnglish ? 'Bocken Recipes' : 'Bocken Rezepte'
siteTitle: isEnglish ? 'Bocken Recipes' : 'Bocken Rezepte',
search: isEnglish ? 'Search tags...' : 'Tags suchen...'
});
let query = $state('');
const filteredTags = $derived(
query
? data.tags.filter(t => t.toLowerCase().includes(query.toLowerCase()))
: data.tags
);
</script>
<svelte:head>
@@ -16,14 +24,38 @@
</svelte:head>
<style>
h1 {
font-size: 3rem;
font-size: 1.5rem;
text-align: center;
}
.search-wrap {
max-width: 400px;
margin: 0 auto 1rem;
padding-inline: 1rem;
}
input {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid var(--nord4);
border-radius: var(--radius-pill, 999px);
font-size: 0.9rem;
background: var(--nord6, #eceff4);
color: var(--nord0);
}
@media (prefers-color-scheme: dark) {
input {
background: var(--nord1);
border-color: var(--nord3);
color: var(--nord6);
}
}
</style>
<h1>{labels.title}</h1>
<div class="search-wrap">
<input type="search" placeholder={labels.search} bind:value={query} />
</div>
<section>
<TagCloud>
{#each data.tags as tag}
{#each filteredTags as tag}
<TagBall {tag} ref="/{data.recipeLang}/tag">
</TagBall>
{/each}

View File

@@ -1,7 +1,6 @@
<script lang="ts">
import type { PageData } from './$types';
import Recipes from '$lib/components/recipes/Recipes.svelte';
import Card from '$lib/components/recipes/Card.svelte';
import CompactCard from '$lib/components/recipes/CompactCard.svelte';
import Search from '$lib/components/recipes/Search.svelte';
import { rand_array } from '$lib/js/randomize';
import { createSearchFilter } from '$lib/js/searchFilter.svelte';
@@ -28,10 +27,8 @@
<h1>{label} <q>{data.tag}</q>:</h1>
<Search tag={data.tag} lang={data.lang} recipes={data.recipes} isLoggedIn={!!data.session?.user} onSearchResults={handleSearchResults}></Search>
<section>
<Recipes>
{#each rand_array(filteredRecipes) as recipe}
<Card {recipe} {current_month} isFavorite={recipe.isFavorite} showFavoriteIndicator={!!data.session?.user} routePrefix="/{data.recipeLang}"></Card>
<div class="recipe-grid">
{#each rand_array(filteredRecipes) as recipe (recipe._id)}
<CompactCard {recipe} {current_month} isFavorite={recipe.isFavorite} showFavoriteIndicator={!!data.session?.user} routePrefix="/{data.recipeLang}" />
{/each}
</Recipes>
</section>
</div>

View File

@@ -0,0 +1,27 @@
import type { PageServerLoad } from "./$types";
import { redirect } from '@sveltejs/kit';
import { ToTryRecipe } from '$models/ToTryRecipe';
import { dbConnect } from '$utils/db';
export const load: PageServerLoad = async ({ locals, params }) => {
const session = await locals.auth();
if (!session?.user?.nickname) {
throw redirect(302, `/${params.recipeLang}`);
}
await dbConnect();
try {
const items = await ToTryRecipe.find().sort({ createdAt: -1 }).lean();
return {
items: JSON.parse(JSON.stringify(items)),
session
};
} catch (e) {
return {
items: [],
error: 'Failed to load to-try recipes'
};
}
};

View File

@@ -0,0 +1,327 @@
<script>
import ToTryCard from '$lib/components/recipes/ToTryCard.svelte';
let { data } = $props();
let items = $state(data.items ?? []);
const isEnglish = $derived(data.lang === 'en');
const labels = $derived({
title: isEnglish ? 'To Try' : 'Zum Ausprobieren',
pageTitle: isEnglish ? 'Recipes To Try - Bocken Recipes' : 'Zum Ausprobieren - Bocken Rezepte',
metaDescription: isEnglish
? 'Recipes we want to try from around the web.'
: 'Rezepte, die wir ausprobieren wollen.',
count: isEnglish
? `${items.length} recipe${items.length !== 1 ? 's' : ''} to try`
: `${items.length} Rezept${items.length !== 1 ? 'e' : ''} zum Ausprobieren`,
noItems: isEnglish ? 'Nothing here yet' : 'Noch nichts vorhanden',
emptyState: isEnglish
? 'Add a recipe you want to try using the form below.'
: 'Füge ein Rezept hinzu, das du ausprobieren möchtest.',
name: isEnglish ? 'Recipe name' : 'Rezeptname',
url: 'URL',
label: isEnglish ? 'Label (optional)' : 'Bezeichnung (optional)',
notes: isEnglish ? 'Notes (optional)' : 'Notizen (optional)',
addLink: isEnglish ? 'Add link' : 'Link hinzufügen',
save: isEnglish ? 'Save' : 'Speichern',
cancel: isEnglish ? 'Cancel' : 'Abbrechen',
add: isEnglish ? 'Add recipe to try' : 'Rezept hinzufügen',
editHeading: isEnglish ? 'Edit recipe' : 'Rezept bearbeiten'
});
let showForm = $state(false);
let saving = $state(false);
let editingId = $state(null);
// Form state
let name = $state('');
let links = $state([{ url: '', label: '' }]);
let notes = $state('');
function addLinkRow() {
links.push({ url: '', label: '' });
}
function removeLinkRow(index) {
links.splice(index, 1);
}
function resetForm() {
name = '';
links = [{ url: '', label: '' }];
notes = '';
editingId = null;
showForm = false;
}
function handleEdit(item) {
name = item.name;
links = item.links.map(l => ({ url: l.url, label: l.label || '' }));
notes = item.notes || '';
editingId = item._id;
showForm = true;
}
async function handleSave() {
const validLinks = links.filter(l => l.url.trim());
if (!name.trim() || validLinks.length === 0) return;
saving = true;
try {
if (editingId) {
const res = await fetch(`/api/${data.recipeLang}/to-try`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: editingId, name, links: validLinks, notes })
});
if (res.ok) {
const updated = await res.json();
items = items.map(i => i._id === editingId ? updated : i);
resetForm();
}
} else {
const res = await fetch(`/api/${data.recipeLang}/to-try`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, links: validLinks, notes })
});
if (res.ok) {
const created = await res.json();
items = [created, ...items];
resetForm();
}
}
} finally {
saving = false;
}
}
async function handleDelete(id) {
const msg = isEnglish ? 'Delete this recipe?' : 'Dieses Rezept löschen?';
if (!confirm(msg)) return;
const res = await fetch(`/api/${data.recipeLang}/to-try`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id })
});
if (res.ok) {
items = items.filter(i => i._id !== id);
if (editingId === id) resetForm();
}
}
</script>
<style>
h1 {
text-align: center;
margin-bottom: 0;
font-size: 4rem;
}
.subheading {
text-align: center;
margin-top: 0;
font-size: 1.5rem;
}
.empty-state {
text-align: center;
margin-top: 3rem;
color: var(--nord3);
}
.add-bar {
text-align: center;
margin-bottom: 1.5em;
}
.add-btn {
background: var(--nord10);
color: white;
border: none;
border-radius: var(--radius-pill);
padding: 0.5em 1.2em;
font-size: 1rem;
cursor: pointer;
transition: background var(--transition-fast);
}
.add-btn:hover {
background: var(--nord9);
}
.form-card {
max-width: 540px;
margin: 0 auto 2em;
padding: 1.2em;
background: var(--color-surface);
border-radius: var(--radius-card);
box-shadow: 0 1px 4px rgba(0,0,0,0.08);
}
.form-card input,
.form-card textarea {
width: 100%;
padding: 0.5em;
border: 1px solid var(--nord4);
border-radius: 6px;
font-size: 0.95rem;
font-family: inherit;
background: inherit;
color: inherit;
box-sizing: border-box;
}
@media (prefers-color-scheme: dark) {
.form-card input,
.form-card textarea {
border-color: var(--nord3);
}
}
.form-card textarea {
resize: vertical;
min-height: 60px;
}
.field {
margin-bottom: 0.8em;
}
.field label {
display: block;
font-size: 0.85rem;
font-weight: 600;
margin-bottom: 0.25em;
}
.link-row {
display: flex;
gap: 0.4em;
margin-bottom: 0.4em;
align-items: center;
}
.link-row input {
flex: 1;
}
.link-remove {
background: var(--nord11);
color: white;
border: none;
border-radius: var(--radius-pill);
width: 1.5em;
height: 1.5em;
font-size: 0.8rem;
cursor: pointer;
display: grid;
place-items: center;
flex-shrink: 0;
}
.link-add {
background: none;
border: none;
color: var(--nord10);
cursor: pointer;
font-size: 0.85rem;
padding: 0.2em 0;
}
.link-add:hover {
text-decoration: underline;
}
.form-actions {
display: flex;
gap: 0.6em;
justify-content: flex-end;
margin-top: 1em;
}
.btn-save {
background: var(--nord14);
color: var(--nord0);
border: none;
border-radius: var(--radius-pill);
padding: 0.45em 1.2em;
font-size: 0.95rem;
cursor: pointer;
transition: background var(--transition-fast);
}
.btn-save:hover {
background: var(--nord7);
}
.btn-save:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-cancel {
background: none;
border: 1px solid var(--nord4);
border-radius: var(--radius-pill);
padding: 0.45em 1.2em;
font-size: 0.95rem;
cursor: pointer;
color: inherit;
}
.form-heading {
margin: 0 0 0.6em;
font-size: 1.1rem;
}
</style>
<svelte:head>
<title>{labels.pageTitle}</title>
<meta name="description" content={labels.metaDescription} />
</svelte:head>
<h1>{labels.title}</h1>
<p class="subheading">
{#if items.length > 0}
{labels.count}
{:else}
{labels.noItems}
{/if}
</p>
<div class="add-bar">
{#if !showForm}
<button class="add-btn" onclick={() => showForm = true}>+ {labels.add}</button>
{/if}
</div>
{#if showForm}
<div class="form-card">
{#if editingId}
<h2 class="form-heading">{labels.editHeading}</h2>
{/if}
<div class="field">
<label for="totry-name">{labels.name}</label>
<input id="totry-name" type="text" bind:value={name} />
</div>
<div class="field">
<!-- svelte-ignore a11y_label_has_associated_control -->
<label>Links</label>
{#each links as link, i (i)}
<div class="link-row">
<input type="url" placeholder={labels.url} bind:value={link.url} />
<input type="text" placeholder={labels.label} bind:value={link.label} />
{#if links.length > 1}
<button class="link-remove" onclick={() => removeLinkRow(i)} aria-label="Remove link"></button>
{/if}
</div>
{/each}
<button class="link-add" onclick={addLinkRow}>+ {labels.addLink}</button>
</div>
<div class="field">
<label for="totry-notes">{labels.notes}</label>
<textarea id="totry-notes" bind:value={notes}></textarea>
</div>
<div class="form-actions">
<button class="btn-cancel" onclick={resetForm}>{labels.cancel}</button>
<button class="btn-save" onclick={handleSave} disabled={saving || !name.trim() || !links.some(l => l.url.trim())}>
{labels.save}
</button>
</div>
</div>
{/if}
{#if items.length > 0}
<div class="recipe-grid">
{#each items as item (item._id)}
<ToTryCard {item} ondelete={handleDelete} onedit={handleEdit} {isEnglish} />
{/each}
</div>
{:else if !showForm}
<div class="empty-state">
<p>{labels.emptyState}</p>
</div>
{/if}

View File

@@ -26,7 +26,7 @@ export const POST: RequestHandler = async ({request, locals}) => {
if (oldShortName !== newShortName) {
// Rename image files in all three directories
const imageDirectories = ['full', 'thumb', 'placeholder'];
const imageDirectories = ['full', 'thumb'];
const staticPath = join(process.cwd(), 'static', 'rezepte');
for (const dir of imageDirectories) {

View File

@@ -5,6 +5,7 @@ import { IMAGE_DIR } from '$env/static/private';
import sharp from 'sharp';
import { generateImageHashFromBuffer, getHashedFilename } from '$utils/imageHash';
import { validateImageFile } from '$utils/imageValidation';
import { extractDominantColor } from '$utils/imageProcessing';
/**
* Secure image upload endpoint for recipe images
@@ -13,7 +14,7 @@ import { validateImageFile } from '$utils/imageValidation';
* - Requires authentication
* - 5-layer validation (size, magic bytes, MIME, extension, Sharp)
* - Uses FormData instead of base64 JSON (more efficient, more secure)
* - Generates full/thumb/placeholder versions
* - Generates full/thumb versions + dominant color extraction
* - Content hash for cache busting
*
* @route POST /api/rezepte/img/add
@@ -109,31 +110,20 @@ export const POST = (async ({ request, locals }) => {
await sharp(thumbBuffer).toFile(thumbHashedPath);
await sharp(thumbBuffer).toFile(thumbUnhashedPath);
console.log('[API:ImgAdd] Thumbnail images saved');
console.log('[API:ImgAdd] Thumbnail images saved');
// Save placeholder (20px width) - both hashed and unhashed versions
console.log('[API:ImgAdd] Processing placeholder...');
const placeholderBuffer = await sharp(buffer)
.resize({ width: 20 })
.toFormat('webp')
.webp({ quality: 60 })
.toBuffer();
console.log('[API:ImgAdd] Placeholder buffer created, size:', placeholderBuffer.length, 'bytes');
// Extract dominant color
console.log('[API:ImgAdd] Extracting dominant color...');
const color = await extractDominantColor(buffer);
console.log('[API:ImgAdd] Dominant color:', color);
const placeholderHashedPath = path.join(IMAGE_DIR, 'rezepte', 'placeholder', hashedFilename);
const placeholderUnhashedPath = path.join(IMAGE_DIR, 'rezepte', 'placeholder', unhashedFilename);
console.log('[API:ImgAdd] Saving placeholder to:', { placeholderHashedPath, placeholderUnhashedPath });
await sharp(placeholderBuffer).toFile(placeholderHashedPath);
await sharp(placeholderBuffer).toFile(placeholderUnhashedPath);
console.log('[API:ImgAdd] Placeholder images saved ✓');
console.log('[API:ImgAdd] Upload completed successfully ✓');
console.log('[API:ImgAdd] Upload completed successfully');
return json({
success: true,
msg: 'Image uploaded successfully',
filename: hashedFilename,
unhashedFilename: unhashedFilename
unhashedFilename: unhashedFilename,
color
});
} catch (err: any) {
// Re-throw errors that already have status codes

View File

@@ -17,7 +17,7 @@ export const POST = (async ({ request, locals}) => {
const basename = data.name || hashedFilename.replace(/\.[a-f0-9]{8}\.webp$/, '').replace(/\.webp$/, '');
const unhashedFilename = basename + '.webp';
[ "full", "thumb", "placeholder"].forEach((folder) => {
[ "full", "thumb"].forEach((folder) => {
// Delete hashed version
unlink(path.join(IMAGE_DIR, "rezepte", folder, hashedFilename), (e) => {
if(e) console.warn(`Could not delete hashed: ${folder}/${hashedFilename}`, e);

View File

@@ -26,7 +26,7 @@ export const POST = (async ({ request, locals}) => {
newFilename = data.new_name + ".webp";
}
[ "full", "thumb", "placeholder"].forEach((folder) => {
[ "full", "thumb"].forEach((folder) => {
const old_path = path.join(IMAGE_DIR, "rezepte", folder, oldFilename)
rename(old_path, path.join(IMAGE_DIR, "rezepte", folder, newFilename), (e) => {
console.log(e)

View File

@@ -142,6 +142,7 @@ export const GET: RequestHandler = async ({ params }) => {
mediapath: img.mediapath,
alt: translatedImages[index]?.alt || img.alt || '',
caption: translatedImages[index]?.caption || img.caption || '',
color: img.color || '',
}));
}

View File

@@ -0,0 +1,120 @@
import { json, error, type RequestHandler } from '@sveltejs/kit';
import { ToTryRecipe } from '$models/ToTryRecipe';
import { dbConnect } from '$utils/db';
export const GET: RequestHandler = async ({ locals }) => {
const session = await locals.auth();
if (!session?.user?.nickname) {
throw error(401, 'Authentication required');
}
await dbConnect();
try {
const items = await ToTryRecipe.find().sort({ createdAt: -1 }).lean();
return json(items);
} catch (e) {
throw error(500, 'Failed to fetch to-try recipes');
}
};
export const POST: RequestHandler = async ({ request, locals }) => {
const session = await locals.auth();
if (!session?.user?.nickname) {
throw error(401, 'Authentication required');
}
const { name, links, notes } = await request.json();
if (!name?.trim()) {
throw error(400, 'Name is required');
}
if (!Array.isArray(links) || links.length === 0 || !links.some((l: any) => l.url?.trim())) {
throw error(400, 'At least one link is required');
}
await dbConnect();
try {
const item = await ToTryRecipe.create({
name: name.trim(),
links: links.filter((l: any) => l.url?.trim()),
notes: notes?.trim() || '',
addedBy: session.user.nickname
});
return json(item, { status: 201 });
} catch (e) {
throw error(500, 'Failed to create to-try recipe');
}
};
export const PATCH: RequestHandler = async ({ request, locals }) => {
const session = await locals.auth();
if (!session?.user?.nickname) {
throw error(401, 'Authentication required');
}
const { id, name, links, notes } = await request.json();
if (!id) {
throw error(400, 'ID is required');
}
if (!name?.trim()) {
throw error(400, 'Name is required');
}
if (!Array.isArray(links) || links.length === 0 || !links.some((l: any) => l.url?.trim())) {
throw error(400, 'At least one link is required');
}
await dbConnect();
try {
const item = await ToTryRecipe.findByIdAndUpdate(
id,
{
name: name.trim(),
links: links.filter((l: any) => l.url?.trim()),
notes: notes?.trim() || ''
},
{ new: true }
).lean();
if (!item) {
throw error(404, 'Item not found');
}
return json(item);
} catch (e) {
if (e instanceof Error && 'status' in e) throw e;
throw error(500, 'Failed to update to-try recipe');
}
};
export const DELETE: RequestHandler = async ({ request, locals }) => {
const session = await locals.auth();
if (!session?.user?.nickname) {
throw error(401, 'Authentication required');
}
const { id } = await request.json();
if (!id) {
throw error(400, 'ID is required');
}
await dbConnect();
try {
await ToTryRecipe.findByIdAndDelete(id);
return json({ success: true });
} catch (e) {
throw error(500, 'Failed to delete to-try recipe');
}
};

View File

@@ -0,0 +1,159 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { Recipe } from '$models/Recipe.js';
import { IMAGE_DIR } from '$env/static/private';
import { extractDominantColor } from '$utils/imageProcessing';
import { join } from 'path';
import { access, constants } from 'fs/promises';
export const POST: RequestHandler = async ({ request, locals }) => {
const session = await locals.auth();
if (!session?.user) {
throw error(401, 'Unauthorized');
}
try {
const body = await request.json();
const { filter = 'missing', limit = 50 } = body;
let query: any = { images: { $exists: true, $ne: [] } };
if (filter === 'missing') {
query = {
images: {
$elemMatch: {
mediapath: { $exists: true },
$or: [{ color: { $exists: false } }, { color: '' }],
},
},
};
}
const recipes = await Recipe.find(query).limit(limit);
if (recipes.length === 0) {
return json({
success: true,
processed: 0,
message: 'No recipes found matching criteria',
});
}
const results: Array<{
shortName: string;
name: string;
color: string;
status: 'ok' | 'error';
error?: string;
}> = [];
for (const recipe of recipes) {
const image = recipe.images[0];
if (!image?.mediapath) continue;
// Try unhashed filename first (always exists), fall back to hashed
const basename = image.mediapath
.replace(/\.[a-f0-9]{8}\.webp$/, '')
.replace(/\.webp$/, '');
const unhashedFilename = basename + '.webp';
const candidates = [
join(IMAGE_DIR, 'rezepte', 'full', unhashedFilename),
join(IMAGE_DIR, 'rezepte', 'full', image.mediapath),
join(IMAGE_DIR, 'rezepte', 'thumb', unhashedFilename),
join(IMAGE_DIR, 'rezepte', 'thumb', image.mediapath),
];
let imagePath: string | null = null;
for (const candidate of candidates) {
try {
await access(candidate, constants.R_OK);
imagePath = candidate;
break;
} catch {
// try next
}
}
if (!imagePath) {
results.push({
shortName: recipe.short_name,
name: recipe.name,
color: '',
status: 'error',
error: 'Image file not found on disk',
});
continue;
}
try {
const color = await extractDominantColor(imagePath);
recipe.images[0].color = color;
await recipe.save();
results.push({
shortName: recipe.short_name,
name: recipe.name,
color,
status: 'ok',
});
} catch (err) {
console.error(`Failed to extract color for ${recipe.short_name}:`, err);
results.push({
shortName: recipe.short_name,
name: recipe.name,
color: '',
status: 'error',
error: err instanceof Error ? err.message : 'Unknown error',
});
}
}
return json({
success: true,
processed: results.filter(r => r.status === 'ok').length,
failed: results.filter(r => r.status === 'error').length,
results,
});
} catch (err) {
console.error('Error in bulk color recalculation:', err);
if (err instanceof Error && 'status' in err) {
throw err;
}
throw error(500, err instanceof Error ? err.message : 'Failed to recalculate colors');
}
};
export const GET: RequestHandler = async ({ locals }) => {
const session = await locals.auth();
if (!session?.user) {
throw error(401, 'Unauthorized');
}
try {
const totalWithImages = await Recipe.countDocuments({
images: { $exists: true, $ne: [] },
});
const missingColor = await Recipe.countDocuments({
images: {
$elemMatch: {
mediapath: { $exists: true },
$or: [{ color: { $exists: false } }, { color: '' }],
},
},
});
const withColor = totalWithImages - missingColor;
return json({
totalWithImages,
missingColor,
withColor,
});
} catch (err) {
throw error(500, 'Failed to fetch statistics');
}
};

View File

@@ -113,10 +113,10 @@ sw.addEventListener('fetch', (event) => {
return;
}
// Handle recipe images (thumbnails, full images, and placeholders)
// Handle recipe images (thumbnails and full images)
if (
url.pathname.startsWith('/static/rezepte/') &&
(url.pathname.includes('/thumb/') || url.pathname.includes('/full/') || url.pathname.includes('/placeholder/'))
(url.pathname.includes('/thumb/') || url.pathname.includes('/full/'))
) {
event.respondWith(
(async () => {
@@ -137,8 +137,8 @@ sw.addEventListener('fetch', (event) => {
}
return response;
} catch {
// Network failed - try to serve thumbnail as fallback for full/placeholder
if (url.pathname.includes('/full/') || url.pathname.includes('/placeholder/')) {
// Network failed - try to serve thumbnail as fallback for full
if (url.pathname.includes('/full/')) {
// Extract filename and try to find cached thumbnail
const filename = url.pathname.split('/').pop();
if (filename) {

View File

@@ -123,7 +123,8 @@ export type RecipeModelType = {
images?: [{
mediapath: string;
alt: string;
caption?: string
caption?: string;
color?: string;
}];
description: string;
tags: [string];
@@ -164,6 +165,7 @@ export type BriefRecipeType = {
mediapath: string;
alt: string;
caption?: string;
color?: string;
}]
description: string;
tags: [string];

View File

@@ -3,18 +3,107 @@ import sharp from 'sharp';
import { generateImageHashFromBuffer, getHashedFilename } from '$utils/imageHash';
import { validateImageFile } from '$utils/imageValidation';
// --- sRGB <-> linear RGB <-> OKLAB color conversions ---
function srgbToLinear(c: number): number {
return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
}
function linearToSrgb(c: number): number {
return c <= 0.0031308 ? c * 12.92 : 1.055 * Math.pow(c, 1 / 2.4) - 0.055;
}
function linearRgbToOklab(r: number, g: number, b: number): [number, number, number] {
const l_ = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b;
const m_ = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b;
const s_ = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b;
const l = Math.cbrt(l_);
const m = Math.cbrt(m_);
const s = Math.cbrt(s_);
return [
0.2104542553 * l + 0.7936177850 * m - 0.0040720468 * s,
1.9779984951 * l - 2.4285922050 * m + 0.4505937099 * s,
0.0259040371 * l + 0.7827717662 * m - 0.8086757660 * s,
];
}
function oklabToLinearRgb(L: number, a: number, b: number): [number, number, number] {
const l = L + 0.3963377774 * a + 0.2158037573 * b;
const m = L - 0.1055613458 * a - 0.0638541728 * b;
const s = L - 0.0894841775 * a - 1.2914855480 * b;
const l3 = l * l * l;
const m3 = m * m * m;
const s3 = s * s * s;
return [
+4.0767416621 * l3 - 3.3077115913 * m3 + 0.2309699292 * s3,
-1.2684380046 * l3 + 2.6097574011 * m3 - 0.3413193965 * s3,
-0.0041960863 * l3 - 0.7034186147 * m3 + 1.7076147010 * s3,
];
}
/**
* Process and save recipe image with multiple versions (full, thumb, placeholder)
* Extract the perceptually dominant color from an image buffer.
* Averages pixels in OKLAB space with a 2D Gaussian kernel biased toward the center.
* Returns a hex string like "#a1b2c3".
*/
export async function extractDominantColor(input: Buffer | string): Promise<string> {
const { data, info } = await sharp(input)
.resize(50, 50, { fit: 'cover' })
.removeAlpha()
.raw()
.toBuffer({ resolveWithObject: true });
const { width, height } = info;
const cx = (width - 1) / 2;
const cy = (height - 1) / 2;
const sigmaX = 0.15 * width;
const sigmaY = 0.15 * height;
let wL = 0, wa = 0, wb = 0, wSum = 0;
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const i = (y * width + x) * 3;
// Gaussian weight based on distance from center
const dx = x - cx;
const dy = y - cy;
const w = Math.exp(-0.5 * ((dx * dx) / (sigmaX * sigmaX) + (dy * dy) / (sigmaY * sigmaY)));
// sRGB [0-255] -> linear [0-1] -> OKLAB
const lr = srgbToLinear(data[i] / 255);
const lg = srgbToLinear(data[i + 1] / 255);
const lb = srgbToLinear(data[i + 2] / 255);
const [L, a, b] = linearRgbToOklab(lr, lg, lb);
wL += w * L;
wa += w * a;
wb += w * b;
wSum += w;
}
}
// Average in OKLAB, convert back to sRGB
const [rLin, gLin, bLin] = oklabToLinearRgb(wL / wSum, wa / wSum, wb / wSum);
const r = Math.round(Math.min(1, Math.max(0, linearToSrgb(rLin))) * 255);
const g = Math.round(Math.min(1, Math.max(0, linearToSrgb(gLin))) * 255);
const b = Math.round(Math.min(1, Math.max(0, linearToSrgb(bLin))) * 255);
return '#' + ((1 << 24) | (r << 16) | (g << 8) | b).toString(16).slice(1);
}
/**
* Process and save recipe image with multiple versions (full, thumb)
* and extract dominant color.
* @param file - The image File object
* @param name - The base name for the image (usually recipe short_name)
* @param imageDir - The base directory where images are stored
* @returns Object with hashedFilename and unhashedFilename
* @returns Object with hashedFilename, unhashedFilename, and dominant color
*/
export async function processAndSaveRecipeImage(
file: File,
name: string,
imageDir: string
): Promise<{ filename: string; unhashedFilename: string }> {
): Promise<{ filename: string; unhashedFilename: string; color: string }> {
console.log('[ImageProcessing] Starting image processing for:', {
fileName: file.name,
recipeName: name,
@@ -58,7 +147,7 @@ export async function processAndSaveRecipeImage(
await sharp(fullBuffer).toFile(fullHashedPath);
await sharp(fullBuffer).toFile(fullUnhashedPath);
console.log('[ImageProcessing] Full size images saved');
console.log('[ImageProcessing] Full size images saved');
// Save thumbnail (800px width) - both hashed and unhashed versions
console.log('[ImageProcessing] Generating thumbnail (800px)...');
@@ -75,28 +164,17 @@ export async function processAndSaveRecipeImage(
await sharp(thumbBuffer).toFile(thumbHashedPath);
await sharp(thumbBuffer).toFile(thumbUnhashedPath);
console.log('[ImageProcessing] Thumbnail images saved');
console.log('[ImageProcessing] Thumbnail images saved');
// Save placeholder (20px width) - both hashed and unhashed versions
console.log('[ImageProcessing] Generating placeholder (20px)...');
const placeholderBuffer = await sharp(buffer)
.resize({ width: 20 })
.toFormat('webp')
.webp({ quality: 60 })
.toBuffer();
console.log('[ImageProcessing] Placeholder buffer created, size:', placeholderBuffer.length, 'bytes');
// Extract dominant color
console.log('[ImageProcessing] Extracting dominant color...');
const color = await extractDominantColor(buffer);
console.log('[ImageProcessing] Dominant color:', color);
const placeholderHashedPath = path.join(imageDir, 'rezepte', 'placeholder', hashedFilename);
const placeholderUnhashedPath = path.join(imageDir, 'rezepte', 'placeholder', unhashedFilename);
console.log('[ImageProcessing] Saving placeholder to:', { placeholderHashedPath, placeholderUnhashedPath });
await sharp(placeholderBuffer).toFile(placeholderHashedPath);
await sharp(placeholderBuffer).toFile(placeholderUnhashedPath);
console.log('[ImageProcessing] Placeholder images saved ✓');
console.log('[ImageProcessing] All image versions processed and saved successfully ✓');
console.log('[ImageProcessing] All image versions processed and saved successfully');
return {
filename: hashedFilename,
unhashedFilename: unhashedFilename
unhashedFilename: unhashedFilename,
color
};
}

Binary file not shown.

Binary file not shown.