15 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
41 changed files with 1522 additions and 197 deletions

View File

@@ -29,18 +29,11 @@ fi
EMOJIS="☀✝❄🌷🍂🎄🐇🍽🥫🛒🛍🚆⚡🎉🤝💸❤🖤✅❌🚀⚠✨🔄📋🖼📖🤖🌐🔐🔍🚫"
# ────────────────────────────────────────────────────────────────────
# Build Unicode codepoint list from the emoji string
UNICODES=""
for char in $(echo "$EMOJIS" | grep -oP '.'); do
code=$(printf 'U+%04X' "'$char")
if [ -n "$UNICODES" ]; then
UNICODES="$UNICODES,$code"
else
UNICODES="$code"
fi
done
# 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 $(echo "$EMOJIS" | grep -oP '.' | wc -l) glyphs..."
echo "Subsetting NotoColorEmoji with $GLYPH_COUNT glyphs..."
# Subset to TTF
pyftsubset "$SRC_FONT" \

View File

@@ -361,15 +361,22 @@ a:focus-visible {
.recipe-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 1.5em;
padding: 0 1.5em;
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(240px, 1fr));
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 1.5em;
padding: 0 1.5em;
}
}
@media (min-width: 1024px) {

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

@@ -99,6 +99,7 @@ nav{
box-shadow: 0 1em 1rem 0rem rgba(0,0,0,0.4);
height: var(--header-h);
padding-left: 0.5rem;
view-transition-name: site-header;
}
.nav-toggle{
display: none;
@@ -258,7 +259,7 @@ footer{
fill: var(--nord8);
scale: 0.9;
}
.nav_site{
.nav_site:not(.no-links){
position: fixed;
top: 0;
right: 0;
@@ -269,28 +270,28 @@ footer{
flex-direction: column;
padding-inline: 0.5rem;
}
.nav_site::before{
.nav_site:not(.no-links)::before{
content: '';
flex: 1;
}
:global(.nav_site ul){
:global(.nav_site:not(.no-links) ul){
width: 100% ;
}
.nav_site :first-child{
.nav_site:not(.no-links) :first-child{
display:none;
}
.nav_site{
.nav_site:not(.no-links){
transform: translateX(100%);
}
.wrapper:has(.nav-toggle:checked) .nav_site{
.wrapper:has(.nav-toggle:checked) .nav_site:not(.no-links){
transform: translateX(0);
transition: transform 100ms;
}
:global(.nav_site a:last-child){
:global(.nav_site:not(.no-links) a:last-child){
margin-bottom: 2rem;
}
.nav_site .links-wrapper {
.nav_site:not(.no-links) .links-wrapper {
width: 100%;
padding: 0 2rem;
}
@@ -308,14 +309,14 @@ footer{
: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 {
@@ -328,9 +329,33 @@ 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">
@@ -340,7 +365,8 @@ footer{
</div>
</div>
<div class="header-shadow"></div>
<nav class=nav_site>
{/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);
}
@@ -64,8 +64,8 @@
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) {
@@ -74,7 +74,7 @@
padding: 1.5rem 0.75rem;
}
:global(.links_grid a :is(svg, img)) {
height: 72px;
height: 90px;
}
:global(.links_grid h3) {
font-size: 1.2rem;
@@ -82,6 +82,10 @@
:global(.links_grid a) {
padding: 0.75rem;
}
:global(.links_grid a .lock-icon) {
width: 1.2rem;
height: 1.2rem;
}
}
@media (max-width: 410px) {
@@ -90,7 +94,7 @@
padding: 1rem 0.5rem;
}
:global(.links_grid a :is(svg, img)) {
height: 48px;
height: 64px;
}
:global(.links_grid h3) {
font-size: 0.95rem;
@@ -98,6 +102,12 @@
: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){
@@ -105,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

@@ -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,7 +5,7 @@ div{
flex-direction: row;
flex-wrap: wrap;
margin-inline:auto;
gap: 1rem;
gap: clamp(0.4rem, 1vw, 1rem);
justify-content: space-evenly;
}
</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

@@ -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 {
@@ -93,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;
@@ -232,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

@@ -20,7 +20,14 @@
recipe.images?.[0]?.alt || recipe.name
);
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 {
@@ -61,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;
@@ -82,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);
@@ -101,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,
@@ -120,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;
@@ -136,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

@@ -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,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

@@ -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');
@@ -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;
@@ -340,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>
@@ -354,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 || '');
@@ -282,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>
@@ -299,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

@@ -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

@@ -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

@@ -25,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);
@@ -47,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>
@@ -63,6 +86,8 @@ 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}

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

@@ -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
};
}