8 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
30 changed files with 1350 additions and 129 deletions

View File

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

View File

@@ -99,6 +99,7 @@ nav{
box-shadow: 0 1em 1rem 0rem rgba(0,0,0,0.4); box-shadow: 0 1em 1rem 0rem rgba(0,0,0,0.4);
height: var(--header-h); height: var(--header-h);
padding-left: 0.5rem; padding-left: 0.5rem;
view-transition-name: site-header;
} }
.nav-toggle{ .nav-toggle{
display: none; display: none;

View File

@@ -36,6 +36,8 @@ const img_name = $derived(
const img_alt = $derived( const img_alt = $derived(
recipe.images?.[0]?.alt || recipe.name recipe.images?.[0]?.alt || recipe.name
); );
const img_color = $derived(recipe.images?.[0]?.color || '');
</script> </script>
<style> <style>
.card-main-link { .card-main-link {
@@ -93,21 +95,16 @@ const img_alt = $derived(
transition: var(--transition-normal); transition: var(--transition-normal);
border-top-left-radius: inherit; border-top-left-radius: inherit;
border-top-right-radius: inherit; border-top-right-radius: inherit;
opacity: 0;
} }
.blur{ .image.loaded{
filter: blur(10px); opacity: 1;
}
.backdrop_blur{
backdrop-filter: blur(10px);
} }
.card-image{ .card-image{
width: 300px; width: 300px;
height: 255px; height: 255px;
position: absolute; position: absolute;
top: 0; top: 0;
background-repeat: no-repeat;
background-size: cover;
background-position: center;
overflow: hidden; overflow: hidden;
border-top-left-radius: inherit; border-top-left-radius: inherit;
border-top-right-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}"> <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> <span class="visually-hidden">View recipe: {recipe.name}</span>
</a> </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> <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> </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> </div>
{#if showFavoriteIndicator && isFavorite} {#if showFavoriteIndicator && isFavorite}
<div class="favorite-indicator">❤️</div> <div class="favorite-indicator">❤️</div>

View File

@@ -20,7 +20,14 @@
recipe.images?.[0]?.alt || recipe.name recipe.images?.[0]?.alt || recipe.name
); );
const img_color = $derived(recipe.images?.[0]?.color || '');
const isInSeason = $derived(icon_override || recipe.season?.includes(current_month)); 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> </script>
<style> <style>
.compact-card { .compact-card {
@@ -61,6 +68,7 @@
height: 100%; height: 100%;
object-fit: cover; object-fit: cover;
transition: transform 0.4s ease; transition: transform 0.4s ease;
border-radius: var(--radius-card) var(--radius-card) 0 0;
} }
.info { .info {
position: relative; position: relative;
@@ -155,16 +163,19 @@
} }
</style> </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> <a href="{routePrefix}/{recipe.short_name}" class="card-link" aria-label={recipe.name}></a>
{#if showFavoriteIndicator && isFavorite} {#if showFavoriteIndicator && isFavorite}
<span class="favorite">❤️</span> <span class="favorite">❤️</span>
{/if} {/if}
<div class="img-wrap"> <div class="img-wrap" style:background-color={img_color}>
<img <img
src="https://bocken.org/static/rezepte/thumb/{img_name}" src="https://bocken.org/static/rezepte/thumb/{img_name}"
alt={img_alt} alt={img_alt}
loading={loading_strat} loading={loading_strat}
data-recipe={recipe.short_name}
/> />
</div> </div>
<div class="info"> <div class="info">

View File

@@ -1,16 +1,11 @@
<script> <script>
import { onMount } from "svelte"; 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); let isredirected = $state(false);
onMount(() => { onMount(() => {
const el = document.querySelector("img")
if(el?.complete){
isloaded = true
}
fetch(src, { method: 'HEAD' }) fetch(src, { method: 'HEAD' })
.then(response => { .then(response => {
isredirected = response.redirected isredirected = response.redirected
@@ -21,9 +16,7 @@
if(isredirected){ if(isredirected){
return return
} }
if(document.querySelector("img").complete){ document.querySelector("#img_carousel").showModal();
document.querySelector("#img_carousel").showModal();
}
} }
function close_dialog_img(){ function close_dialog_img(){
document.querySelector("#img_carousel").close(); document.querySelector("#img_carousel").close();
@@ -79,21 +72,25 @@
margin: 0; 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{ .image{
display: block; display: block;
position: absolute; position: absolute;
top: 0; top: 0;
width: min(1000px, 100dvw); width: min(1000px, 100dvw);
z-index: -1;
opacity: 0;
transition: var(--transition-normal);
height: max(60dvh,600px); height: max(60dvh,600px);
object-fit: cover; object-fit: cover;
object-position: 50% 20%; object-position: 50% 20%;
backdrop-filter: blur(20px);
filter: blur(20px);
z-index: -10;
} }
.image-container::after { .image-container::after {
@@ -106,34 +103,6 @@
:global(h1){ :global(h1){
width: 100%; 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 */
dialog{ dialog{
@@ -174,15 +143,13 @@ dialog button{
<figure class="image-container"> <figure class="image-container">
<!-- svelte-ignore a11y_click_events_have_key_events --> <!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<div class:zoom-in={isloaded && !isredirected} onclick={show_dialog_img}> <div class:zoom-in={!isredirected} onclick={show_dialog_img}>
<div class=placeholder style="background-image:url({placeholder_src})" > <div class="image-wrap" style:background-color={color}>
<div class=placeholder_blur> <img class="image" {src} {alt} style:view-transition-name={transitionName || null}/>
<img class="image" class:unblur={isloaded} {src} onload={() => {isloaded=true}} {alt}/>
</div>
</div> </div>
<noscript> <noscript>
<div class=placeholder style="background-image:url({placeholder_src})" > <div class="image-wrap" style:background-color={color}>
<img class="image unblur" {src} onload={() => {isloaded=true}} {alt}/> <img class="image" {src} {alt}/>
</div> </div>
</noscript> </noscript>
</div> </div>
@@ -191,7 +158,7 @@ dialog button{
</section> </section>
<dialog id=img_carousel> <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}> <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> <Cross fill=white width=2rem height=2rem></Cross>
</button> </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, name: recipe.translations.en.name,
short_name: recipe.translations.en.short_name, short_name: recipe.translations.en.short_name,
images: recipe.images?.[0] 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 || [], tags: recipe.translations.en.tags || [],
category: recipe.translations.en.category, category: recipe.translations.en.category,
@@ -51,7 +51,7 @@ export function toBrief(recipe: any, recipeLang: string): BriefRecipeType {
return { return {
...recipe, ...recipe,
images: recipe.images?.[0] 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; } 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" mediapath: {type: String, required: true}, // filename with hash for cache busting: e.g., "maccaroni.a1b2c3d4.webp"
alt: String, alt: String,
caption: String, caption: String,
color: String, // dominant color hex e.g. "#a1b2c3", used as loading placeholder
}], }],
description: {type: String, required: true}, description: {type: String, required: true},
note: {type: String}, 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

@@ -1,7 +1,47 @@
<script> <script>
import '$lib/css/recipe-links.css'; import '$lib/css/recipe-links.css';
import { page } from '$app/stores'; import { page } from '$app/stores';
import { onNavigate } from '$app/navigation';
import Header from '$lib/components/Header.svelte' 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 UserHeader from '$lib/components/UserHeader.svelte';
import LanguageSelector from '$lib/components/LanguageSelector.svelte'; import LanguageSelector from '$lib/components/LanguageSelector.svelte';
import OfflineSyncButton from '$lib/components/OfflineSyncButton.svelte'; import OfflineSyncButton from '$lib/components/OfflineSyncButton.svelte';

View File

@@ -37,6 +37,9 @@
const heroImg = $derived( const heroImg = $derived(
heroRecipe ? heroRecipe.images[0].mediapath : '' heroRecipe ? heroRecipe.images[0].mediapath : ''
); );
const heroColor = $derived(
heroRecipe ? (heroRecipe.images[0].color || '') : ''
);
// Category chip state: 'all', 'season', or a category name // Category chip state: 'all', 'season', or a category name
let activeChip = $state('all'); let activeChip = $state('all');
@@ -343,12 +346,13 @@
{#if heroRecipe} {#if heroRecipe}
<section class="hero-section"> <section class="hero-section">
<figure class="hero"> <figure class="hero" style:background-color={heroColor}>
<img <img
class="hero-img" class="hero-img"
src="https://bocken.org/static/rezepte/full/{heroImg}" src="https://bocken.org/static/rezepte/full/{heroImg}"
alt="" alt=""
loading="eager" loading="eager"
data-recipe={heroRecipe.short_name}
/> />
<div class="hero-overlay"></div> <div class="hero-overlay"></div>
</figure> </figure>
@@ -357,7 +361,11 @@
<div class="hero-text"> <div class="hero-text">
<h1>{labels.title}</h1> <h1>{labels.title}</h1>
<p class="subheading">{labels.subheading}</p> <p class="subheading">{labels.subheading}</p>
<a href="/{data.recipeLang}/{heroRecipe.short_name}" class="hero-featured"> <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> <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> <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> </a>

View File

@@ -40,7 +40,7 @@
`${data.germanShortName || data.short_name}.webp` `${data.germanShortName || data.short_name}.webp`
); );
const hero_img_src = $derived("https://bocken.org/static/rezepte/full/" + img_filename); 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 // Get alt text from images array
const img_alt = $derived(data.images?.[0]?.alt || ''); const img_alt = $derived(data.images?.[0]?.alt || '');
@@ -282,6 +282,20 @@ h2{
margin-bottom: 0; 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> </style>
<svelte:head> <svelte:head>
<title>{data.strippedName} - {labels.title}</title> <title>{data.strippedName} - {labels.title}</title>
@@ -299,8 +313,8 @@ h2{
<link rel="alternate" hreflang="x-default" href="https://bocken.org/rezepte/{data.germanShortName}" /> <link rel="alternate" hreflang="x-default" href="https://bocken.org/rezepte/{data.germanShortName}" />
</svelte:head> </svelte:head>
<TitleImgParallax src={hero_img_src} {placeholder_src} alt={img_alt}> <TitleImgParallax src={hero_img_src} color={img_color} alt={img_alt} transitionName="recipe-{data.short_name}-img">
<div class=title> <div class=title style="view-transition-name: recipe-title">
{#if data.category} {#if data.category}
<a class="category g-pill g-btn-dark" href='/{data.recipeLang}/category/{data.category}'>{data.category}</a> <a class="category g-pill g-btn-dark" href='/{data.recipeLang}/category/{data.category}'>{data.category}</a>
{/if} {/if}

View File

@@ -69,7 +69,7 @@ export const actions = {
try { try {
console.log('[RecipeAdd] Starting image processing...'); console.log('[RecipeAdd] Starting image processing...');
// Process and save the image // Process and save the image
const { filename } = await processAndSaveRecipeImage( const { filename, color } = await processAndSaveRecipeImage(
recipeImage, recipeImage,
recipeData.short_name, recipeData.short_name,
IMAGE_DIR IMAGE_DIR
@@ -79,7 +79,8 @@ export const actions = {
recipeData.images = [{ recipeData.images = [{
mediapath: filename, mediapath: filename,
alt: '', alt: '',
caption: '' caption: '',
color
}]; }];
} catch (imageError: any) { } catch (imageError: any) {
console.error('[RecipeAdd] Image processing error:', imageError); 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', : 'Alternativtext für Rezeptbilder mit KI generieren',
href: `/${data.recipeLang}/admin/alt-text-generator`, href: `/${data.recipeLang}/admin/alt-text-generator`,
icon: '🖼️' 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> </script>

View File

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

View File

@@ -25,7 +25,8 @@
emptyState2: isEnglish emptyState2: isEnglish
? 'Visit a recipe and click the heart icon to add it to your favorites.' ? '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.', : '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); const { filtered: filteredFavorites, handleSearchResults } = createSearchFilter(() => data.favorites);
@@ -47,6 +48,28 @@ h1{
margin-top: 3rem; margin-top: 3rem;
color: var(--nord3); 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> </style>
<svelte:head> <svelte:head>
@@ -63,6 +86,8 @@ h1{
{/if} {/if}
</p> </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> <Search favoritesOnly={true} lang={data.lang} recipes={data.favorites} isLoggedIn={!!data.session?.user} onSearchResults={handleSearchResults}></Search>
{#if data.error} {#if data.error}

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) { if (oldShortName !== newShortName) {
// Rename image files in all three directories // Rename image files in all three directories
const imageDirectories = ['full', 'thumb', 'placeholder']; const imageDirectories = ['full', 'thumb'];
const staticPath = join(process.cwd(), 'static', 'rezepte'); const staticPath = join(process.cwd(), 'static', 'rezepte');
for (const dir of imageDirectories) { for (const dir of imageDirectories) {

View File

@@ -5,6 +5,7 @@ import { IMAGE_DIR } from '$env/static/private';
import sharp from 'sharp'; import sharp from 'sharp';
import { generateImageHashFromBuffer, getHashedFilename } from '$utils/imageHash'; import { generateImageHashFromBuffer, getHashedFilename } from '$utils/imageHash';
import { validateImageFile } from '$utils/imageValidation'; import { validateImageFile } from '$utils/imageValidation';
import { extractDominantColor } from '$utils/imageProcessing';
/** /**
* Secure image upload endpoint for recipe images * Secure image upload endpoint for recipe images
@@ -13,7 +14,7 @@ import { validateImageFile } from '$utils/imageValidation';
* - Requires authentication * - Requires authentication
* - 5-layer validation (size, magic bytes, MIME, extension, Sharp) * - 5-layer validation (size, magic bytes, MIME, extension, Sharp)
* - Uses FormData instead of base64 JSON (more efficient, more secure) * - 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 * - Content hash for cache busting
* *
* @route POST /api/rezepte/img/add * @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(thumbHashedPath);
await sharp(thumbBuffer).toFile(thumbUnhashedPath); 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 // Extract dominant color
console.log('[API:ImgAdd] Processing placeholder...'); console.log('[API:ImgAdd] Extracting dominant color...');
const placeholderBuffer = await sharp(buffer) const color = await extractDominantColor(buffer);
.resize({ width: 20 }) console.log('[API:ImgAdd] Dominant color:', color);
.toFormat('webp')
.webp({ quality: 60 })
.toBuffer();
console.log('[API:ImgAdd] Placeholder buffer created, size:', placeholderBuffer.length, 'bytes');
const placeholderHashedPath = path.join(IMAGE_DIR, 'rezepte', 'placeholder', hashedFilename); console.log('[API:ImgAdd] Upload completed successfully');
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 ✓');
return json({ return json({
success: true, success: true,
msg: 'Image uploaded successfully', msg: 'Image uploaded successfully',
filename: hashedFilename, filename: hashedFilename,
unhashedFilename: unhashedFilename unhashedFilename: unhashedFilename,
color
}); });
} catch (err: any) { } catch (err: any) {
// Re-throw errors that already have status codes // 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 basename = data.name || hashedFilename.replace(/\.[a-f0-9]{8}\.webp$/, '').replace(/\.webp$/, '');
const unhashedFilename = basename + '.webp'; const unhashedFilename = basename + '.webp';
[ "full", "thumb", "placeholder"].forEach((folder) => { [ "full", "thumb"].forEach((folder) => {
// Delete hashed version // Delete hashed version
unlink(path.join(IMAGE_DIR, "rezepte", folder, hashedFilename), (e) => { unlink(path.join(IMAGE_DIR, "rezepte", folder, hashedFilename), (e) => {
if(e) console.warn(`Could not delete hashed: ${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"; newFilename = data.new_name + ".webp";
} }
[ "full", "thumb", "placeholder"].forEach((folder) => { [ "full", "thumb"].forEach((folder) => {
const old_path = path.join(IMAGE_DIR, "rezepte", folder, oldFilename) const old_path = path.join(IMAGE_DIR, "rezepte", folder, oldFilename)
rename(old_path, path.join(IMAGE_DIR, "rezepte", folder, newFilename), (e) => { rename(old_path, path.join(IMAGE_DIR, "rezepte", folder, newFilename), (e) => {
console.log(e) console.log(e)

View File

@@ -142,6 +142,7 @@ export const GET: RequestHandler = async ({ params }) => {
mediapath: img.mediapath, mediapath: img.mediapath,
alt: translatedImages[index]?.alt || img.alt || '', alt: translatedImages[index]?.alt || img.alt || '',
caption: translatedImages[index]?.caption || img.caption || '', 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; return;
} }
// Handle recipe images (thumbnails, full images, and placeholders) // Handle recipe images (thumbnails and full images)
if ( if (
url.pathname.startsWith('/static/rezepte/') && 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( event.respondWith(
(async () => { (async () => {
@@ -137,8 +137,8 @@ sw.addEventListener('fetch', (event) => {
} }
return response; return response;
} catch { } catch {
// Network failed - try to serve thumbnail as fallback for full/placeholder // Network failed - try to serve thumbnail as fallback for full
if (url.pathname.includes('/full/') || url.pathname.includes('/placeholder/')) { if (url.pathname.includes('/full/')) {
// Extract filename and try to find cached thumbnail // Extract filename and try to find cached thumbnail
const filename = url.pathname.split('/').pop(); const filename = url.pathname.split('/').pop();
if (filename) { if (filename) {

View File

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

View File

@@ -3,18 +3,107 @@ import sharp from 'sharp';
import { generateImageHashFromBuffer, getHashedFilename } from '$utils/imageHash'; import { generateImageHashFromBuffer, getHashedFilename } from '$utils/imageHash';
import { validateImageFile } from '$utils/imageValidation'; 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 file - The image File object
* @param name - The base name for the image (usually recipe short_name) * @param name - The base name for the image (usually recipe short_name)
* @param imageDir - The base directory where images are stored * @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( export async function processAndSaveRecipeImage(
file: File, file: File,
name: string, name: string,
imageDir: string imageDir: string
): Promise<{ filename: string; unhashedFilename: string }> { ): Promise<{ filename: string; unhashedFilename: string; color: string }> {
console.log('[ImageProcessing] Starting image processing for:', { console.log('[ImageProcessing] Starting image processing for:', {
fileName: file.name, fileName: file.name,
recipeName: name, recipeName: name,
@@ -58,7 +147,7 @@ export async function processAndSaveRecipeImage(
await sharp(fullBuffer).toFile(fullHashedPath); await sharp(fullBuffer).toFile(fullHashedPath);
await sharp(fullBuffer).toFile(fullUnhashedPath); 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 // Save thumbnail (800px width) - both hashed and unhashed versions
console.log('[ImageProcessing] Generating thumbnail (800px)...'); console.log('[ImageProcessing] Generating thumbnail (800px)...');
@@ -75,28 +164,17 @@ export async function processAndSaveRecipeImage(
await sharp(thumbBuffer).toFile(thumbHashedPath); await sharp(thumbBuffer).toFile(thumbHashedPath);
await sharp(thumbBuffer).toFile(thumbUnhashedPath); 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 // Extract dominant color
console.log('[ImageProcessing] Generating placeholder (20px)...'); console.log('[ImageProcessing] Extracting dominant color...');
const placeholderBuffer = await sharp(buffer) const color = await extractDominantColor(buffer);
.resize({ width: 20 }) console.log('[ImageProcessing] Dominant color:', color);
.toFormat('webp')
.webp({ quality: 60 })
.toBuffer();
console.log('[ImageProcessing] Placeholder buffer created, size:', placeholderBuffer.length, 'bytes');
const placeholderHashedPath = path.join(imageDir, 'rezepte', 'placeholder', hashedFilename); console.log('[ImageProcessing] All image versions processed and saved successfully');
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 ✓');
return { return {
filename: hashedFilename, filename: hashedFilename,
unhashedFilename: unhashedFilename unhashedFilename: unhashedFilename,
color
}; };
} }