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
14 changed files with 762 additions and 25 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

@@ -23,6 +23,11 @@
const img_color = $derived(recipe.images?.[0]?.color || ''); 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 {
@@ -63,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;
@@ -157,7 +163,9 @@
} }
</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>
@@ -167,6 +175,7 @@
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, color = '', 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();
@@ -95,8 +88,6 @@
position: absolute; position: absolute;
top: 0; top: 0;
width: min(1000px, 100dvw); width: min(1000px, 100dvw);
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%;
@@ -112,9 +103,6 @@
:global(h1){ :global(h1){
width: 100%; width: 100%;
} }
.unblur.image{
opacity: 1;
}
/* DIALOG */ /* DIALOG */
dialog{ dialog{
@@ -155,13 +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="image-wrap" style:background-color={color}> <div class="image-wrap" style:background-color={color}>
<img class="image" class:unblur={isloaded} {src} onload={() => {isloaded=true}} {alt}/> <img class="image" {src} {alt} style:view-transition-name={transitionName || null}/>
</div> </div>
<noscript> <noscript>
<div class="image-wrap" style:background-color={color}> <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>
@@ -170,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>

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

@@ -352,6 +352,7 @@
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>
@@ -360,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

@@ -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} color={img_color} 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

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

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

@@ -56,8 +56,8 @@ export async function extractDominantColor(input: Buffer | string): Promise<stri
const { width, height } = info; const { width, height } = info;
const cx = (width - 1) / 2; const cx = (width - 1) / 2;
const cy = (height - 1) / 2; const cy = (height - 1) / 2;
const sigmaX = 0.3 * width; const sigmaX = 0.15 * width;
const sigmaY = 0.3 * height; const sigmaY = 0.15 * height;
let wL = 0, wa = 0, wb = 0, wSum = 0; let wL = 0, wa = 0, wb = 0, wSum = 0;