6 Commits

Author SHA1 Message Date
Alexander 467f9a4e71 feat(tasks): vinyl sticker album + fridge-calendar rewards redesign
CI / update (push) Has been cancelled
Replace the pokedex grid on /tasks/rewards with a scrapbook "sticker album":
category pages on warm paper, die-cut glossy vinyl stickers (debossed
silhouettes for missing ones), rarity-scaled holo shine/foil/glow, and a
large sticker-themed detail popup on click. Pages sort by rarity (rarest
category + sticker first; "Allerlei" catch-all last); each category has an
info popover explaining how its stickers drop, with the /tasks tag icons.

Restyle the calendar as a cozy fridge wall-calendar (paper, washi tape,
Fredoka month, cats stuck on dates as tilted die-cut stickers, weekend
tint). Shows only the current user by default; tap a name in the monthly
tally to fold in the other household member (per-person colour dots).

Export ALWAYS_CATEGORIES + getTagsForCategory from stickers util.
2026-06-01 23:42:47 +02:00
Alexander 8bd794bccb perf(build): replace adapter precompress with a parallel, filtered step
adapter-node's `precompress: true` brotli-q11 + gzips every file in
build/client single-threaded — ~150 MB including 91 MB of already-
compressed media (zero gain) and tens of MB of server-only data — adding
minutes to the build. The worst offenders were ML embedding JSONs
(nutrition 35 MB + bls 20 MB + shopping ~4 MB) that are `?url`-imported by
$lib/server/{nutritionMatcher,shoppingCategorizer}.ts and read server-side
via SvelteKit's read(); no browser ever fetches them, so compressing them
is pure waste.

- svelte.config.js: precompress: false.
- scripts/precompress.ts: postbuild step that only compresses text asset
  types, skips binaries and server-only data (bible TSVs by name, embedding
  JSONs by hashed-name pattern), tunes brotli quality down for large files,
  runs gzip+brotli in parallel, and never writes a larger-than-original or
  duplicate sibling.
- package.json: run precompress after build-error-page with
  UV_THREADPOOL_SIZE=12 so the async compression actually parallelizes.
- Delete static/allioli.json: 20 MB, unreferenced anywhere in the repo.
2026-06-01 16:11:52 +02:00
Alexander f52d6b4d4b perf(tasks): show completion sticker instantly
Roll the sticker client-side and render the popup immediately on completion
instead of waiting for the POST roundtrip + DB writes to return it. Preload
the sticker image so the cat is decoded before the bounce-in finishes. The
chosen stickerId is sent to the server, which persists it (falling back to a
server-side roll if missing/invalid).
2026-06-01 16:05:54 +02:00
Alexander 9b5cfe5e49 fix(recipes): build deploy against .env_prod; harden image save
Recipe image upload failed in prod with ENOENT writing the full image.
Root cause: the deploy built against the dev .env, whose relative
IMAGE_DIR="./imgs/" resolves under the service's dist/ working dir
instead of the real served image directory — and `$env/static/private`
is inlined at build time, so dev values shipped to prod.

- deploy.sh: source .env_prod (overridable via PROD_ENV) into the env
  before `pnpm build`, so prod values win over .env for the whole build
  lifecycle; abort if it's missing rather than ship a dev-env build.
- .gitignore: ignore .env_* so .env_prod (prod secrets) isn't committed
  (the existing .env.* dot pattern didn't match the underscore form).
- imageProcessing: mkdir -p the full/thumb dirs before writing. The
  WebP passthrough writes the full image with fs.writeFile, which (unlike
  sharp's toFile) does not create parent dirs.
- recipeFormHelpers: add serializableFormValues() and use it in the add/
  edit actions' fail() returns. Returning raw formData (now containing the
  recipe_image File) crashed the action response with a non-POJO devalue
  error, masking the real failure with an opaque 500.
2026-05-31 13:49:04 +02:00
Alexander 9fe9d95e36 feat(hikes): unify below-map view transition into one sliding panel
The detail-page enter/exit transition previously slid only the metric
tiles up from the bottom — the wrapper had no background, so its
snapshot was transparent and no containing panel moved. The photo strip
also animated separately, sliding in from the right.

Wrap everything below the hero map (stage nav, photo strip, metrics,
tags, elevation, scroll area, footer) in one `.below-map` element with
`view-transition-name: hike-below-map` and an opaque background, so the
whole sheet — background included — slides up on enter and down on exit
as a single panel. Drop the obsolete hike-strip right-slide rules and
keyframes; rename hike-below-strip → hike-below-map.
2026-05-31 13:29:15 +02:00
Alexander cd7912fa8f feat(recipes): client-side image editor + modernize /add to match /edit
CI / update (push) Has been cancelled
Add a from-scratch photo editor (crop, max-resolution scale-to-fit,
WebP quality with live final-size + dimensions readout) that opens on
image pick in the recipe add/edit flow. Conversion uses the browser's
canvas WebP encoder (sharp can't run client-side); crop, scale and the
size readout are built by hand.

Server now stores the client WebP full image byte-for-byte (passthrough)
so the on-disk file matches the user's chosen quality/size; sharp still
derives the 800px thumb and OKLAB colour. Non-WebP uploads keep the old
q90 re-encode fallback.

Rework /add to reuse EditTitleImgParallax (parallax hero +
titleExtras/below-hero layout, shape-tile Backform, SaveFab + optional
translation), replacing the antiquated CardAdd card. Move the edit/remove
image controls into the hero, below the fixed header. Delete now-dead
CardAdd and RecipeEditor.
2026-05-30 15:54:28 +02:00
27 changed files with 2576 additions and 417610 deletions
+1
View File
@@ -7,6 +7,7 @@ node_modules
/package /package
.env .env
.env.* .env.*
.env_*
!.env.example !.env.example
vite.config.js.timestamp-* vite.config.js.timestamp-*
vite.config.ts.timestamp-* vite.config.ts.timestamp-*
-2
View File
@@ -173,7 +173,6 @@ Generated: 2025-11-18
- `EditButton.svelte` - Edit button (floating) - `EditButton.svelte` - Edit button (floating)
- `FavoriteButton.svelte` - Toggle favorite - `FavoriteButton.svelte` - Toggle favorite
- `Card.svelte` (259 lines) ⚠️ Large - Recipe card with hover effects, tags, category - `Card.svelte` (259 lines) ⚠️ Large - Recipe card with hover effects, tags, category
- `CardAdd.svelte` - Add recipe card placeholder
- `FormSection.svelte` - Styled form section wrapper - `FormSection.svelte` - Styled form section wrapper
- `Header.svelte` - Page header - `Header.svelte` - Page header
- `UserHeader.svelte` - User-specific header - `UserHeader.svelte` - User-specific header
@@ -190,7 +189,6 @@ Generated: 2025-11-18
#### Recipe-Specific Components #### Recipe-Specific Components
- `Recipes.svelte` - Recipe list display - `Recipes.svelte` - Recipe list display
- `RecipeEditor.svelte` - Recipe editing form
- `RecipeNote.svelte` - Recipe notes display - `RecipeNote.svelte` - Recipe notes display
- `EditRecipe.svelte` - Edit recipe modal - `EditRecipe.svelte` - Edit recipe modal
- `EditRecipeNote.svelte` - Edit recipe notes - `EditRecipeNote.svelte` - Edit recipe notes
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "homepage", "name": "homepage",
"version": "1.94.1", "version": "1.96.0",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -8,7 +8,7 @@
"dev": "vite dev", "dev": "vite dev",
"prebuild": "bash scripts/subset-emoji-font.sh && pnpm exec vite-node scripts/generate-mystery-verses.ts && pnpm exec vite-node scripts/download-models.ts && pnpm exec vite-node scripts/generate-loyalty-cards.ts && pnpm exec vite-node scripts/generate-error-quotes.ts && pnpm exec vite-node scripts/build-hikes.ts && pnpm exec vite-node scripts/build-private-images.ts", "prebuild": "bash scripts/subset-emoji-font.sh && pnpm exec vite-node scripts/generate-mystery-verses.ts && pnpm exec vite-node scripts/download-models.ts && pnpm exec vite-node scripts/generate-loyalty-cards.ts && pnpm exec vite-node scripts/generate-error-quotes.ts && pnpm exec vite-node scripts/build-hikes.ts && pnpm exec vite-node scripts/build-private-images.ts",
"build": "vite build", "build": "vite build",
"postbuild": "pnpm exec vite-node scripts/build-error-page.ts", "postbuild": "pnpm exec vite-node scripts/build-error-page.ts && UV_THREADPOOL_SIZE=12 pnpm exec vite-node scripts/precompress.ts",
"preview": "vite preview", "preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
+19 -1
View File
@@ -51,7 +51,25 @@ echo " node $local_node (match)"
echo ":: Installing deps (frozen lockfile)" echo ":: Installing deps (frozen lockfile)"
pnpm install --frozen-lockfile pnpm install --frozen-lockfile
echo ":: Building" # Build against production env, NOT the dev .env. SvelteKit's
# `$env/static/private` (IMAGE_DIR, DB creds, …) is inlined at BUILD time, so a
# build that picks up the dev .env ships dev values to prod — e.g. the relative
# IMAGE_DIR="./imgs/" that resolves under the service's dist/ cwd instead of the
# real served image dir. We export .env_prod into the environment; real env vars
# take precedence over .env files in Vite/SvelteKit's env loading, so this wins
# for the whole `pnpm build` lifecycle (prebuild vite-node scripts + build).
PROD_ENV="${PROD_ENV:-.env_prod}"
if [[ ! -f "$PROD_ENV" ]]; then
echo "!! $PROD_ENV not found in $(pwd) — refusing to build with the dev .env."
echo " Create $PROD_ENV with production values (IMAGE_DIR must be an"
echo " ABSOLUTE path to the served recipe-image dir, DB creds, etc.)."
exit 1
fi
echo ":: Building (env from $PROD_ENV)"
set -a
# shellcheck source=/dev/null
source "$PROD_ENV"
set +a
pnpm build pnpm build
if [[ ! -d build ]]; then if [[ ! -d build ]]; then
+174
View File
@@ -0,0 +1,174 @@
/**
* Postbuild: precompress static build output for nginx `gzip_static` /
* `brotli_static`.
*
* Replaces adapter-node's `precompress: true`, which brotli-q11 + gzips EVERY
* file in build/client single-threaded — including ~90 MB of already-compressed
* jpg/mp4/png/webp/woff2 (zero gain) and 20 MB+ text blobs at q11 (~30 s each).
*
* This version instead:
* - only touches compressible text types (skips binaries entirely),
* - tunes brotli quality down for large files (q11 is wildly slow past a few MB
* for marginal ratio gains over q10/q9),
* - runs gzip + brotli concurrently across the libuv threadpool,
* - skips files that already have a .br/.gz sibling (e.g. the error pages the
* build-error-page step emits), so it's idempotent.
*
* Run: pnpm exec vite-node scripts/precompress.ts
*/
// The async gzip/brotli calls run on libuv's threadpool. Its size must be set
// before the pool is first used — by the time this module runs under vite-node
// the pool is already up, so postbuild sets UV_THREADPOOL_SIZE on the command
// line (the authoritative knob). This line is just a fallback default for
// direct `vite-node scripts/precompress.ts` runs and won't override an
// already-set value.
import os from 'node:os';
const CORES = Math.max(1, os.cpus().length);
process.env.UV_THREADPOOL_SIZE ||= String(Math.min(CORES, 12));
import { readdir, readFile, writeFile, stat } from 'node:fs/promises';
import { join, resolve, dirname, extname, basename } from 'node:path';
import { fileURLToPath } from 'node:url';
import { gzip, brotliCompress, constants as zlib } from 'node:zlib';
import { promisify } from 'node:util';
const gzipAsync = promisify(gzip);
const brotliAsync = promisify(brotliCompress);
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const TARGET_DIRS = ['build/client', 'build/prerendered'];
// Only these extensions are worth compressing; everything else (images, video,
// fonts, archives) is already compressed and skipped.
const COMPRESSIBLE = new Set([
'.js', '.mjs', '.cjs', '.css', '.html', '.htm', '.json', '.map',
'.svg', '.xml', '.txt', '.tsv', '.csv', '.wasm', '.webmanifest', '.ico'
]);
// Server-side-only data that nonetheless lands in build/client and is read back
// from disk server-side (never delivered to a browser). A .br/.gz sibling for
// these is dead weight nginx never serves — and they're the largest, slowest
// files in the tree, so skipping them is where almost all the time goes. They
// must still exist UNCOMPRESSED for the server reads, so we skip rather than
// remove them. Two kinds:
// - bible TSVs: read via src/lib/server/staticAsset.ts → resolveStaticAsset
// - ML embedding JSONs: `?url`-imported by $lib/server/{nutritionMatcher,
// shoppingCategorizer}.ts and read via SvelteKit's read(); emitted into
// _app/immutable/assets/ with a content hash (…Embeddings.<hash>.json).
const SERVER_ONLY_NAMES = new Set(['allioli.tsv', 'drb.tsv']);
const SERVER_ONLY_RE = /embeddings\.[^/]*\.json$/i;
function isServerOnly(file: string): boolean {
const base = basename(file);
return SERVER_ONLY_NAMES.has(base) || SERVER_ONLY_RE.test(base);
}
// Don't bother compressing tiny files — overhead/headers outweigh the savings.
const MIN_BYTES = 1024;
/** Pick a brotli quality that balances ratio against time for large files. */
function brotliQuality(size: number): number {
if (size > 4 * 1024 * 1024) return 9; // >4 MB: q9 (q11 would take 30 s+)
if (size > 1024 * 1024) return 10; // 14 MB
return 11; // small files: max ratio, still fast
}
async function* walk(dir: string): AsyncGenerator<string> {
let entries;
try {
entries = await readdir(dir, { withFileTypes: true });
} catch {
return; // dir doesn't exist (e.g. no prerendered output) — skip
}
for (const entry of entries) {
const full = join(dir, entry.name);
if (entry.isDirectory()) yield* walk(full);
else if (entry.isFile()) yield full;
}
}
async function collect(): Promise<string[]> {
const files: string[] = [];
for (const rel of TARGET_DIRS) {
for await (const f of walk(join(ROOT, rel))) {
const ext = extname(f).toLowerCase();
if (!COMPRESSIBLE.has(ext)) continue;
if (f.endsWith('.gz') || f.endsWith('.br')) continue;
if (isServerOnly(f)) continue;
files.push(f);
}
}
return files;
}
async function exists(p: string): Promise<boolean> {
try {
await stat(p);
return true;
} catch {
return false;
}
}
let saved = 0;
let written = 0;
async function compressOne(file: string): Promise<void> {
const buf = await readFile(file);
if (buf.length < MIN_BYTES) return;
const jobs: Promise<void>[] = [];
if (!(await exists(file + '.gz'))) {
jobs.push(
gzipAsync(buf, { level: zlib.Z_BEST_COMPRESSION }).then(async (out) => {
if (out.length < buf.length) {
await writeFile(file + '.gz', out);
written++;
saved += buf.length - out.length;
}
})
);
}
if (!(await exists(file + '.br'))) {
jobs.push(
brotliAsync(buf, {
params: {
[zlib.BROTLI_PARAM_QUALITY]: brotliQuality(buf.length),
[zlib.BROTLI_PARAM_SIZE_HINT]: buf.length
}
}).then(async (out) => {
if (out.length < buf.length) {
await writeFile(file + '.br', out);
written++;
saved += buf.length - out.length;
}
})
);
}
await Promise.all(jobs);
}
/** Run `tasks` with at most `limit` in flight at once. */
async function pool<T>(items: T[], limit: number, fn: (item: T) => Promise<void>): Promise<void> {
let i = 0;
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
while (i < items.length) {
const idx = i++;
await fn(items[idx]);
}
});
await Promise.all(workers);
}
const t0 = Date.now();
const files = await collect();
console.log(`[precompress] ${files.length} compressible files, ${CORES} cores`);
await pool(files, CORES, compressOne);
console.log(
`[precompress] wrote ${written} files, saved ${(saved / 1048576).toFixed(1)} MB in ${(
(Date.now() - t0) / 1000
).toFixed(1)}s`
);
+14 -34
View File
@@ -467,10 +467,11 @@ a:focus-visible {
/* ============================================ /* ============================================
HIKES TRANSITIONS HIKES TRANSITIONS
Cards + filter fly in/out vertically, clicked card morphs into the hero Cards + filter fly in/out vertically, clicked card morphs into the hero
map (cross-fade between thumbnail and map), photo strip slides in from map (cross-fade between thumbnail and map), and the whole below-map panel
the right. Page chrome under the hero cross-fades so nothing snaps in (an opaque sheet) slides up from the bottom. Page chrome under the hero
at transition end. Lives in app.css (not the page component) so the cross-fades so nothing snaps in at transition end. Lives in app.css (not
rules are still loaded on the OLD side of a nav AWAY from /hikes. the page component) so the rules are still loaded on the OLD side of a
nav AWAY from /hikes.
============================================ */ ============================================ */
@keyframes hikes-fly-up { @keyframes hikes-fly-up {
@@ -489,15 +490,6 @@ a:focus-visible {
from { opacity: 0; } from { opacity: 0; }
to { opacity: 1; } to { opacity: 1; }
} }
@keyframes hike-strip-in-right {
from { transform: translateX(100vw); }
to { transform: translateX(0); }
}
@keyframes hike-strip-out-right {
from { transform: translateX(0); }
to { transform: translateX(100vw); }
}
/* Only-child hike-fly-in pseudos (unpaired cards/filter on enter or exit): /* Only-child hike-fly-in pseudos (unpaired cards/filter on enter or exit):
* kill UA's default fade, switch blend mode so the custom fly animation * kill UA's default fade, switch blend mode so the custom fly animation
* shows clean motion against the rest of the page. */ * shows clean motion against the rest of the page. */
@@ -533,29 +525,17 @@ html.vt-exit-hikes::view-transition-old(.hike-fly-in):only-child {
animation: hikes-fly-down 700ms cubic-bezier(0.4, 0.1, 0.4, 1) both; animation: hikes-fly-down 700ms cubic-bezier(0.4, 0.1, 0.4, 1) both;
} }
/* Photo strip slides in from the right when arriving at a detail page, /* Everything below the hero map on a detail page — stage nav, photo strip,
* and slides back out whenever the detail page is left for any other * metrics, tags, elevation chart, scroll area, meta footer — slides up from
* route (back to /hikes, off to /, /hikes/route-builder, …). Both exit * the bottom on enter and back down on any exit, as one panel. The wrapper
* scopes (vt-enter-hikes for the back-nav case, vt-exit-hike-detail for * carries `view-transition-name: hike-below-map` and an opaque background, so
* everywhere else) trigger the same animation. */ * the whole sheet (background included) moves; the hero map morphs separately
html.vt-enter-hike-detail::view-transition-new(hike-strip):only-child { * above, and the rest of the page chrome cross-fades via the root-pseudo rule. */
animation: hike-strip-in-right 600ms cubic-bezier(0.2, 0.7, 0.2, 1) both; html.vt-enter-hike-detail::view-transition-new(hike-below-map):only-child {
}
html.vt-enter-hikes::view-transition-old(hike-strip):only-child,
html.vt-exit-hike-detail::view-transition-old(hike-strip):only-child {
animation: hike-strip-out-right 600ms cubic-bezier(0.4, 0.1, 0.4, 1) both;
}
/* Everything below the photo strip on a detail page (metrics, tags,
* elevation chart, scroll area, meta footer) slides up from the bottom
* on enter and back down on any exit. Wrapper element carries
* `view-transition-name: hike-below-strip`; the rest of the page chrome
* still cross-fades via the root-pseudo rule above. */
html.vt-enter-hike-detail::view-transition-new(hike-below-strip):only-child {
animation: hikes-fly-up 700ms cubic-bezier(0.2, 0.7, 0.2, 1) both; animation: hikes-fly-up 700ms cubic-bezier(0.2, 0.7, 0.2, 1) both;
} }
html.vt-enter-hikes::view-transition-old(hike-below-strip):only-child, html.vt-enter-hikes::view-transition-old(hike-below-map):only-child,
html.vt-exit-hike-detail::view-transition-old(hike-below-strip):only-child { html.vt-exit-hike-detail::view-transition-old(hike-below-map):only-child {
animation: hikes-fly-down 700ms cubic-bezier(0.4, 0.1, 0.4, 1) both; animation: hikes-fly-down 700ms cubic-bezier(0.4, 0.1, 0.4, 1) both;
} }
-434
View File
@@ -1,434 +0,0 @@
<script lang="ts">
import Cross from '$lib/assets/icons/Cross.svelte'
import { toast } from '$lib/js/toast.svelte'
import "$lib/css/shake.css"
import "$lib/css/icon.css"
import { onMount } from 'svelte'
let {
card_data = $bindable(),
image_preview_url = $bindable(''),
selected_image_file = $bindable<File | null>(null),
short_name = ''
}: {
card_data: any,
image_preview_url: string,
selected_image_file: File | null,
short_name: string
} = $props();
// Constants for validation
const ALLOWED_MIME_TYPES = ['image/webp', 'image/jpeg', 'image/jpg', 'image/png'];
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
// Handle file selection via onchange event
function handleFileSelect(event: Event) {
const input = event.currentTarget as HTMLInputElement;
const file = input.files?.[0];
if (!file) {
return;
}
// Validate MIME type
if (!ALLOWED_MIME_TYPES.includes(file.type)) {
toast.error('Invalid file type. Please upload a JPEG, PNG, or WebP image.');
input.value = '';
return;
}
// Validate file size
if (file.size > MAX_FILE_SIZE) {
toast.error(`File too large. Maximum size is 5MB. Your file is ${(file.size / 1024 / 1024).toFixed(2)}MB.`);
input.value = '';
return;
}
// Clean up old preview URL if exists
if (image_preview_url && image_preview_url.startsWith('blob:')) {
URL.revokeObjectURL(image_preview_url);
}
// Create preview and store file
image_preview_url = URL.createObjectURL(file);
selected_image_file = file;
}
// Check if initial image_preview_url redirects to placeholder
onMount(() => {
if (image_preview_url && !image_preview_url.startsWith('blob:')) {
const img = new Image();
img.onload = () => {
// Check if this is the placeholder image (150x150)
if (img.naturalWidth === 150 && img.naturalHeight === 150) {
image_preview_url = ""
}
};
img.onerror = () => {
image_preview_url = ""
};
img.src = image_preview_url;
}
});
// Initialize tags if needed
if (!card_data.tags) {
card_data.tags = []
}
// Tag management
let new_tag = $state("");
// Reference to file input for clearing
let fileInput: HTMLInputElement;
function remove_selected_images() {
if (image_preview_url && image_preview_url.startsWith('blob:')) {
URL.revokeObjectURL(image_preview_url);
}
image_preview_url = "";
selected_image_file = null;
// Reset the file input
if (fileInput) {
fileInput.value = '';
}
}
function add_to_tags() {
if (new_tag && !card_data.tags.includes(new_tag)) {
card_data.tags = [...card_data.tags, new_tag];
}
new_tag = "";
}
function remove_from_tags(tag: string) {
card_data.tags = card_data.tags.filter((item: string) => item !== tag);
}
function add_on_enter(event: KeyboardEvent) {
if (event.key === 'Enter') {
event.preventDefault();
add_to_tags();
}
}
function remove_on_enter(event: KeyboardEvent, tag: string) {
if (event.key === 'Enter') {
card_data.tags = card_data.tags.filter((item: string) => item !== tag);
}
}
</script>
<style>
.card{
position: relative;
margin-inline: auto;
--card-width: 300px;
text-decoration: none;
position: relative;
box-sizing: border-box;
width: var(--card-width);
aspect-ratio: 4/7;
border-radius: var(--radius-card);
background-size: contain;
display: flex;
flex-direction: column;
justify-content: end;
transition: var(--transition-normal);
background-color: var(--blue);
box-shadow: 0em 0em 2em 0.1em rgba(0, 0, 0, 0.3);
z-index: 0;
}
.img_label{
position :absolute;
z-index: 1;
top: 0;
width: 100%;
height: 100%;
cursor: pointer;
display: flex;
justify-content: center;
align-items: center;
border-radius: 20px 20px 0 0 ;
transition: var(--transition-normal);
}
.img_label_wrapper:hover{
background-color: var(--red);
box-shadow: 0 2em 1em 0.5em rgba(0,0,0,0.3);
transform:scale(1.02, 1.02);
}
.img_label_wrapper{
position: absolute;
height: 50%;
width: 100%;
top:0;
left: 0;
border-radius: 20px 20px 0 0;
transition: var(--transition-normal);
}
.img_label_wrapper:hover .delete{
opacity: 100%;
}
.img_label svg{
width: 100px;
height: 100px;
fill: white;
transition: var(--transition-normal);
}
.delete{
cursor: pointer;
all: unset;
position: absolute;
top:2rem;
left: 2rem;
opacity: 0%;
z-index: 4;
transition: var(--transition-normal);
}
.delete:hover{
transform: scale(1.2, 1.2);
}
.upload{
z-index: 1;
}
.img_label:hover .upload{
transform: scale(1.2, 1.2);
z-index: 10;
}
#img_picker{
display: none;
width: 300px;
height: 300px;
position:absolute;
}
input{
all: unset;
}
input::placeholder{
all:unset;
}
.card .icon{
z-index: 3;
box-sizing: border-box;
text-decoration: unset;
text-align:center;
width: 2.6rem;
aspect-ratio: 1/1;
transition: var(--transition-fast);
position: absolute;
font-size: 1.5rem;
top:-0.5em;
right:-0.5em;
padding: 0.25em;
background-color: var(--nord6);
border-radius: var(--radius-pill);
box-shadow: 0em 0em 2em 0.1em rgba(0, 0, 0, 0.6);
}
.card .icon:hover,
.card .icon:focus-visible
{
box-shadow: 0em 0em 1em 0.2em rgba(0, 0, 0, 0.6);
transform:scale(1.2, 1.2)
}
.card:hover,
.card:focus-within{
transform: scale(1.02,1.02);
box-shadow: 0.2em 0.2em 2em 1em rgba(0, 0, 0, 0.3);
}
.card img{
height: 50%;
object-fit: cover;
border-top-left-radius: inherit;
border-top-right-radius: inherit;
}
.card .title {
position: relative;
box-sizing: border-box;
padding-top: 0.5em;
height: 50%;
width: 100% ;
border-bottom-left-radius: inherit;
border-bottom-right-radius: inherit;
display: flex;
flex-direction: column;
justify-content: space-between;
transition: var(--transition-fast);
}
.card .name{
all: unset;
width:100%;
font-size: 2em;
color: white;
padding-inline: 0.5em;
padding-block: 0.2em;
}
.card .name:hover{
color:var(--nord0);
}
.card .description{
box-sizing:border-box;
border: 2px solid var(--nord5);
border-radius: 30px;
padding-inline: 1em;
padding-block: 0.5em;
margin-inline: 1em;
margin-top: 0;
color: var(--nord4);
width: calc(300px - 2em); /*??*/
}
.card .description:hover{
color: var(--nord0);
border: 2px solid var(--nord0);
}
.card .tags{
display: flex;
flex-wrap: wrap-reverse;
overflow: hidden;
column-gap: 0.25em;
padding-inline: 0.5em;
padding-top: 0.25em;
margin-bottom:0.5em;
flex-grow: 0;
}
.card .tag{
cursor: pointer;
text-decoration: unset;
background-color: var(--nord4);
color: var(--nord0);
border-radius: 100px;
padding-inline: 1em;
line-height: 1.5em;
margin-bottom: 0.5em;
transition: var(--transition-fast);
box-shadow: 0.2em 0.2em 0.2em 0.05em rgba(0, 0, 0, 0.3);
}
.card .tag:hover,
.card .tag:focus-visible,
.card .tag:focus-within
{
transform: scale(1.04, 1.04);
background-color: var(--nord8);
box-shadow: 0.2em 0.2em 0.2em 0.1em rgba(0, 0, 0, 0.3);
}
.card .title .category{
z-index: 2;
position: absolute;
box-shadow: 0em 0em 1em 0.1em rgba(0, 0, 0, 0.6);
text-decoration: none;
color: var(--nord6);
font-size: 1.5rem;
top: -0.8em;
left: -0.5em;
width: 10rem;
background-color: var(--nord0);
padding-inline: 1em;
border-radius: var(--radius-pill);
transition: var(--transition-fast);
}
.card .title .category:hover,
.card .title .category:focus-within
{
box-shadow: -0.2em 0.2em 1em 0.1em rgba(0, 0, 0, 0.6);
background-color: var(--nord3);
transform: scale(1.05, 1.05)
}
.card:hover .icon,
.card:focus-visible .icon
{
animation: shake 0.6s
}
@keyframes shake{
0%{
transform: rotate(0)
scale(1,1);
}
25%{
box-shadow: 0em 0em 1em 0.2em rgba(0, 0, 0, 0.6);
transform: rotate(30deg)
scale(1.2,1.2)
;
}
50%{
box-shadow: 0em 0em 1em 0.2em rgba(0, 0, 0, 0.6);
transform: rotate(-30deg)
scale(1.2,1.2);
}
74%{
box-shadow: 0em 0em 1em 0.2em rgba(0, 0, 0, 0.6);
transform: rotate(30deg)
scale(1.2, 1.2);
}
100%{
transform: rotate(0)
scale(1,1);
}
}
.input_wrapper{
position: relative;
padding-left: 3rem;
padding-left: 40rem;
}
.input_wrapper > input{
margin-left: 1ch;
}
.input{
position:absolute;
top: -.1ch;
left: 0.6ch;
font-size: 1.6rem;
}
.tag_input{
width: 12ch;
}
</style>
<div class=card>
<input class=icon placeholder=🥫 bind:value={card_data.icon}/>
{#if image_preview_url}
<!-- svelte-ignore a11y_missing_attribute -->
<img src={image_preview_url} class=img_preview width=300px height=300px />
{/if}
<div class=img_label_wrapper>
{#if image_preview_url}
<button class=delete onclick={remove_selected_images}>
<Cross fill=white style="width:2rem;height:2rem;"></Cross>
</button>
{/if}
<label class=img_label for=img_picker>
<svg class="upload over_img" xmlns="http://www.w3.org/2000/svg" height="1em" viewBox="0 0 512 512"><path d="M288 109.3V352c0 17.7-14.3 32-32 32s-32-14.3-32-32V109.3l-73.4 73.4c-12.5 12.5-32.8 12.5-45.3 0s-12.5-32.8 0-45.3l128-128c12.5-12.5 32.8-12.5 45.3 0l128 128c12.5 12.5 12.5 32.8 0 45.3s-32.8 12.5-45.3 0L288 109.3zM64 352H192c0 35.3 28.7 64 64 64s64-28.7 64-64H448c35.3 0 64 28.7 64 64v32c0 35.3-28.7 64-64 64H64c-35.3 0-64-28.7-64-64V416c0-35.3 28.7-64 64-64zM432 456a24 24 0 1 0 0-48 24 24 0 1 0 0 48z"/></svg>
</label>
</div>
<input type="file" id="img_picker" accept="image/webp,image/jpeg,image/jpg,image/png" onchange={handleFileSelect} bind:this={fileInput}>
<div class=title>
<input class=category placeholder=Kategorie... bind:value={card_data.category}/>
<div>
<input class=name placeholder=Name... bind:value={card_data.name}/>
<p contenteditable class=description placeholder=Kurzbeschreibung... bind:innerText={card_data.description}></p>
</div>
<div class=tags>
{#each card_data.tags as tag (tag)}
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<div class="tag" role="button" tabindex="0" onkeydown={(event) => remove_on_enter(event, tag)} onclick={() => remove_from_tags(tag)} aria-label="Tag {tag} entfernen">{tag}</div>
{/each}
<div class="tag input_wrapper"><span class=input>+</span><input class="tag_input" type="text" onkeydown={add_on_enter} onfocusout={add_to_tags} size="1" bind:value={new_tag} placeholder=Stichwort...></div>
</div>
</div>
</div>
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import Cross from '$lib/assets/icons/Cross.svelte'; import Cross from '$lib/assets/icons/Cross.svelte';
import ImageEditor from '$lib/components/recipes/ImageEditor.svelte';
import { toast } from '$lib/js/toast.svelte'; import { toast } from '$lib/js/toast.svelte';
import { onMount, type Snippet } from 'svelte'; import { onMount, type Snippet } from 'svelte';
@@ -53,9 +54,34 @@
input.value = ''; input.value = '';
return; return;
} }
openEditor(file);
input.value = '';
}
// Photo editor (crop / scale / webp quality) state
let editorFile = $state<File | null>(null);
let editorOpen = $state(false);
function openEditor(file: File) {
editorFile = file;
editorOpen = true;
}
function closeEditor() {
editorOpen = false;
editorFile = null;
if (fileInput) fileInput.value = '';
}
function handleEditorApply(file: File, url: string) {
if (image_preview_url?.startsWith('blob:')) URL.revokeObjectURL(image_preview_url); if (image_preview_url?.startsWith('blob:')) URL.revokeObjectURL(image_preview_url);
image_preview_url = URL.createObjectURL(file);
selected_image_file = file; selected_image_file = file;
image_preview_url = url;
closeEditor();
}
function editCurrentImage() {
if (selected_image_file) openEditor(selected_image_file);
} }
function clearSelectedImage() { function clearSelectedImage() {
@@ -129,15 +155,30 @@
</div> </div>
</button> </button>
{#if selected_image_file} {#if selected_image_file}
<button <div class="img-controls">
type="button" <button
class="clear-img" type="button"
onclick={clearSelectedImage} class="img-btn"
title="Auswahl verwerfen" onclick={editCurrentImage}
aria-label="Auswahl verwerfen" title="Bild bearbeiten"
> aria-label="Bild bearbeiten"
<Cross fill="white" width="1.25rem" height="1.25rem" /> >
</button> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" aria-hidden="true">
<path
d="M362.7 19.3L314.3 67.7 444.3 197.7l48.4-48.4c25-25 25-65.5 0-90.5L453.3 19.3c-25-25-65.5-25-90.5 0zm-71 71L58.6 323.5c-10.4 10.4-18 23.3-22.2 37.4L1 481.2C-1.5 489.7 .8 498.8 7 505s15.3 8.5 23.7 6.1l120.3-35.4c14.1-4.2 27-11.8 37.4-22.2L421.7 220.3 291.7 90.3z"
/>
</svg>
</button>
<button
type="button"
class="img-btn danger"
onclick={clearSelectedImage}
title="Auswahl verwerfen"
aria-label="Auswahl verwerfen"
>
<Cross fill="white" width="1.15rem" height="1.15rem" />
</button>
</div>
{/if} {/if}
<input <input
bind:this={fileInput} bind:this={fileInput}
@@ -215,6 +256,10 @@
</div> </div>
</section> </section>
{#if editorOpen && editorFile}
<ImageEditor file={editorFile} onApply={handleEditorApply} onCancel={closeEditor} />
{/if}
<style> <style>
.section { .section {
--scale: 0.3; --scale: 0.3;
@@ -312,10 +357,18 @@
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5); text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);
} }
.clear-img { /* Edit / remove controls — top-right of the image, offset below the fixed
site header (height 3rem, top max(12px, safe-area+4px)) so the nav never
obstructs them. */
.img-controls {
position: absolute; position: absolute;
top: calc(1rem + env(safe-area-inset-top, 0px)); top: calc(max(12px, env(safe-area-inset-top, 0px) + 4px) + 3rem + 1rem);
right: 1rem; right: 1rem;
display: flex;
gap: 0.5rem;
z-index: 5;
}
.img-btn {
background: rgba(0, 0, 0, 0.55); background: rgba(0, 0, 0, 0.55);
border: none; border: none;
width: 2.5rem; width: 2.5rem;
@@ -324,17 +377,26 @@
display: grid; display: grid;
place-items: center; place-items: center;
cursor: pointer; cursor: pointer;
z-index: 5;
transition: transition:
transform 150ms ease, transform 150ms ease,
background 150ms ease; background 150ms ease;
backdrop-filter: blur(6px); backdrop-filter: blur(6px);
box-shadow: var(--shadow-sm);
} }
.clear-img:hover, .img-btn svg {
.clear-img:focus-visible { width: 1.15rem;
background: var(--red); height: 1.15rem;
fill: white;
}
.img-btn:hover,
.img-btn:focus-visible {
background: var(--color-primary);
transform: scale(1.08); transform: scale(1.08);
} }
.img-btn.danger:hover,
.img-btn.danger:focus-visible {
background: var(--red);
}
.file-input { .file-input {
position: absolute; position: absolute;
@@ -0,0 +1,797 @@
<script lang="ts">
import { onMount } from 'svelte';
import {
loadBitmap,
renderToBlob,
fitWithin,
blobToFile,
formatBytes,
type CropRect
} from '$lib/js/imageEdit';
type Props = {
file: File;
shortName?: string;
onApply: (file: File, url: string) => void;
onCancel: () => void;
};
let { file, shortName = '', onApply, onCancel }: Props = $props();
const MIN_CROP = 24; // minimum crop edge, source px
const RATIOS = [
{ key: 'free', label: 'Frei' },
{ key: 'orig', label: 'Original' },
{ key: '1:1', label: '1:1', value: 1 },
{ key: '4:3', label: '4:3', value: 4 / 3 },
{ key: '3:2', label: '3:2', value: 3 / 2 },
{ key: '16:9', label: '16:9', value: 16 / 9 }
] as const;
const RES_PRESETS = [1000, 1500, 2000, 0]; // 0 = Original
let bitmap = $state<ImageBitmap | null>(null);
let imgW = $state(0);
let imgH = $state(0);
let loadError = $state('');
let crop = $state<CropRect>({ x: 0, y: 0, w: 0, h: 0 });
let ratioMode = $state<string>('free');
let maxRes = $state(2000);
let quality = $state(92);
// Live-encode output
let outBlob = $state<Blob | null>(null);
let outUrl = $state('');
let outW = $state(0);
let outH = $state(0);
let encoding = $state(false);
// Stage measurement
let stageW = $state(0);
let stageH = $state(0);
let stageCanvas = $state<HTMLCanvasElement | null>(null);
const activeRatio = $derived.by(() => {
const r = RATIOS.find((x) => x.key === ratioMode);
if (!r) return null;
if (r.key === 'orig') return imgH ? imgW / imgH : null;
return 'value' in r ? r.value : null;
});
// Fit the source image into the available stage area (display pixels).
const displayScale = $derived.by(() => {
if (!imgW || !imgH || !stageW || !stageH) return 1;
const availW = Math.max(1, stageW - 24);
const availH = Math.max(1, stageH - 24);
return Math.min(availW / imgW, availH / imgH);
});
const dispW = $derived(Math.round(imgW * displayScale));
const dispH = $derived(Math.round(imgH * displayScale));
function clamp(v: number, lo: number, hi: number) {
return Math.max(lo, Math.min(hi, v));
}
onMount(() => {
let cancelled = false;
(async () => {
try {
const bm = await loadBitmap(file);
if (cancelled) {
bm.close?.();
return;
}
bitmap = bm;
imgW = bm.width;
imgH = bm.height;
crop = { x: 0, y: 0, w: bm.width, h: bm.height };
} catch {
loadError = 'Bild konnte nicht geladen werden.';
}
})();
return () => {
cancelled = true;
};
});
// Draw the source onto the display canvas whenever it or the layout changes.
$effect(() => {
const cv = stageCanvas;
const bm = bitmap;
const w = dispW;
const h = dispH;
if (!cv || !bm || w <= 0 || h <= 0) return;
cv.width = w;
cv.height = h;
const ctx = cv.getContext('2d');
if (!ctx) return;
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.clearRect(0, 0, w, h);
ctx.drawImage(bm, 0, 0, imgW, imgH, 0, 0, w, h);
});
// Debounced live encode — runs whenever crop / resolution / quality change.
let encodeToken = 0;
$effect(() => {
const bm = bitmap;
if (!bm) return;
const c = { ...crop };
const mr = maxRes;
const q = quality;
const token = ++encodeToken;
encoding = true;
const timer = setTimeout(async () => {
try {
const blob = await renderToBlob(bm, c, mr, q);
if (token !== encodeToken) return;
const size = fitWithin(c.w, c.h, mr);
if (outUrl) URL.revokeObjectURL(outUrl);
outBlob = blob;
outUrl = URL.createObjectURL(blob);
outW = size.w;
outH = size.h;
} catch {
/* transient encode failure — next change retries */
} finally {
if (token === encodeToken) encoding = false;
}
}, 200);
return () => clearTimeout(timer);
});
$effect(() => {
return () => {
if (outUrl) URL.revokeObjectURL(outUrl);
bitmap?.close?.();
};
});
// --- Crop drag handling ---
type Drag = { handle: string; hx: number; hy: number; px: number; py: number; start: CropRect };
let drag: Drag | null = null;
function startDrag(e: PointerEvent, handle: string, hx: number, hy: number) {
e.preventDefault();
e.stopPropagation();
(e.currentTarget as Element).setPointerCapture(e.pointerId);
drag = { handle, hx, hy, px: e.clientX, py: e.clientY, start: { ...crop } };
}
function onPointerMove(e: PointerEvent) {
if (!drag || displayScale === 0) return;
const ddx = (e.clientX - drag.px) / displayScale;
const ddy = (e.clientY - drag.py) / displayScale;
const s = drag.start;
if (drag.handle === 'move') {
crop = {
x: clamp(s.x + ddx, 0, imgW - s.w),
y: clamp(s.y + ddy, 0, imgH - s.h),
w: s.w,
h: s.h
};
return;
}
let left = s.x;
let top = s.y;
let right = s.x + s.w;
let bottom = s.y + s.h;
if (drag.hx === 1) right = s.x + s.w + ddx;
else if (drag.hx === -1) left = s.x + ddx;
if (drag.hy === 1) bottom = s.y + s.h + ddy;
else if (drag.hy === -1) top = s.y + ddy;
const r = activeRatio;
if (r) {
if (drag.hx !== 0 && drag.hy !== 0) {
const nw = Math.max(MIN_CROP, right - left);
const nh = nw / r;
if (drag.hy === 1) bottom = top + nh;
else top = bottom - nh;
} else if (drag.hx !== 0) {
const cy = s.y + s.h / 2;
const nh = Math.max(MIN_CROP, right - left) / r;
top = cy - nh / 2;
bottom = cy + nh / 2;
} else if (drag.hy !== 0) {
const cx = s.x + s.w / 2;
const nw = Math.max(MIN_CROP, bottom - top) * r;
left = cx - nw / 2;
right = cx + nw / 2;
}
}
left = Math.max(0, left);
top = Math.max(0, top);
right = Math.min(imgW, right);
bottom = Math.min(imgH, bottom);
if (right - left < MIN_CROP) {
if (drag.hx === -1) left = right - MIN_CROP;
else right = left + MIN_CROP;
}
if (bottom - top < MIN_CROP) {
if (drag.hy === -1) top = bottom - MIN_CROP;
else bottom = top + MIN_CROP;
}
left = Math.max(0, left);
top = Math.max(0, top);
right = Math.min(imgW, right);
bottom = Math.min(imgH, bottom);
crop = { x: left, y: top, w: right - left, h: bottom - top };
}
function endDrag() {
// Pointer capture is released implicitly on pointerup.
drag = null;
}
function selectRatio(key: string) {
ratioMode = key;
const r = RATIOS.find((x) => x.key === key);
const value = r && r.key === 'orig' ? imgW / imgH : r && 'value' in r ? r.value : null;
if (!value) return; // 'free' keeps the current crop
// Fit a centred rect of this ratio inside the current crop.
const cx = crop.x + crop.w / 2;
const cy = crop.y + crop.h / 2;
let nw = crop.w;
let nh = nw / value;
if (nh > crop.h) {
nh = crop.h;
nw = nh * value;
}
nw = Math.min(nw, imgW);
nh = Math.min(nh, imgH);
crop = {
x: clamp(cx - nw / 2, 0, imgW - nw),
y: clamp(cy - nh / 2, 0, imgH - nh),
w: nw,
h: nh
};
}
function resetCrop() {
ratioMode = 'free';
crop = { x: 0, y: 0, w: imgW, h: imgH };
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
e.preventDefault();
onCancel();
}
}
function apply() {
if (!outBlob || !outUrl) return;
const url = outUrl;
const f = blobToFile(outBlob, shortName);
outUrl = ''; // hand the object URL off to the caller; don't revoke it
onApply(f, url);
}
const handles = [
{ key: 'nw', hx: -1, hy: -1 },
{ key: 'n', hx: 0, hy: -1 },
{ key: 'ne', hx: 1, hy: -1 },
{ key: 'e', hx: 1, hy: 0 },
{ key: 'se', hx: 1, hy: 1 },
{ key: 's', hx: 0, hy: 1 },
{ key: 'sw', hx: -1, hy: 1 },
{ key: 'w', hx: -1, hy: 0 }
];
</script>
<svelte:window onkeydown={onKeydown} />
<div
class="backdrop"
role="dialog"
aria-modal="true"
aria-label="Bild bearbeiten"
tabindex="-1"
>
<button type="button" class="scrim" aria-label="Schliessen" onclick={onCancel}></button>
<div class="panel">
<header class="panel-head">
<h2>Bild bearbeiten</h2>
<button type="button" class="ghost" onclick={onCancel} aria-label="Abbrechen"></button>
</header>
<div class="body">
<!-- Stage -->
<div class="stage" bind:clientWidth={stageW} bind:clientHeight={stageH}>
{#if loadError}
<p class="stage-msg">{loadError}</p>
{:else if !bitmap}
<p class="stage-msg">Lade Bild…</p>
{:else}
<div class="frame" style:width="{dispW}px" style:height="{dispH}px">
<canvas bind:this={stageCanvas}></canvas>
<div
class="crop"
style:left="{crop.x * displayScale}px"
style:top="{crop.y * displayScale}px"
style:width="{crop.w * displayScale}px"
style:height="{crop.h * displayScale}px"
role="application"
aria-label="Zuschneidebereich verschieben"
onpointerdown={(e) => startDrag(e, 'move', 0, 0)}
onpointermove={onPointerMove}
onpointerup={endDrag}
onpointercancel={endDrag}
>
<span class="third v1"></span>
<span class="third v2"></span>
<span class="third h1"></span>
<span class="third h2"></span>
{#each handles as h (h.key)}
<button
type="button"
class="handle h-{h.key}"
aria-label="Ziehpunkt {h.key}"
onpointerdown={(e) => startDrag(e, h.key, h.hx, h.hy)}
></button>
{/each}
</div>
</div>
{/if}
</div>
<!-- Controls -->
<div class="rail">
<div class="preview">
<div class="preview-img" class:busy={encoding}>
{#if outUrl}
<!-- svelte-ignore a11y_missing_attribute -->
<img src={outUrl} />
{/if}
</div>
<dl class="stats">
<div><dt>Auflösung</dt><dd>{outW || '—'} × {outH || '—'}</dd></div>
<div>
<dt>Dateigrösse</dt>
<dd class="size">{outBlob ? formatBytes(outBlob.size) : '—'}</dd>
</div>
</dl>
</div>
<fieldset class="group">
<legend>Seitenverhältnis</legend>
<div class="chips">
{#each RATIOS as r (r.key)}
<button
type="button"
class="chip"
class:active={ratioMode === r.key}
onclick={() => selectRatio(r.key)}>{r.label}</button
>
{/each}
</div>
</fieldset>
<fieldset class="group">
<legend>Max. Auflösung</legend>
<div class="chips">
{#each RES_PRESETS as p (p)}
<button
type="button"
class="chip"
class:active={maxRes === p}
onclick={() => (maxRes = p)}>{p === 0 ? 'Original' : p}</button
>
{/each}
</div>
<label class="custom">
<span>Eigene Kante</span>
<input type="number" min="0" step="50" bind:value={maxRes} />
<span class="unit">px</span>
</label>
</fieldset>
<fieldset class="group">
<legend>WebP-Qualität</legend>
<div class="quality">
<input type="range" min="1" max="100" step="1" bind:value={quality} />
<output>{quality}</output>
</div>
</fieldset>
<button type="button" class="reset" onclick={resetCrop}>Zuschnitt zurücksetzen</button>
</div>
</div>
<footer class="panel-foot">
<button type="button" class="btn ghost-btn" onclick={onCancel}>Abbrechen</button>
<button type="button" class="btn primary" disabled={!outBlob} onclick={apply}>
Übernehmen
</button>
</footer>
</div>
</div>
<style>
.backdrop {
position: fixed;
inset: 0;
z-index: 1000;
display: grid;
place-items: center;
padding: clamp(0px, 2vw, 1.5rem);
}
.scrim {
all: unset;
position: absolute;
inset: 0;
background: rgba(10, 14, 20, 0.6);
backdrop-filter: blur(4px);
cursor: pointer;
}
.panel {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
width: min(1100px, 100%);
height: min(760px, 100%);
max-height: 100%;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-card);
box-shadow: var(--shadow-lg);
overflow: hidden;
}
.panel-head,
.panel-foot {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.85rem 1.25rem;
flex-shrink: 0;
}
.panel-head {
justify-content: space-between;
border-bottom: 1px solid var(--color-border);
}
.panel-head h2 {
margin: 0;
font-size: var(--text-lg, 1.2rem);
color: var(--color-text-primary);
}
.panel-foot {
justify-content: flex-end;
border-top: 1px solid var(--color-border);
}
.ghost {
all: unset;
cursor: pointer;
color: var(--color-text-secondary);
font-size: 1.1rem;
line-height: 1;
padding: 0.35rem;
border-radius: var(--radius-sm);
}
.ghost:hover {
color: var(--color-text-primary);
background: var(--color-bg-elevated);
}
.body {
display: grid;
grid-template-columns: 1fr 320px;
flex: 1;
min-height: 0;
}
/* Stage */
.stage {
position: relative;
display: grid;
place-items: center;
min-height: 0;
background:
repeating-conic-gradient(var(--color-bg-secondary) 0% 25%, transparent 0% 50%) 50% / 24px 24px;
background-color: var(--color-bg-tertiary);
overflow: hidden;
}
.stage-msg {
color: var(--color-text-secondary);
font-size: var(--text-sm);
}
.frame {
position: relative;
touch-action: none;
box-shadow: var(--shadow-md);
}
.frame canvas {
display: block;
width: 100%;
height: 100%;
}
.crop {
position: absolute;
box-sizing: border-box;
border: 1px solid rgba(255, 255, 255, 0.95);
box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.5);
cursor: move;
touch-action: none;
}
.third {
position: absolute;
background: rgba(255, 255, 255, 0.35);
pointer-events: none;
}
.third.v1,
.third.v2 {
top: 0;
bottom: 0;
width: 1px;
}
.third.v1 {
left: 33.33%;
}
.third.v2 {
left: 66.66%;
}
.third.h1,
.third.h2 {
left: 0;
right: 0;
height: 1px;
}
.third.h1 {
top: 33.33%;
}
.third.h2 {
top: 66.66%;
}
.handle {
all: unset;
position: absolute;
width: 14px;
height: 14px;
box-sizing: border-box;
background: var(--color-primary);
border: 2px solid white;
border-radius: 50%;
cursor: pointer;
touch-action: none;
}
.h-nw {
top: -7px;
left: -7px;
cursor: nwse-resize;
}
.h-ne {
top: -7px;
right: -7px;
cursor: nesw-resize;
}
.h-se {
bottom: -7px;
right: -7px;
cursor: nwse-resize;
}
.h-sw {
bottom: -7px;
left: -7px;
cursor: nesw-resize;
}
.h-n {
top: -7px;
left: calc(50% - 7px);
cursor: ns-resize;
}
.h-s {
bottom: -7px;
left: calc(50% - 7px);
cursor: ns-resize;
}
.h-e {
right: -7px;
top: calc(50% - 7px);
cursor: ew-resize;
}
.h-w {
left: -7px;
top: calc(50% - 7px);
cursor: ew-resize;
}
/* Rail */
.rail {
display: flex;
flex-direction: column;
gap: 1.1rem;
padding: 1.1rem;
overflow-y: auto;
border-left: 1px solid var(--color-border);
background: var(--color-bg-secondary);
}
.preview {
display: flex;
gap: 0.85rem;
align-items: center;
}
.preview-img {
flex-shrink: 0;
width: 96px;
height: 96px;
border-radius: var(--radius-md);
background: var(--color-bg-tertiary);
border: 1px solid var(--color-border);
overflow: hidden;
display: grid;
place-items: center;
transition: opacity 150ms ease;
}
.preview-img.busy {
opacity: 0.55;
}
.preview-img img {
width: 100%;
height: 100%;
object-fit: cover;
}
.stats {
margin: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.stats dt {
font-size: var(--text-sm, 0.8rem);
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.stats dd {
margin: 0;
font-size: var(--text-md, 1rem);
color: var(--color-text-primary);
font-variant-numeric: tabular-nums;
}
.stats dd.size {
font-weight: 700;
color: var(--color-primary);
}
.group {
border: none;
margin: 0;
padding: 0;
min-width: 0;
}
.group legend {
padding: 0;
font-size: var(--text-sm, 0.8rem);
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.04em;
margin-bottom: 0.5rem;
}
.chips {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.chip {
all: unset;
cursor: pointer;
padding: 0.35rem 0.7rem;
border-radius: var(--radius-pill);
background: var(--color-bg-tertiary);
border: 1px solid var(--color-border);
color: var(--color-text-secondary);
font-size: var(--text-sm, 0.85rem);
transition: var(--transition-fast);
}
.chip:hover {
background: var(--color-bg-elevated);
color: var(--color-text-primary);
}
.chip.active {
background: var(--color-primary);
border-color: var(--color-primary);
color: var(--color-text-on-primary);
}
.custom {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.6rem;
font-size: var(--text-sm, 0.85rem);
color: var(--color-text-secondary);
}
.custom input {
width: 6ch;
padding: 0.3rem 0.5rem;
background: var(--color-bg-tertiary);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
color: var(--color-text-primary);
font: inherit;
}
.custom .unit {
color: var(--color-text-tertiary);
}
.quality {
display: flex;
align-items: center;
gap: 0.75rem;
}
.quality input[type='range'] {
flex: 1;
accent-color: var(--color-primary);
}
.quality output {
min-width: 2.5ch;
text-align: right;
font-variant-numeric: tabular-nums;
font-weight: 700;
color: var(--color-text-primary);
}
.reset {
all: unset;
cursor: pointer;
text-align: center;
padding: 0.5rem;
border-radius: var(--radius-md);
border: 1px solid var(--color-border);
color: var(--color-text-secondary);
font-size: var(--text-sm, 0.85rem);
transition: var(--transition-fast);
}
.reset:hover {
background: var(--color-bg-elevated);
color: var(--color-text-primary);
}
.btn {
all: unset;
cursor: pointer;
padding: 0.6rem 1.4rem;
border-radius: var(--radius-pill);
font-weight: 600;
transition: var(--transition-fast);
}
.ghost-btn {
color: var(--color-text-secondary);
}
.ghost-btn:hover {
background: var(--color-bg-elevated);
color: var(--color-text-primary);
}
.primary {
background: var(--color-primary);
color: var(--color-text-on-primary);
}
.primary:hover {
background: var(--color-primary-hover);
}
.primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
@media (max-width: 760px) {
.panel {
height: 100%;
border-radius: 0;
}
.body {
grid-template-columns: 1fr;
grid-template-rows: minmax(0, 1fr) auto;
}
.rail {
border-left: none;
border-top: 1px solid var(--color-border);
max-height: 45dvh;
}
}
</style>
@@ -1,81 +0,0 @@
<script lang="ts">
import CardAdd from '$lib/components/recipes/CardAdd.svelte';
import MediaScroller from '$lib/components/recipes/MediaScroller.svelte';
import Search from '$lib/components/recipes/Search.svelte';
import SeasonSelect from '$lib/components/recipes/SeasonSelect.svelte';
import CreateIngredientList from '$lib/components/recipes/CreateIngredientList.svelte';
import CreateStepList from '$lib/components/recipes/CreateStepList.svelte';
let {
card_data = $bindable({}),
seasonRanges = $bindable([]),
ingredients = $bindable([]),
instructions = $bindable([])
}: {
card_data?: any,
seasonRanges?: any[],
ingredients?: any[],
instructions?: any[]
} = $props();
let short_name = $state('');
let password = $state('');
let datecreated = $state(new Date());
let datemodified = $state(datecreated);
let result = $state('');
let image_preview_url = $state('');
let selected_image_file = $state<File | null>(null);
async function doPost () {
const res = await fetch('/api/add', {
method: 'POST',
body: JSON.stringify({
recipe: {
seasonRanges: seasonRanges,
...card_data,
images: [{
mediapath: short_name + '.webp',
alt: "",
caption: ""
}],
short_name,
datecreated,
datemodified,
instructions,
ingredients,
},
headers: {
'content-type': 'application/json',
bearer: password,
}
})
})
const json = await res.json()
result = JSON.stringify(json)
}
</script>
<style>
input.temp{
all: unset;
display: block;
margin: 1rem auto;
padding: 0.2em 1em;
border-radius: var(--radius-pill);
background-color: var(--nord4);
}
</style>
<CardAdd bind:card_data={card_data} bind:image_preview_url={image_preview_url} bind:selected_image_file={selected_image_file} {short_name}></CardAdd>
<input class=temp bind:value={short_name} placeholder="Kurzname"/>
<SeasonSelect bind:ranges={seasonRanges} />
<button onclick={() => console.log(seasonRanges)}>PRINTOUT season</button>
<h2>Zutaten</h2>
<CreateIngredientList bind:ingredients={ingredients}></CreateIngredientList>
<h2>Zubereitung</h2>
<CreateStepList bind:instructions={instructions} add_info={{}}></CreateStepList>
<input class=temp type="password" placeholder=Passwort bind:value={password}>
+202 -123
View File
@@ -4,25 +4,50 @@
import { getStickerById } from '$lib/utils/stickers'; import { getStickerById } from '$lib/utils/stickers';
import { import {
startOfMonth, endOfMonth, startOfWeek, endOfWeek, startOfMonth, endOfMonth, startOfWeek, endOfWeek,
eachDayOfInterval, isSameMonth, isToday, format, addMonths, subMonths eachDayOfInterval, isSameMonth, isToday, isWeekend, format, addMonths, subMonths
} from 'date-fns'; } from 'date-fns';
import { de } from 'date-fns/locale'; import { de } from 'date-fns/locale';
let { completions = [], currentUser = '' } = $props(); let { completions = [], currentUser = '' } = $props();
let viewDate = $state(new Date()); // who-did-what colours (the household)
const PERSON_COLOR = /** @type {Record<string, string>} */ ({
anna: 'var(--nord15)',
alexander: 'var(--nord10)'
});
const personColor = /** @param {string} who */ (who) => PERSON_COLOR[who?.toLowerCase()] || 'var(--nord12)';
let filteredCompletions = $derived( // every sticker drop, both members
completions let drops = $derived(completions.filter((/** @type {any} */ c) => c.stickerId));
.filter((/** @type {any} */ c) => c.stickerId)
.filter((/** @type {any} */ c) => !currentUser || c.completedBy === currentUser)
);
// Build a map: "YYYY-MM-DD" -> sticker ids[] // Who's visible on the grid. Default: just the current user; others appear
let stickersByDate = $derived.by(() => { // only when you tap their name in the tally.
let allPeople = $derived([...new Set(drops.map((/** @type {any} */ c) => c.completedBy))]);
let defaultShown = $derived(new Set(currentUser ? [currentUser] : allPeople));
/** @type {Set<string> | null} */
let manual = $state(null);
let shown = $derived(manual ?? defaultShown);
/** @param {string} who */
function toggle(who) {
const next = new Set(shown);
if (next.has(who)) next.delete(who);
else next.add(who);
manual = next;
}
/** @param {string} s */
function hash(s) {
let h = 0;
for (let i = 0; i < (s || '').length; i++) h = (Math.imul(h, 31) + s.charCodeAt(i)) | 0;
return Math.abs(h);
}
// "YYYY-MM-DD" -> completions[]
let byDate = $derived.by(() => {
/** @type {Map<string, any[]>} */ /** @type {Map<string, any[]>} */
const map = new Map(); const map = new Map();
for (const c of filteredCompletions) { for (const c of drops) {
if (!shown.has(c.completedBy)) continue;
const key = format(new Date(c.completedAt), 'yyyy-MM-dd'); const key = format(new Date(c.completedAt), 'yyyy-MM-dd');
if (!map.has(key)) map.set(key, []); if (!map.has(key)) map.set(key, []);
map.get(key)?.push(c); map.get(key)?.push(c);
@@ -31,59 +56,89 @@
}); });
let calendarDays = $derived.by(() => { let calendarDays = $derived.by(() => {
const monthStart = startOfMonth(viewDate); const calStart = startOfWeek(startOfMonth(viewDate), { locale: de });
const monthEnd = endOfMonth(viewDate); const calEnd = endOfWeek(endOfMonth(viewDate), { locale: de });
const calStart = startOfWeek(monthStart, { locale: de });
const calEnd = endOfWeek(monthEnd, { locale: de });
return eachDayOfInterval({ start: calStart, end: calEnd }); return eachDayOfInterval({ start: calStart, end: calEnd });
}); });
let viewDate = $state(new Date());
let monthLabel = $derived(format(viewDate, 'MMMM yyyy', { locale: de })); let monthLabel = $derived(format(viewDate, 'MMMM yyyy', { locale: de }));
const weekdays = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']; // per-person tally for the visible month
let tally = $derived.by(() => {
/** @type {Map<string, number>} */
const m = new Map();
for (const c of drops) {
if (!isSameMonth(new Date(c.completedAt), viewDate)) continue;
m.set(c.completedBy, (m.get(c.completedBy) || 0) + 1);
}
return [...m.entries()].sort((a, b) => b[1] - a[1]);
});
const weekdays = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
function prevMonth() { viewDate = subMonths(viewDate, 1); } function prevMonth() { viewDate = subMonths(viewDate, 1); }
function nextMonth() { viewDate = addMonths(viewDate, 1); } function nextMonth() { viewDate = addMonths(viewDate, 1); }
</script> </script>
<div class="cal-container"> <div class="cal-page">
<span class="tape tape-l" aria-hidden="true"></span>
<span class="tape tape-r" aria-hidden="true"></span>
<div class="cal-header"> <div class="cal-header">
<button class="cal-nav" onclick={prevMonth}><ChevronLeft size={18} /></button> <button class="cal-nav" onclick={prevMonth} aria-label="Voriger Monat"><ChevronLeft size={18} /></button>
<span class="cal-month">{monthLabel}</span> <span class="cal-month">{monthLabel}</span>
<button class="cal-nav" onclick={nextMonth}><ChevronRight size={18} /></button> <button class="cal-nav" onclick={nextMonth} aria-label="Nächster Monat"><ChevronRight size={18} /></button>
</div> </div>
{#if tally.length > 0}
<div class="tally">
{#each tally as [who, n] (who)}
<button
type="button"
class="tally-chip"
class:active={shown.has(who)}
class:me={who === currentUser}
style="--pc: {personColor(who)}"
title="{shown.has(who) ? 'Ausblenden' : 'Einblenden'}"
aria-pressed={shown.has(who)}
onclick={() => toggle(who)}
>
<span class="dot"></span>{who}<strong>{n}</strong>
</button>
{/each}
</div>
{/if}
<div class="cal-grid"> <div class="cal-grid">
{#each weekdays as day} {#each weekdays as day (day)}
<div class="cal-weekday">{day}</div> <div class="cal-weekday">{day}</div>
{/each} {/each}
{#each calendarDays as day} {#each calendarDays as day (day.toISOString())}
{@const key = format(day, 'yyyy-MM-dd')} {@const key = format(day, 'yyyy-MM-dd')}
{@const dayStickers = stickersByDate.get(key) || []} {@const dayDrops = byDate.get(key) || []}
{@const inMonth = isSameMonth(day, viewDate)} {@const inMonth = isSameMonth(day, viewDate)}
<div <div
class="cal-day" class="cal-day"
class:outside={!inMonth} class:outside={!inMonth}
class:weekend={isWeekend(day)}
class:today={isToday(day)} class:today={isToday(day)}
class:has-stickers={dayStickers.length > 0}
> >
<span class="cal-day-num">{format(day, 'd')}</span> <span class="cal-day-num">{format(day, 'd')}</span>
{#if dayStickers.length > 0} {#if dayDrops.length > 0}
<div class="cal-stickers"> <div class="stuck">
{#each dayStickers.slice(0, 6) as completion} {#each dayDrops.slice(0, 4) as c (c._id)}
{@const sticker = getStickerById(completion.stickerId)} {@const sticker = getStickerById(c.stickerId)}
{#if sticker} {#if sticker}
<img {@const tilt = (hash(c._id) % 13) - 6}
class="cal-sticker-img" <span class="cat" style="--tilt: {tilt}deg; --pc: {personColor(c.completedBy)}">
src="/stickers/{sticker.image}" <img src="/stickers/{sticker.image}" alt={sticker.name} title="{sticker.name} {c.taskTitle} ({c.completedBy})" loading="lazy" />
alt={sticker.name} <span class="who-dot"></span>
title="{sticker.name} {completion.taskTitle}" </span>
/>
{/if} {/if}
{/each} {/each}
{#if dayStickers.length > 6} {#if dayDrops.length > 4}
<span class="cal-more">+{dayStickers.length - 6}</span> <span class="more">+{dayDrops.length - 4}</span>
{/if} {/if}
</div> </div>
{/if} {/if}
@@ -93,27 +148,48 @@
</div> </div>
<style> <style>
.cal-container { /* warm paper page (matches the sticker album) — stays cream in both themes */
background: var(--color-bg-primary, white); .cal-page {
border: 1px solid var(--color-border, #e8e4dd); position: relative;
border-radius: 14px;
padding: 1rem;
margin-bottom: 2rem; margin-bottom: 2rem;
padding: 1.25rem 1rem 1.4rem;
border-radius: var(--radius-lg);
background-color: #f3ecd9;
background-image: radial-gradient(rgba(120, 100, 70, 0.16) 1px, transparent 1.4px);
background-size: 18px 18px;
border: 1px solid #e4d9be;
box-shadow: var(--shadow-sm), inset 0 0 50px rgba(150, 130, 90, 0.08);
} }
:global(:root[data-theme='dark']) .cal-page,
:global(:root:not([data-theme='light'])) .cal-page { background-color: #ece3cb; }
/* washi tape holding the page up */
.tape {
position: absolute;
top: -10px;
width: 78px;
height: 24px;
background: repeating-linear-gradient(45deg, rgba(136, 192, 208, 0.45) 0 7px, rgba(136, 192, 208, 0.28) 7px 14px);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12);
}
.tape-l { left: 26px; transform: rotate(-5deg); }
.tape-r { right: 26px; transform: rotate(4deg); background: repeating-linear-gradient(45deg, rgba(235, 203, 139, 0.5) 0 7px, rgba(235, 203, 139, 0.3) 7px 14px); }
.cal-header { .cal-header {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 1rem; gap: 1rem;
margin-bottom: 0.75rem; margin-bottom: 0.5rem;
} }
.cal-month { .cal-month {
font-size: 1rem; font-family: 'Fredoka', Helvetica, sans-serif;
font-size: 1.5rem;
font-weight: 700; font-weight: 700;
text-transform: capitalize; text-transform: capitalize;
min-width: 160px; min-width: 180px;
text-align: center; text-align: center;
color: #5a4a2c;
} }
.cal-nav { .cal-nav {
display: flex; display: flex;
@@ -123,132 +199,135 @@
height: 32px; height: 32px;
border: none; border: none;
background: transparent; background: transparent;
color: var(--color-text-secondary, #888); color: #8a7747;
border-radius: 8px; border-radius: 8px;
cursor: pointer; cursor: pointer;
transition: all 120ms; transition: all 120ms;
} }
.cal-nav:hover { .cal-nav:hover { background: rgba(138, 119, 71, 0.14); color: #5a4a2c; }
background: var(--color-bg-secondary, #f0ede6);
color: var(--color-text-primary, #333); .tally {
display: flex;
justify-content: center;
flex-wrap: wrap;
gap: 0.4rem;
margin-bottom: 0.8rem;
} }
.tally-chip {
display: inline-flex;
align-items: center;
gap: 0.32rem;
padding: 0.18rem 0.6rem;
font-size: 0.74rem;
font-weight: 600;
color: #5a4a2c;
background: rgba(255, 255, 255, 0.4);
border: 1px solid color-mix(in srgb, var(--pc) 30%, transparent);
border-radius: var(--radius-pill);
text-transform: capitalize;
cursor: pointer;
opacity: 0.5;
transition: opacity 120ms, background 120ms, border-color 120ms, transform 120ms;
}
.tally-chip:hover { opacity: 0.85; transform: translateY(-1px); }
.tally-chip.active {
opacity: 1;
background: color-mix(in srgb, var(--pc) 16%, rgba(255, 255, 255, 0.6));
border-color: color-mix(in srgb, var(--pc) 55%, transparent);
}
.tally-chip .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--pc); }
.tally-chip strong { font-family: 'Fredoka', Helvetica, sans-serif; color: var(--pc); }
.cal-grid { .cal-grid {
display: grid; display: grid;
grid-template-columns: repeat(7, 1fr); grid-template-columns: repeat(7, 1fr);
gap: 1px; gap: 2px;
} }
.cal-weekday { .cal-weekday {
text-align: center; text-align: center;
font-size: 0.68rem; font-size: 0.66rem;
font-weight: 700; font-weight: 700;
color: var(--color-text-secondary, #999); color: #9a865a;
padding: 0.3rem 0; padding: 0.2rem 0;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.03em; letter-spacing: 0.05em;
} }
.cal-day { .cal-day {
position: relative; position: relative;
min-height: 80px; min-height: 78px;
padding: 0.3rem; padding: 0.3rem;
border-radius: 8px; border-radius: 8px;
border: 1px solid transparent; border: 1px dashed transparent;
transition: background 120ms;
}
.cal-day.outside {
opacity: 0.25;
}
.cal-day.today {
background: rgba(94, 129, 172, 0.08);
border-color: rgba(94, 129, 172, 0.2);
} }
.cal-day.weekend { background: rgba(150, 130, 90, 0.07); }
.cal-day.outside { opacity: 0.3; }
.cal-day.today { border-color: var(--nord10); background: rgba(94, 129, 172, 0.1); }
.cal-day.today .cal-day-num { .cal-day.today .cal-day-num {
background: var(--nord10); background: var(--nord10);
color: white; color: #fff;
border-radius: 50%; border-radius: 50%;
width: 20px; width: 19px;
height: 20px; height: 19px;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
} }
.cal-day.has-stickers {
background: rgba(163, 190, 140, 0.06);
}
.cal-day-num { .cal-day-num {
font-size: 0.7rem; font-size: 0.7rem;
font-weight: 600; font-weight: 700;
color: var(--color-text-secondary, #888); color: #8a7747;
line-height: 1; line-height: 1;
display: block; display: block;
margin-bottom: 0.2rem; margin-bottom: 0.25rem;
} }
.cal-stickers { .stuck {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 3px; gap: 3px 2px;
align-items: center; align-items: center;
} }
.cal-sticker-img { /* a cat sticker "stuck" on the date — die-cut white edge + hand tilt */
width: 28px; .cat {
height: 28px; position: relative;
object-fit: contain; transform: rotate(var(--tilt));
transition: transform 150ms; transition: transform 150ms cubic-bezier(0.34, 1.56, 0.64, 1);
cursor: default; cursor: default;
} }
.cal-sticker-img:hover { .cat img {
transform: scale(2); display: block;
z-index: 10; width: 27px;
position: relative; height: 27px;
filter: drop-shadow(0 2px 6px rgba(0,0,0,0.2)); object-fit: contain;
filter:
drop-shadow(1px 0 0 #fff) drop-shadow(-1px 0 0 #fff)
drop-shadow(0 1px 0 #fff) drop-shadow(0 -1px 0 #fff)
drop-shadow(0 2px 2px rgba(0, 0, 0, 0.22));
} }
.cal-more { .cat:hover { transform: rotate(0deg) scale(1.9); z-index: 10; }
.who-dot {
position: absolute;
bottom: -1px;
right: -1px;
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--pc);
border: 1.5px solid #f3ecd9;
}
.more {
font-size: 0.6rem; font-size: 0.6rem;
font-weight: 700; font-weight: 700;
color: var(--color-text-secondary, #aaa); color: #9a865a;
display: flex; align-self: center;
align-items: center; padding-left: 1px;
padding-left: 2px;
}
/* Dark mode */
@media (prefers-color-scheme: dark) {
:global(:root:not([data-theme="light"])) .cal-container {
background: var(--nord1);
border-color: var(--nord2);
}
:global(:root:not([data-theme="light"])) .cal-nav:hover {
background: var(--nord2);
}
:global(:root:not([data-theme="light"])) .cal-day.today {
background: rgba(94, 129, 172, 0.12);
}
:global(:root:not([data-theme="light"])) .cal-day.has-stickers {
background: rgba(163, 190, 140, 0.08);
}
}
:global(:root[data-theme="dark"]) .cal-container {
background: var(--nord1);
border-color: var(--nord2);
}
:global(:root[data-theme="dark"]) .cal-nav:hover {
background: var(--nord2);
}
:global(:root[data-theme="dark"]) .cal-day.today {
background: rgba(94, 129, 172, 0.12);
}
:global(:root[data-theme="dark"]) .cal-day.has-stickers {
background: rgba(163, 190, 140, 0.08);
} }
@media (max-width: 500px) { @media (max-width: 500px) {
.cal-day { min-height: 56px; padding: 0.2rem; } .cal-day { min-height: 58px; padding: 0.2rem; }
.cal-sticker-img { width: 22px; height: 22px; } .cat img { width: 21px; height: 21px; }
.cal-stickers { gap: 2px; } .cal-month { font-size: 1.25rem; min-width: 140px; }
.cal-month { font-size: 0.9rem; min-width: 130px; } .tape { display: none; }
} }
</style> </style>
@@ -0,0 +1,208 @@
<script>
import { getRarityColor } from '$lib/utils/stickers';
let { sticker, count = 0, owned = false, onpick } = $props();
const rarityLabels = /** @type {Record<string, string>} */ ({
common: 'Gewöhnlich',
uncommon: 'Ungewöhnlich',
rare: 'Selten',
legendary: 'Legendär'
});
const foilByRarity = /** @type {Record<string, number>} */ ({
common: 0,
uncommon: 0.22,
rare: 0.6,
legendary: 1
});
/** @param {string} s */
function hash(s) {
let h = 0;
for (let i = 0; i < s.length; i++) h = (Math.imul(h, 31) + s.charCodeAt(i)) | 0;
return Math.abs(h);
}
let tilt = $derived((hash(sticker.id) % 9) - 4); // -4deg .. 4deg, hand-placed
/** @type {HTMLElement | undefined} */
let el = $state();
let mx = $state(50), my = $state(50), active = $state(false);
/** @param {PointerEvent} e */
function onmove(e) {
if (!el) return;
const r = el.getBoundingClientRect();
mx = Math.round(((e.clientX - r.left) / r.width) * 100);
my = Math.round(((e.clientY - r.top) / r.height) * 100);
active = true;
}
function leave() {
mx = 50; my = 50; active = false;
}
</script>
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<div
class="slot"
class:owned
bind:this={el}
role={owned ? 'button' : undefined}
tabindex={owned ? 0 : undefined}
onpointermove={onmove}
onpointerleave={leave}
onclick={() => owned && onpick?.(sticker)}
onkeydown={(e) => owned && (e.key === 'Enter' || e.key === ' ') && (e.preventDefault(), onpick?.(sticker))}
style="--tilt: {tilt}deg; --mx: {mx}%; --my: {my}%; --m: url('/stickers/{sticker.image}'); --foil: {owned ? foilByRarity[sticker.rarity] : 0}; --on: {active ? 1 : 0}; --rarity: {getRarityColor(sticker.rarity)};"
title={owned ? `${sticker.name} ${rarityLabels[sticker.rarity]}` : 'Noch nicht gesammelt'}
>
{#if owned}
<div class="vinyl rarity-{sticker.rarity}">
<span class="glow" aria-hidden="true"></span>
<img src="/stickers/{sticker.image}" alt={sticker.name} loading="lazy" />
<span class="sheen" aria-hidden="true"></span>
<span class="foil" aria-hidden="true"></span>
{#if count > 1}<span class="dupes">×{count}</span>{/if}
</div>
<span class="label">{sticker.name}</span>
{:else}
<div class="deboss" aria-hidden="true"></div>
<span class="label empty">?</span>
{/if}
</div>
<style>
.slot {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.3rem;
padding: 0.4rem 0.2rem;
}
/* ---------- owned: die-cut glossy vinyl ---------- */
.vinyl {
position: relative;
width: 78px;
height: 78px;
transform: rotate(var(--tilt));
transition: transform 180ms cubic-bezier(0.34, 1.56, 0.64, 1), filter 180ms;
cursor: pointer;
}
.vinyl img {
width: 100%;
height: 100%;
object-fit: contain;
/* white die-cut border + contact shadow */
filter:
drop-shadow(1.4px 0 0 #fff) drop-shadow(-1.4px 0 0 #fff)
drop-shadow(0 1.4px 0 #fff) drop-shadow(0 -1.4px 0 #fff)
drop-shadow(0 3px 3px rgba(0, 0, 0, 0.28));
}
.slot:hover .vinyl {
transform: rotate(0deg) translateY(-4px) scale(1.06);
}
/* rarity aura behind the sticker (scales with grade) */
.glow {
position: absolute;
inset: -14%;
z-index: -1;
border-radius: 50%;
background: radial-gradient(circle, var(--rarity), transparent 62%);
opacity: calc(var(--foil) * (0.3 + 0.35 * var(--on)));
filter: blur(5px);
}
.rarity-legendary .glow { animation: pulse 2.8s ease-in-out infinite; }
/* glossy specular sweep, clipped to the sticker shape */
.sheen, .foil {
position: absolute;
inset: 0;
pointer-events: none;
-webkit-mask: var(--m) center / contain no-repeat;
mask: var(--m) center / contain no-repeat;
}
.sheen {
background: radial-gradient(35% 35% at var(--mx) var(--my), rgba(255, 255, 255, 0.85), transparent 60%),
linear-gradient(120deg, transparent 40%, rgba(255, 255, 255, 0.5) 50%, transparent 60%);
background-size: 100% 100%, 220% 220%;
background-position: 0 0, var(--mx) var(--my);
opacity: calc(0.35 + 0.45 * var(--on));
mix-blend-mode: overlay;
}
/* periodic light sweep for rare+ stickers even at rest */
.rarity-rare .sheen, .rarity-legendary .sheen {
animation: sweep 4.5s ease-in-out infinite;
}
/* holographic foil for rarer stickers — always shimmers, intensifies on hover */
.foil {
background: repeating-linear-gradient(
115deg,
rgba(0, 231, 255, 0.55) 0%,
rgba(255, 0, 231, 0.55) 7%,
rgba(255, 245, 0, 0.55) 14%,
rgba(0, 231, 255, 0.55) 21%
);
background-size: 250% 250%;
background-position: var(--mx) var(--my);
mix-blend-mode: color-dodge;
opacity: calc(var(--foil) * (0.3 + 0.55 * var(--on)));
animation: holo 5s linear infinite;
}
/* when the pointer is on the card, follow it instead of auto-drifting */
.slot:hover .foil { animation-play-state: paused; }
@keyframes holo {
0% { background-position: 0% 50%; }
100% { background-position: 250% 50%; }
}
@keyframes sweep {
0%, 100% { background-position: 0 0, -60% 0; }
50% { background-position: 0 0, 160% 0; }
}
@keyframes pulse {
0%, 100% { opacity: calc(var(--foil) * 0.3); transform: scale(1); }
50% { opacity: calc(var(--foil) * 0.5); transform: scale(1.06); }
}
.dupes {
position: absolute;
bottom: -2px;
right: -4px;
padding: 0.02rem 0.32rem;
font-family: 'Fredoka', Helvetica, sans-serif;
font-size: 0.6rem;
font-weight: 700;
color: #fff;
background: var(--nord10);
border-radius: var(--radius-pill);
box-shadow: var(--shadow-sm);
}
/* ---------- missing: debossed silhouette pressed into the page ---------- */
.deboss {
width: 70px;
height: 70px;
/* fixed paper tones — the album sheet stays cream in both themes */
background: rgba(90, 74, 44, 0.22);
-webkit-mask: var(--m) center / contain no-repeat;
mask: var(--m) center / contain no-repeat;
filter: drop-shadow(0 1.5px 0.5px rgba(255, 255, 255, 0.7));
opacity: 0.85;
}
.label {
font-size: 0.62rem;
text-align: center;
color: #6a5a3a;
max-width: 92px;
line-height: 1.1;
}
.label.empty { color: #b0a07c; font-weight: 700; }
@media (prefers-reduced-motion: reduce) {
.vinyl { transition: none; }
.foil, .sheen, .glow { animation: none !important; }
}
</style>
@@ -0,0 +1,181 @@
<script>
import { scale, fade } from 'svelte/transition';
import { elasticOut } from 'svelte/easing';
import { getRarityColor } from '$lib/utils/stickers';
let { sticker, count = 0, firstEarnedLabel = '', sourceTask = '', onclose } = $props();
const rarityLabels = /** @type {Record<string, string>} */ ({
common: 'Gewöhnlich',
uncommon: 'Ungewöhnlich',
rare: 'Selten',
legendary: 'Legendär'
});
const foilByRarity = /** @type {Record<string, number>} */ ({
common: 0,
uncommon: 0.25,
rare: 0.65,
legendary: 1
});
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div class="backdrop" transition:fade={{ duration: 180 }} onclick={onclose} onkeydown={(e) => e.key === 'Escape' && onclose?.()}>
<div
class="card"
transition:scale={{ start: 0.85, duration: 320, easing: elasticOut }}
style="--rarity: {getRarityColor(sticker.rarity)}; --foil: {foilByRarity[sticker.rarity]};"
onclick={(e) => e.stopPropagation()}
>
<div class="stage">
<div class="vinyl">
<img src="/stickers/{sticker.image}" alt={sticker.name} />
<span class="foil" style="--m: url('/stickers/{sticker.image}');" aria-hidden="true"></span>
</div>
</div>
<h2 class="title">{sticker.name}</h2>
<span class="rarity-badge">{rarityLabels[sticker.rarity]}</span>
<p class="desc">{sticker.description}</p>
<dl class="stats">
<div><dt>Anzahl</dt><dd>×{count}</dd></div>
<div><dt>Zuerst erhalten</dt><dd>{firstEarnedLabel || '—'}</dd></div>
<div><dt>Quelle</dt><dd>{sourceTask || '—'}</dd></div>
</dl>
<button class="close" onclick={onclose}>Schließen</button>
</div>
</div>
<style>
.backdrop {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
padding: 1rem;
}
.card {
position: relative;
width: 100%;
max-width: 340px;
padding: 1.5rem 1.5rem 1.25rem;
text-align: center;
border-radius: var(--radius-card);
background:
radial-gradient(120% 70% at 50% 0%, color-mix(in srgb, var(--rarity) 22%, var(--color-surface)), var(--color-surface));
border: 2px solid color-mix(in srgb, var(--rarity) 60%, transparent);
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.35);
}
.stage {
display: flex;
align-items: center;
justify-content: center;
height: 170px;
margin-bottom: 0.5rem;
}
.stage::before {
content: '';
position: absolute;
width: 180px;
height: 180px;
border-radius: 50%;
background: radial-gradient(circle, var(--rarity), transparent 65%);
opacity: 0.4;
}
.vinyl { position: relative; width: 150px; height: 150px; }
.vinyl img {
width: 100%;
height: 100%;
object-fit: contain;
/* die-cut white border + drop shadow */
filter:
drop-shadow(2px 0 0 #fff) drop-shadow(-2px 0 0 #fff)
drop-shadow(0 2px 0 #fff) drop-shadow(0 -2px 0 #fff)
drop-shadow(0 6px 7px rgba(0, 0, 0, 0.3));
}
.foil {
position: absolute;
inset: 0;
pointer-events: none;
-webkit-mask: var(--m) center / contain no-repeat;
mask: var(--m) center / contain no-repeat;
background: repeating-linear-gradient(
115deg,
rgba(0, 231, 255, 0.55) 0%,
rgba(255, 0, 231, 0.55) 7%,
rgba(255, 245, 0, 0.55) 14%,
rgba(0, 231, 255, 0.55) 21%
);
background-size: 250% 250%;
mix-blend-mode: color-dodge;
opacity: var(--foil);
animation: shift 6s linear infinite;
}
@keyframes shift {
to { background-position: 250% 0; }
}
.title {
margin: 0;
font-family: 'Fredoka', Helvetica, sans-serif;
font-weight: 700;
font-size: 1.85rem;
line-height: 1.1;
color: var(--color-text-primary);
}
.rarity-badge {
display: inline-block;
margin-top: 0.3rem;
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--rarity);
}
.desc {
margin: 0.5rem 0 1rem;
font-size: 0.88rem;
font-style: italic;
color: var(--color-text-secondary);
}
.stats {
margin: 0 0 1.25rem;
text-align: left;
font-size: 0.82rem;
}
.stats div {
display: flex;
justify-content: space-between;
gap: 0.5rem;
padding: 0.4rem 0.2rem;
border-bottom: 1px solid var(--color-border);
}
.stats dt { color: var(--color-text-secondary); }
.stats dd { margin: 0; font-weight: 600; color: var(--color-text-primary); text-align: right; }
.close {
padding: 0.55rem 2rem;
border: none;
border-radius: var(--radius-pill);
background: var(--color-primary);
color: var(--color-text-on-primary);
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
transition: background var(--transition-fast);
}
.close:hover { background: var(--color-primary-hover); }
@media (prefers-reduced-motion: reduce) {
.foil { animation: none; }
}
</style>
+81
View File
@@ -0,0 +1,81 @@
/**
* Client-side image editing pipeline (no DOM / Svelte deps).
*
* The browser already ships a WebP encoder via `canvas.toBlob(cb, 'image/webp', q)`.
* Crop, scale-to-fit and the size readout are built on top of `<canvas>`; the
* encode is the only primitive we don't hand-roll. `sharp` cannot run here — it's
* a native Node binding so all of this happens on the main thread.
*/
export type CropRect = { x: number; y: number; w: number; h: number };
export type Size = { w: number; h: number };
/**
* Decode a File into an ImageBitmap, honouring EXIF orientation so that
* sideways phone photos render upright.
*/
export async function loadBitmap(file: File): Promise<ImageBitmap> {
try {
return await createImageBitmap(file, { imageOrientation: 'from-image' });
} catch {
// Older Safari ignores the options bag — fall back to the plain call.
return await createImageBitmap(file);
}
}
/**
* Scale `w`×`h` to fit inside a `max`×`max` box, preserving aspect ratio.
* Never upscales (a smaller source is returned untouched). `max <= 0` means
* "no limit" (Original).
*/
export function fitWithin(w: number, h: number, max: number): Size {
if (max <= 0 || (w <= max && h <= max)) {
return { w: Math.round(w), h: Math.round(h) };
}
const scale = Math.min(max / w, max / h);
return { w: Math.max(1, Math.round(w * scale)), h: Math.max(1, Math.round(h * scale)) };
}
/**
* Crop `bitmap` to `crop` (source pixels), scale the result to fit `maxRes`,
* and encode as WebP at `quality` (1100). Returns the encoded Blob; read
* `.size` for the final byte count.
*/
export async function renderToBlob(
bitmap: ImageBitmap,
crop: CropRect,
maxRes: number,
quality: number
): Promise<Blob> {
const out = fitWithin(crop.w, crop.h, maxRes);
const canvas = document.createElement('canvas');
canvas.width = out.w;
canvas.height = out.h;
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('2D canvas context unavailable');
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(bitmap, crop.x, crop.y, crop.w, crop.h, 0, 0, out.w, out.h);
return await new Promise<Blob>((resolve, reject) => {
canvas.toBlob(
(blob) => (blob ? resolve(blob) : reject(new Error('WebP encoding failed'))),
'image/webp',
Math.min(1, Math.max(0.01, quality / 100))
);
});
}
/** Wrap an encoded Blob as a File the form can upload. */
export function blobToFile(blob: Blob, shortName: string): File {
const base = (shortName || 'image').trim() || 'image';
return new File([blob], `${base}.webp`, { type: 'image/webp' });
}
/** Human-readable byte size, e.g. "412 KB" / "1.3 MB". */
export function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
+12
View File
@@ -172,6 +172,18 @@ const DIFFICULTY_RARITY_WEIGHTS: Record<string, Record<string, number>> = {
high: { common: 25, uncommon: 30, rare: 30, legendary: 15 }, high: { common: 25, uncommon: 30, rare: 30, legendary: 15 },
}; };
// Categories that can drop from ANY task (see getStickerForTags below).
export const ALWAYS_CATEGORIES = ['general', 'achievement', 'cozy', 'special'];
// Reverse of TAG_CATEGORY_MAP: which task tags can drop a given category.
export function getTagsForCategory(category: string): string[] {
const tags: string[] = [];
for (const [tag, cats] of Object.entries(TAG_CATEGORY_MAP)) {
if (cats.includes(category)) tags.push(tag);
}
return tags;
}
export function getStickerForTags(tags: string[], difficulty?: string): Sticker { export function getStickerForTags(tags: string[], difficulty?: string): Sticker {
const weights = DIFFICULTY_RARITY_WEIGHTS[difficulty || 'medium'] || DIFFICULTY_RARITY_WEIGHTS.medium; const weights = DIFFICULTY_RARITY_WEIGHTS[difficulty || 'medium'] || DIFFICULTY_RARITY_WEIGHTS.medium;
+4 -4
View File
@@ -80,10 +80,10 @@
// card pairs into the hero and the rest fly out.) // card pairs into the hero and the rest fly out.)
// - vt-enter-hike-detail: arriving at a hike detail page (card → zoom). // - vt-enter-hike-detail: arriving at a hike detail page (card → zoom).
// - vt-exit-hike-detail: leaving a hike detail page for anywhere // - vt-exit-hike-detail: leaving a hike detail page for anywhere
// else (back to /hikes, off to /, route-builder, …) → photo strip // else (back to /hikes, off to /, route-builder, …) → the whole
// slides back out to the right and the below-strip block flies // below-map panel flies back down off the bottom. Excluded for
// down. Excluded for slug → slug navigations (both sides share the // slug → slug navigations (both sides share the same route.id, so
// same route.id, so paired UA transitions handle them). // paired UA transitions handle them).
const intoHikesIndex = toId === '/hikes' && fromId !== '/hikes'; const intoHikesIndex = toId === '/hikes' && fromId !== '/hikes';
const outOfHikesIndex = fromId === '/hikes' && toId !== '/hikes'; const outOfHikesIndex = fromId === '/hikes' && toId !== '/hikes';
const intoHikeDetail = toId === '/hikes/[slug]'; const intoHikeDetail = toId === '/hikes/[slug]';
@@ -7,7 +7,8 @@ import { processAndSaveRecipeImage } from '$utils/imageProcessing';
import { import {
extractRecipeFromFormData, extractRecipeFromFormData,
validateRecipeData, validateRecipeData,
serializeRecipeForDatabase serializeRecipeForDatabase,
serializableFormValues
} from '$utils/recipeFormHelpers'; } from '$utils/recipeFormHelpers';
export const load: PageServerLoad = async ({locals, params}) => { export const load: PageServerLoad = async ({locals, params}) => {
@@ -51,7 +52,7 @@ export const actions = {
return fail(400, { return fail(400, {
error: validationErrors.join(', '), error: validationErrors.join(', '),
errors: validationErrors, errors: validationErrors,
values: Object.fromEntries(formData) values: serializableFormValues(formData)
}); });
} }
@@ -87,7 +88,7 @@ export const actions = {
return fail(400, { return fail(400, {
error: `Failed to process image: ${message}`, error: `Failed to process image: ${message}`,
errors: ['Image processing failed'], errors: ['Image processing failed'],
values: Object.fromEntries(formData) values: serializableFormValues(formData)
}); });
} }
} else { } else {
@@ -127,7 +128,7 @@ export const actions = {
return fail(400, { return fail(400, {
error: `A recipe with the short name "${recipeData.short_name}" already exists. Please choose a different short name.`, error: `A recipe with the short name "${recipeData.short_name}" already exists. Please choose a different short name.`,
errors: ['Duplicate short_name'], errors: ['Duplicate short_name'],
values: Object.fromEntries(formData) values: serializableFormValues(formData)
}); });
} }
@@ -135,7 +136,7 @@ export const actions = {
return fail(500, { return fail(500, {
error: `Failed to create recipe: ${dbMessage || 'Unknown database error'}`, error: `Failed to create recipe: ${dbMessage || 'Unknown database error'}`,
errors: [dbMessage], errors: [dbMessage],
values: Object.fromEntries(formData) values: serializableFormValues(formData)
}); });
} }
} catch (error: unknown) { } catch (error: unknown) {
@@ -2,15 +2,14 @@
import { enhance } from '$app/forms'; import { enhance } from '$app/forms';
import { tick } from 'svelte'; import { tick } from 'svelte';
import type { ActionData, PageData } from './$types'; import type { ActionData, PageData } from './$types';
import Check from '$lib/assets/icons/Check.svelte'; import SaveFab from '$lib/components/SaveFab.svelte';
import SeasonSelect from '$lib/components/recipes/SeasonSelect.svelte'; import SeasonSelect from '$lib/components/recipes/SeasonSelect.svelte';
import TranslationApproval from '$lib/components/recipes/TranslationApproval.svelte'; import TranslationApproval from '$lib/components/recipes/TranslationApproval.svelte';
import CardAdd from '$lib/components/recipes/CardAdd.svelte'; import EditTitleImgParallax from '$lib/components/recipes/EditTitleImgParallax.svelte';
import CreateIngredientList from '$lib/components/recipes/CreateIngredientList.svelte'; import CreateIngredientList from '$lib/components/recipes/CreateIngredientList.svelte';
import CreateStepList from '$lib/components/recipes/CreateStepList.svelte'; import CreateStepList from '$lib/components/recipes/CreateStepList.svelte';
import { toast } from '$lib/js/toast.svelte'; import { toast } from '$lib/js/toast.svelte';
import Toggle from '$lib/components/Toggle.svelte'; import Toggle from '$lib/components/Toggle.svelte';
import '$lib/css/action_button.css';
let { data, form }: { data: PageData; form: ActionData } = $props(); let { data, form }: { data: PageData; form: ActionData } = $props();
@@ -87,20 +86,30 @@
defaultForm, defaultForm,
}); });
// Show translation workflow before submission function validate(): boolean {
function prepareSubmit() {
// Client-side validation
if (!short_name.trim()) { if (!short_name.trim()) {
toast.error('Bitte geben Sie einen Kurznamen ein'); toast.error('Bitte geben Sie einen Kurznamen ein');
return; return false;
} }
if (!card_data.name) { if (!card_data.name) {
toast.error('Bitte geben Sie einen Namen ein'); toast.error('Bitte geben Sie einen Namen ein');
return; return false;
} }
return true;
}
// Create directly without an English translation (mirrors /edit's SaveFab).
async function saveRecipe() {
if (!validate()) return;
translationData = null;
await tick();
formElement?.requestSubmit();
}
// Open the optional translation workflow before submission.
function openTranslation() {
if (!validate()) return;
showTranslationWorkflow = true; showTranslationWorkflow = true;
// Scroll to translation section
setTimeout(() => { setTimeout(() => {
document.getElementById('translation-section')?.scrollIntoView({ behavior: 'smooth' }); document.getElementById('translation-section')?.scrollIntoView({ behavior: 'smooth' });
}, 100); }, 100);
@@ -147,141 +156,322 @@
</script> </script>
<style> <style>
input { h3 {
display: block; text-align: center;
border: unset; font-size: 1.15rem;
margin: 1rem auto; letter-spacing: 0.02em;
padding: 0.5em 1em; margin-block: 1.25rem 0.75rem;
border-radius: var(--radius-pill); color: var(--color-text-primary);
background-color: var(--nord4); }
font-size: 1.1rem;
transition: var(--transition-fast); /* ===== Below-hero content wrapper: full-width backdrop hides the sticky hero ===== */
} .below-hero {
input:hover, --bg-color: var(--color-bg-primary);
input:focus-visible { position: relative;
scale: 1.05 1.05; max-width: 1000px;
} margin: 0 auto;
.list_wrapper { padding: 2rem 1rem 4rem;
margin-inline: auto; }
display: flex; .below-hero::before {
flex-direction: row; content: '';
max-width: 1000px; position: absolute;
gap: 2rem; inset: 0;
justify-content: center; left: 50%;
} transform: translateX(-50%);
@media screen and (max-width: 700px) { width: 100vw;
.list_wrapper { background-color: var(--bg-color);
z-index: -1;
}
/* ===== Title-card extras (inside hero card) ===== */
.section-label {
font-size: 1.1rem;
font-weight: 700;
text-align: center;
margin-block: 1.25rem 0.5rem;
color: var(--color-text-primary);
}
.season-wrapper {
margin-block: 0.25rem 0.75rem;
}
.preamble {
margin: 0.5rem 0 0.25rem;
padding: 1em 1.25em;
background: var(--color-bg-primary);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
color: var(--color-text-primary);
font-size: 1rem;
min-height: 3em;
outline: none;
transition: border-color 200ms ease;
}
.preamble:focus,
.preamble:hover {
border-color: var(--color-primary);
}
.preamble:empty::before {
content: attr(data-placeholder);
color: var(--color-text-tertiary);
font-style: italic;
}
/* ===== Meta row under the hero: URL + base-recipe toggle ===== */
.meta-row {
display: flex;
gap: 1.5rem 2rem;
align-items: flex-end;
justify-content: center;
flex-wrap: wrap;
margin-block: 0.5rem 2rem;
padding-bottom: 1.5rem;
border-bottom: 1px solid var(--color-border);
}
.url-field {
display: flex;
flex-direction: column; flex-direction: column;
gap: 0.35rem;
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--color-text-secondary);
font-weight: 700;
} }
} .url-field input {
h1 { display: block;
text-align: center; border: 1px solid var(--color-border);
margin-bottom: 2rem; margin: 0;
} padding: 0.55em 1.1em;
.title_container { border-radius: var(--radius-pill);
max-width: 1000px; background-color: var(--color-bg-tertiary);
display: flex; color: var(--color-text-primary);
flex-direction: column; font-size: 1rem;
margin-inline: auto; font-weight: 400;
} letter-spacing: 0;
.title { text-transform: none;
position: relative; min-width: 16rem;
width: min(800px, 80vw); transition: var(--transition-fast);
margin-block: 2rem; }
margin-inline: auto; .url-field input:hover,
background-color: var(--nord6); .url-field input:focus-visible {
padding: 1rem 2rem; border-color: var(--color-primary);
} outline: none;
.title p { }
border: 2px solid var(--nord1); .toggle-field {
border-radius: 10000px; align-self: center;
padding: 0.5em 1em; }
font-size: 1.1rem;
transition: var(--transition-normal); /* ===== Ingredients + Instructions two-col ===== */
} .list_wrapper {
.title p:hover, margin-inline: auto;
.title p:focus-within { display: flex;
scale: 1.02 1.02; flex-direction: row;
} max-width: 1000px;
.addendum { gap: 2rem;
font-size: 1.1rem; justify-content: center;
max-width: 90%; margin-block: 2.5rem;
margin-inline: auto; }
border: 2px solid var(--nord1); @media screen and (max-width: 700px) {
border-radius: 45px; .list_wrapper {
padding: 1em 1em; flex-direction: column;
transition: var(--transition-fast); gap: 1rem;
} }
.addendum:hover, }
.addendum:focus-within {
scale: 1.02 1.02; /* ===== Addendum ===== */
} .addendum_wrapper {
.addendum_wrapper { max-width: 1000px;
max-width: 1000px; margin: 2.5rem auto;
margin-inline: auto; }
} .addendum {
h3 { font-size: 1.05rem;
text-align: center; max-width: min(720px, 100%);
} margin-inline: auto;
button.action_button { padding: 1em 1.25em;
animation: unset !important; background: var(--color-bg-primary);
font-size: 1.3rem; border: 1px solid var(--color-border);
color: white; border-radius: var(--radius-md);
} color: var(--color-text-primary);
.submit_buttons { min-height: 3em;
display: flex; outline: none;
margin-inline: auto; transition: border-color 200ms ease;
max-width: 1000px; }
margin-block: 1rem; .addendum:hover,
justify-content: center; .addendum:focus-visible {
align-items: center; border-color: var(--color-primary);
gap: 2rem; }
} .addendum:empty::before {
.submit_buttons p { content: attr(data-placeholder);
padding: 0; color: var(--color-text-tertiary);
padding-right: 0.5em; font-style: italic;
margin: 0; }
}
@media (prefers-color-scheme: dark) { /* ===== Form-size / Backform ===== */
:global(:root:not([data-theme="light"])) .title { .form-size-section {
background-color: var(--nord6-dark); max-width: 600px;
margin: 2rem auto;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
overflow: hidden;
}
.form-size-head {
padding: 0.75rem 1rem;
}
.form-size-title {
font-weight: 600;
font-size: var(--text-sm);
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--color-text-secondary);
}
.form-size-body {
padding: 0.25rem 1rem 1rem;
border-top: 1px solid var(--color-border);
display: flex;
flex-direction: column;
gap: 1rem;
}
.form-shape-row {
display: flex;
gap: 0.4rem;
margin-top: 0.75rem;
}
.form-shape-row .shape-tile {
flex: 1 1 0;
display: flex;
align-items: center;
justify-content: center;
height: 2.25rem;
padding: 0;
background: var(--color-bg-tertiary);
border: 1.5px solid var(--color-border);
border-radius: var(--radius-md);
cursor: pointer;
color: var(--color-text-secondary);
transition: all 150ms ease;
}
.form-shape-row .shape-tile:hover,
.form-shape-row .shape-tile:focus-visible {
border-color: color-mix(in srgb, var(--color-primary) 50%, var(--color-border));
color: var(--color-text-primary);
outline: none;
}
.form-shape-row .shape-tile[aria-checked="true"] {
border-color: var(--color-primary);
background: color-mix(in srgb, var(--color-primary) 10%, var(--color-bg-tertiary));
color: var(--color-primary);
}
.form-shape-row .shape-tile svg {
width: 1.25rem;
height: 1.25rem;
}
.form-size-inputs {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr));
gap: 0.75rem;
}
.form-size-inputs .input-wrap {
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.form-size-inputs .input-label {
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.04em;
color: var(--color-text-tertiary);
text-transform: uppercase;
}
.form-size-inputs .input-box {
position: relative;
display: flex;
align-items: center;
background: var(--color-bg-tertiary);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
transition: border-color 150ms ease, box-shadow 150ms ease;
}
.form-size-inputs .input-box:focus-within {
border-color: var(--color-primary);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-primary) 25%, transparent);
}
.form-size-inputs .input-box input {
flex: 1;
width: 100%;
padding: 0.55rem 2.25rem 0.55rem 0.75rem;
border: none;
background: transparent;
color: var(--color-text-primary);
font: inherit;
font-size: 1rem;
outline: none;
}
.form-size-inputs .input-box input::-webkit-outer-spin-button,
.form-size-inputs .input-box input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
.form-size-inputs .input-box input[type="number"] {
-moz-appearance: textfield;
appearance: textfield;
}
.form-size-inputs .input-suffix {
position: absolute;
right: 0.75rem;
font-size: 0.8rem;
font-weight: 600;
color: var(--color-text-tertiary);
pointer-events: none;
letter-spacing: 0.02em;
}
@media (max-width: 560px) {
.form-size-head { padding: 0.65rem 0.75rem; }
.form-size-body { padding: 0.25rem 0.75rem 0.85rem; }
.form-shape-row .shape-tile { height: 2rem; }
.form-shape-row .shape-tile svg { width: 1.1rem; height: 1.1rem; }
.form-size-inputs { grid-template-columns: 1fr 1fr; }
}
/* ===== Translation trigger ===== */
.translation-section-trigger {
max-width: 1000px;
margin: 2.5rem auto;
text-align: center;
}
.section-actions {
display: flex;
gap: 0.75rem;
justify-content: center;
}
.section-btn {
padding: 0.75rem 1.5rem;
border: none;
border-radius: var(--radius-pill);
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all var(--transition-fast);
background: var(--color-primary);
color: var(--color-text-on-primary);
}
.section-btn:hover {
opacity: 0.85;
}
.section-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.error-message {
background: var(--red);
color: white;
padding: 1rem;
border-radius: var(--radius-md);
margin: 1rem auto;
max-width: 800px;
text-align: center;
} }
}
:global(:root[data-theme="dark"]) .title {
background-color: var(--nord6-dark);
}
.form-size-section {
max-width: 600px;
margin: 1rem auto;
text-align: center;
}
.form-size-controls {
display: flex;
gap: 1.5rem;
justify-content: center;
margin-bottom: 0.5rem;
}
.form-size-inputs {
display: flex;
gap: 1rem;
justify-content: center;
align-items: center;
flex-wrap: wrap;
}
.form-size-inputs input[type="number"] {
width: 4em;
display: inline;
margin: 0 0.3em;
}
.error-message {
background: var(--nord11);
color: var(--nord6);
padding: 1rem;
border-radius: 4px;
margin: 1rem auto;
max-width: 800px;
text-align: center;
}
</style> </style>
<svelte:head> <svelte:head>
@@ -289,8 +479,6 @@ button.action_button {
<meta name="description" content="Hier können neue Rezepte hinzugefügt werden" /> <meta name="description" content="Hier können neue Rezepte hinzugefügt werden" />
</svelte:head> </svelte:head>
<h1>Rezept erstellen</h1>
{#if form?.error} {#if form?.error}
<div class="error-message"> <div class="error-message">
<strong>Fehler:</strong> {form.error} <strong>Fehler:</strong> {form.error}
@@ -330,16 +518,6 @@ button.action_button {
})} /> })} />
{/if} {/if}
<CardAdd
bind:card_data
bind:image_preview_url
bind:selected_image_file
short_name={short_name}
/>
<h3>Kurzname (für URL):</h3>
<input name="short_name" bind:value={short_name} placeholder="Kurzname" required />
<!-- Hidden inputs for card data --> <!-- Hidden inputs for card data -->
<input type="hidden" name="name" value={card_data.name} /> <input type="hidden" name="name" value={card_data.name} />
<input type="hidden" name="description" value={card_data.description} /> <input type="hidden" name="description" value={card_data.description} />
@@ -348,99 +526,187 @@ button.action_button {
<input type="hidden" name="portions" value={portions_local} /> <input type="hidden" name="portions" value={portions_local} />
<input type="hidden" name="isBaseRecipe" value={isBaseRecipe ? "true" : "false"} /> <input type="hidden" name="isBaseRecipe" value={isBaseRecipe ? "true" : "false"} />
<input type="hidden" name="defaultForm_json" value={defaultForm ? JSON.stringify(defaultForm) : ''} /> <input type="hidden" name="defaultForm_json" value={defaultForm ? JSON.stringify(defaultForm) : ''} />
<input type="hidden" name="preamble" value={preamble} />
<div style="text-align: center; margin: 1rem;"> <EditTitleImgParallax
<Toggle bind:card_data
bind:checked={isBaseRecipe} bind:image_preview_url
label="Als Basisrezept markieren (kann von anderen Rezepten referenziert werden)" bind:selected_image_file
/> >
</div> {#snippet titleExtras()}
<h2 class="section-label">Saison</h2>
<!-- Default Form (Cake Pan) --> <div class="season-wrapper">
<div class="form-size-section">
<h3>Backform (Standard):</h3>
<div class="form-size-controls">
<label>
<input type="radio" name="formShape" value="none" checked={!defaultForm} onchange={() => { defaultForm = null; }
} />
Keine
</label>
<label>
<input type="radio" name="formShape" value="round" checked={defaultForm?.shape === 'round'} onchange={() => { defaultForm = { shape: 'round', diameter: defaultForm?.diameter || 26 }; }
} />
Rund
</label>
<label>
<input type="radio" name="formShape" value="rectangular" checked={defaultForm?.shape === 'rectangular'} onchange={() => { defaultForm = { shape: 'rectangular', width: defaultForm?.width || 20, length: defaultForm?.length || 30 }; }
} />
Rechteckig
</label>
<label>
<input type="radio" name="formShape" value="gugelhupf" checked={defaultForm?.shape === 'gugelhupf'} onchange={() => { defaultForm = { shape: 'gugelhupf', diameter: defaultForm?.diameter || 24, innerDiameter: defaultForm?.innerDiameter || 8 }; }
} />
Gugelhupf
</label>
</div>
{#if defaultForm?.shape === 'round'}
<div class="form-size-inputs">
<label>Durchmesser: <input type="number" min="1" step="1" bind:value={defaultForm.diameter} /> cm</label>
</div>
{:else if defaultForm?.shape === 'rectangular'}
<div class="form-size-inputs">
<label>Breite: <input type="number" min="1" step="1" bind:value={defaultForm.width} /> cm</label>
<label>Länge: <input type="number" min="1" step="1" bind:value={defaultForm.length} /> cm</label>
</div>
{:else if defaultForm?.shape === 'gugelhupf'}
<div class="form-size-inputs">
<label>Aussen-Ø: <input type="number" min="1" step="1" bind:value={defaultForm.diameter} /> cm</label>
<label>Innen-Ø: <input type="number" min="1" step="1" bind:value={defaultForm.innerDiameter} /> cm</label>
</div>
{/if}
</div>
<div class="title_container">
<div class="title">
<h4>Eine etwas längere Beschreibung:</h4>
<p bind:innerText={preamble} contenteditable></p>
<input type="hidden" name="preamble" value={preamble} />
<div class="tags">
<h4>Saison:</h4>
<SeasonSelect bind:ranges={season_local} /> <SeasonSelect bind:ranges={season_local} />
</div> </div>
</div>
</div>
<div class="list_wrapper"> <h2 class="section-label">Einleitung</h2>
<div> <p
<CreateIngredientList bind:ingredients /> class="preamble"
</div> contenteditable="plaintext-only"
<div> bind:innerText={preamble}
<CreateStepList bind:instructions bind:add_info /> data-placeholder="Eine etwas längere Einleitung für dieses Rezept…"
</div> aria-label="Einleitung"
</div> ></p>
{/snippet}
<div class="addendum_wrapper"> <div class="below-hero">
<h3>Nachtrag:</h3> <div class="meta-row">
<div class="addendum" bind:innerText={addendum} contenteditable></div> <label class="url-field">
<input type="hidden" name="addendum" value={addendum} /> <span>URL-Kurzname</span>
</div> <input name="short_name" bind:value={short_name} placeholder="Kurzname" required />
</label>
<div class="toggle-field">
<Toggle bind:checked={isBaseRecipe} label="Als Basisrezept markieren" />
</div>
</div>
{#if !showTranslationWorkflow} <div class="form-size-section">
<div class="submit_buttons"> <div class="form-size-head">
<button <span class="form-size-title">Backform (Standard)</span>
type="button" </div>
class="action_button" <div class="form-size-body">
onclick={prepareSubmit} <div class="form-shape-row" role="radiogroup" aria-label="Backform">
disabled={submitting} <button
> type="button"
<p>Weiter zur Übersetzung</p> role="radio"
<Check fill="white" width="2rem" height="2rem" /> aria-checked={!defaultForm}
</button> aria-label="Keine"
title="Keine"
class="shape-tile"
onclick={() => { defaultForm = null; }}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" aria-hidden="true">
<circle cx="12" cy="12" r="8.5"/>
<path d="m6 6 12 12"/>
</svg>
</button>
<button
type="button"
role="radio"
aria-checked={defaultForm?.shape === 'round'}
aria-label="Rund"
title="Rund"
class="shape-tile"
onclick={() => { defaultForm = { shape: 'round', diameter: defaultForm?.diameter || 26 }; }}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" aria-hidden="true">
<circle cx="12" cy="12" r="8.5"/>
</svg>
</button>
<button
type="button"
role="radio"
aria-checked={defaultForm?.shape === 'rectangular'}
aria-label="Rechteckig"
title="Rechteckig"
class="shape-tile"
onclick={() => { defaultForm = { shape: 'rectangular', width: defaultForm?.width || 20, length: defaultForm?.length || 30 }; }}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" aria-hidden="true">
<rect x="3" y="6" width="18" height="12" rx="1.5"/>
</svg>
</button>
<button
type="button"
role="radio"
aria-checked={defaultForm?.shape === 'gugelhupf'}
aria-label="Gugelhupf"
title="Gugelhupf"
class="shape-tile"
onclick={() => { defaultForm = { shape: 'gugelhupf', diameter: defaultForm?.diameter || 24, innerDiameter: defaultForm?.innerDiameter || 8 }; }}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" aria-hidden="true">
<circle cx="12" cy="12" r="8.5"/>
<circle cx="12" cy="12" r="3"/>
</svg>
</button>
</div>
{#if defaultForm?.shape === 'round'}
<div class="form-size-inputs">
<label class="input-wrap">
<span class="input-label">Durchmesser</span>
<span class="input-box">
<input type="number" min="1" step="1" bind:value={defaultForm.diameter} />
<span class="input-suffix">cm</span>
</span>
</label>
</div>
{:else if defaultForm?.shape === 'rectangular'}
<div class="form-size-inputs">
<label class="input-wrap">
<span class="input-label">Breite</span>
<span class="input-box">
<input type="number" min="1" step="1" bind:value={defaultForm.width} />
<span class="input-suffix">cm</span>
</span>
</label>
<label class="input-wrap">
<span class="input-label">Länge</span>
<span class="input-box">
<input type="number" min="1" step="1" bind:value={defaultForm.length} />
<span class="input-suffix">cm</span>
</span>
</label>
</div>
{:else if defaultForm?.shape === 'gugelhupf'}
<div class="form-size-inputs">
<label class="input-wrap">
<span class="input-label">Aussen-Ø</span>
<span class="input-box">
<input type="number" min="1" step="1" bind:value={defaultForm.diameter} />
<span class="input-suffix">cm</span>
</span>
</label>
<label class="input-wrap">
<span class="input-label">Innen-Ø</span>
<span class="input-box">
<input type="number" min="1" step="1" bind:value={defaultForm.innerDiameter} />
<span class="input-suffix">cm</span>
</span>
</label>
</div>
{/if}
</div>
</div>
<div class="list_wrapper">
<div>
<CreateIngredientList bind:ingredients />
</div>
<div>
<CreateStepList bind:instructions bind:add_info />
</div>
</div>
<div class="addendum_wrapper">
<h3>Nachtrag</h3>
<div
class="addendum"
contenteditable="plaintext-only"
bind:innerText={addendum}
data-placeholder="Optionaler Nachtrag…"
aria-label="Nachtrag"
></div>
<input type="hidden" name="addendum" value={addendum} />
</div>
{#if !showTranslationWorkflow}
<div class="translation-section-trigger">
<h3>Übersetzung</h3>
<div class="section-actions">
<button type="button" class="section-btn" onclick={openTranslation} disabled={submitting}>
Übersetzen & erstellen
</button>
</div>
</div>
{/if}
</div> </div>
{/if} </EditTitleImgParallax>
</form> </form>
<SaveFab type="button" onclick={saveRecipe} disabled={submitting} label="Rezept erstellen" />
{#if showTranslationWorkflow} {#if showTranslationWorkflow}
<div id="translation-section"> <div id="translation-section">
<TranslationApproval <TranslationApproval
@@ -13,7 +13,8 @@ import {
extractRecipeFromFormData, extractRecipeFromFormData,
validateRecipeData, validateRecipeData,
serializeRecipeForDatabase, serializeRecipeForDatabase,
detectChangedFields detectChangedFields,
serializableFormValues
} from '$utils/recipeFormHelpers'; } from '$utils/recipeFormHelpers';
/** /**
@@ -98,7 +99,7 @@ export const actions = {
return fail(400, { return fail(400, {
error: 'Original short name is required for edit', error: 'Original short name is required for edit',
errors: ['Missing original_short_name'], errors: ['Missing original_short_name'],
values: Object.fromEntries(formData) values: serializableFormValues(formData)
}); });
} }
@@ -108,7 +109,7 @@ export const actions = {
return fail(400, { return fail(400, {
error: validationErrors.join(', '), error: validationErrors.join(', '),
errors: validationErrors, errors: validationErrors,
values: Object.fromEntries(formData) values: serializableFormValues(formData)
}); });
} }
@@ -143,7 +144,7 @@ export const actions = {
return fail(400, { return fail(400, {
error: `Failed to process image: ${message}`, error: `Failed to process image: ${message}`,
errors: ['Image processing failed'], errors: ['Image processing failed'],
values: Object.fromEntries(formData) values: serializableFormValues(formData)
}); });
} }
} else if (keepExistingImage && existingImagePath) { } else if (keepExistingImage && existingImagePath) {
@@ -206,7 +207,7 @@ export const actions = {
return fail(404, { return fail(404, {
error: `Recipe with short name "${originalShortName}" not found`, error: `Recipe with short name "${originalShortName}" not found`,
errors: ['Recipe not found'], errors: ['Recipe not found'],
values: Object.fromEntries(formData) values: serializableFormValues(formData)
}); });
} }
@@ -263,7 +264,7 @@ export const actions = {
return fail(400, { return fail(400, {
error: `A recipe with the short name "${recipeData.short_name}" already exists. Please choose a different short name.`, error: `A recipe with the short name "${recipeData.short_name}" already exists. Please choose a different short name.`,
errors: ['Duplicate short_name'], errors: ['Duplicate short_name'],
values: Object.fromEntries(formData) values: serializableFormValues(formData)
}); });
} }
@@ -271,7 +272,7 @@ export const actions = {
return fail(500, { return fail(500, {
error: `Failed to update recipe: ${dbMessage || 'Unknown database error'}`, error: `Failed to update recipe: ${dbMessage || 'Unknown database error'}`,
errors: [dbMessage], errors: [dbMessage],
values: Object.fromEntries(formData) values: serializableFormValues(formData)
}); });
} }
} catch (error: unknown) { } catch (error: unknown) {
@@ -3,7 +3,7 @@ import { Task } from '$models/Task';
import { TaskCompletion } from '$models/TaskCompletion'; import { TaskCompletion } from '$models/TaskCompletion';
import { dbConnect } from '$utils/db'; import { dbConnect } from '$utils/db';
import { error, json } from '@sveltejs/kit'; import { error, json } from '@sveltejs/kit';
import { getStickerForTags } from '$lib/utils/stickers'; import { getStickerForTags, getStickerById } from '$lib/utils/stickers';
import { addDays } from 'date-fns'; import { addDays } from 'date-fns';
function getNextDueDate(completedAt: Date, frequencyType: string, customDays?: number): Date { function getNextDueDate(completedAt: Date, frequencyType: string, customDays?: number): Date {
@@ -37,8 +37,11 @@ export const POST: RequestHandler = async ({ params, request, locals }) => {
const now = new Date(); const now = new Date();
// Award a sticker based on task tags and difficulty // Award a sticker. The client rolls + displays it optimistically and passes
const sticker = getStickerForTags(task.tags, task.difficulty || 'medium'); // the id here; fall back to a server-side roll if it's missing or invalid.
const sticker =
(typeof body.stickerId === 'string' && getStickerById(body.stickerId)) ||
getStickerForTags(task.tags, task.difficulty || 'medium');
// Record the completion // Record the completion
const completion = await TaskCompletion.create({ const completion = await TaskCompletion.create({
+16 -6
View File
@@ -415,21 +415,22 @@
</div> </div>
</section> </section>
<!-- Everything below the hero map — stage nav, photo strip, metrics,
tags, elevation chart, scroll area, footer — is wrapped in one panel
so view-transitions slide the whole block (with its own background)
up from the bottom on enter and down on exit. The hero map morphs
separately above this. -->
<div class="below-map" style="view-transition-name: hike-below-map">
{#if hasStages && stages} {#if hasStages && stages}
<HikeStageNav {stages} /> <HikeStageNav {stages} />
{/if} {/if}
{#if track && track.length > 0 && visibleImagePoints.length > 0} {#if track && track.length > 0 && visibleImagePoints.length > 0}
<section class="strip-area" style="view-transition-name: hike-strip"> <section class="strip-area">
<HikePhotoStrip images={visibleImagePoints} {track} {stages} /> <HikePhotoStrip images={visibleImagePoints} {track} {stages} />
</section> </section>
{/if} {/if}
<!-- Everything below the photo strip is wrapped so view-transitions
can slide the whole block (metrics, tags, elevation chart, scroll
area, footer) up from the bottom on enter and down on exit. The
hero map and strip animate separately above this. -->
<div class="below-strip" style="view-transition-name: hike-below-strip">
<section class="metrics" aria-label="Tourendaten"> <section class="metrics" aria-label="Tourendaten">
{#if hike.icon} {#if hike.icon}
<img class="route-icon" src={hike.icon} alt="" aria-hidden="true" /> <img class="route-icon" src={hike.icon} alt="" aria-hidden="true" />
@@ -985,6 +986,15 @@
opacity: 0.55; opacity: 0.55;
} }
/* The whole below-the-map block. The solid background makes its
view-transition snapshot an opaque panel, so on enter/exit the entire
sheet (background included) slides up/down from the bottom rather than
just the metric tiles appearing to float. */
.below-map {
position: relative;
background: var(--color-bg-primary);
}
.strip-area { .strip-area {
padding-inline: 1rem; padding-inline: 1rem;
margin-top: 0.5rem; margin-top: 0.5rem;
+15 -5
View File
@@ -27,6 +27,7 @@
import { flip } from 'svelte/animate'; import { flip } from 'svelte/animate';
import TaskForm from '$lib/components/tasks/TaskForm.svelte'; import TaskForm from '$lib/components/tasks/TaskForm.svelte';
import StickerPopup from '$lib/components/tasks/StickerPopup.svelte'; import StickerPopup from '$lib/components/tasks/StickerPopup.svelte';
import { getStickerForTags } from '$lib/utils/stickers';
import ProfilePicture from '$lib/components/cospend/ProfilePicture.svelte'; import ProfilePicture from '$lib/components/cospend/ProfilePicture.svelte';
let { data } = $props(); let { data } = $props();
@@ -112,16 +113,25 @@
* @param {string} [forUser] * @param {string} [forUser]
*/ */
async function completeTask(task, forUser) { async function completeTask(task, forUser) {
// Roll the sticker client-side and show it immediately — don't wait on the
// POST roundtrip (DB writes) just to learn which sticker to display.
const sticker = getStickerForTags(task.tags, task.difficulty || 'medium');
// Warm the image cache so the cat is decoded by the time the popup finishes
// its bounce-in, instead of fading into an empty circle.
if (typeof Image !== 'undefined') {
const img = new Image();
img.src = `/stickers/${sticker.image}`;
}
awardedSticker = sticker;
completeForTaskId = null;
// Persist in the background; tell the server which sticker we showed.
const res = await fetch(`/api/tasks/${task._id}/complete`, { const res = await fetch(`/api/tasks/${task._id}/complete`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(forUser ? { completedFor: forUser } : {}) body: JSON.stringify({ stickerId: sticker.id, ...(forUser ? { completedFor: forUser } : {}) })
}); });
if (!res.ok) return; if (!res.ok) return;
const result = await res.json();
awardedSticker = result.sticker;
completeForTaskId = null;
await refreshTasks(); await refreshTasks();
} }
+201 -177
View File
@@ -1,65 +1,100 @@
<script> <script>
import { invalidateAll } from '$app/navigation'; import { invalidateAll } from '$app/navigation';
import { confirm } from '$lib/js/confirmDialog.svelte'; import { confirm } from '$lib/js/confirmDialog.svelte';
import { STICKERS, getStickerById, getRarityColor } from '$lib/utils/stickers'; import { STICKERS, getStickerById, ALWAYS_CATEGORIES, getTagsForCategory } from '$lib/utils/stickers';
import { formatDistanceToNow } from 'date-fns'; import { formatDistanceToNow, format } from 'date-fns';
import { de } from 'date-fns/locale'; import { de } from 'date-fns/locale';
import { scale } from 'svelte/transition';
import { flip } from 'svelte/animate';
import Trash2 from '@lucide/svelte/icons/trash-2'; import Trash2 from '@lucide/svelte/icons/trash-2';
import Sparkles from '@lucide/svelte/icons/sparkles';
import Wind from '@lucide/svelte/icons/wind';
import Brush from '@lucide/svelte/icons/brush';
import Bath from '@lucide/svelte/icons/bath';
import UtensilsCrossed from '@lucide/svelte/icons/utensils-crossed';
import CookingPot from '@lucide/svelte/icons/cooking-pot';
import Droplets from '@lucide/svelte/icons/droplets';
import WashingMachine from '@lucide/svelte/icons/washing-machine';
import Shirt from '@lucide/svelte/icons/shirt';
import Flower2 from '@lucide/svelte/icons/flower-2';
import Leaf from '@lucide/svelte/icons/leaf';
import ShoppingCart from '@lucide/svelte/icons/shopping-cart';
import StickerCalendar from '$lib/components/tasks/StickerCalendar.svelte'; import StickerCalendar from '$lib/components/tasks/StickerCalendar.svelte';
import StickerPopup from '$lib/components/tasks/StickerPopup.svelte'; import VinylSticker from '$lib/components/tasks/VinylSticker.svelte';
import VinylStickerCard from '$lib/components/tasks/VinylStickerCard.svelte';
let { data } = $props(); let { data } = $props();
/** @type {import('$lib/utils/stickers').Sticker | null} */ /** @type {import('$lib/utils/stickers').Sticker | null} */
let selectedSticker = $state(null); let selected = $state(null);
let stats = $derived(data.stats || { userStats: [], userStickers: [], recentCompletions: [] }); let stats = $derived(data.stats || { userStats: [], userStickers: [], recentCompletions: [] });
let currentUser = $derived(data.session?.user?.nickname || ''); let currentUser = $derived(data.session?.user?.nickname || '');
const rarityLabels = /** @type {Record<string, string>} */ ({ // id -> times earned (current user)
common: 'Gewöhnlich', let counts = $derived.by(() => {
uncommon: 'Ungewöhnlich',
rare: 'Selten',
legendary: 'Legendär'
});
const rarityOrder = /** @type {Record<string, number>} */ ({
legendary: 0,
rare: 1,
uncommon: 2,
common: 3
});
// Build current user's sticker collection
let displayedStickers = $derived.by(() => {
/** @type {Map<string, number>} */ /** @type {Map<string, number>} */
const collection = new Map(); const m = new Map();
for (const entry of stats.userStickers) { for (const entry of stats.userStickers) {
if (entry._id.user === currentUser) { if (entry._id.user === currentUser) m.set(entry._id.sticker, entry.count);
collection.set(entry._id.sticker, entry.count);
}
} }
return collection; return m;
}); });
// Sort stickers for display: owned first (by rarity), then unowned // album "pages" by category
let sortedStickers = $derived.by(() => { const PAGES = [
return [...STICKERS].sort((a, b) => { { cat: 'general', name: 'Allerlei' },
const aOwned = displayedStickers.has(a.id); { cat: 'kitchen', name: 'Küche' },
const bOwned = displayedStickers.has(b.id); { cat: 'cozy', name: 'Gemütlichkeit' },
if (aOwned && !bOwned) return -1; { cat: 'plants', name: 'Pflanzen & Garten' },
if (!aOwned && bOwned) return 1; { cat: 'cleaning', name: 'Sauberkeit' },
const rarityDiff = (rarityOrder[a.rarity] ?? 3) - (rarityOrder[b.rarity] ?? 3); { cat: 'errands', name: 'Erledigungen' },
if (rarityDiff !== 0) return rarityDiff; { cat: 'achievement', name: 'Erfolge' },
return a.name.localeCompare(b.name, 'de'); { cat: 'special', name: 'Besonderes' }
}); ];
const rarityRank = /** @type {Record<string, number>} */ ({ legendary: 0, rare: 1, uncommon: 2, common: 3 });
let pages = $derived(
PAGES.map((p) => {
const items = STICKERS.filter((s) => s.category === p.cat).sort(
(a, b) => (rarityRank[a.rarity] ?? 9) - (rarityRank[b.rarity] ?? 9) || a.name.localeCompare(b.name, 'de')
);
// category rank = average sticker rarity (lower = rarer -> higher up);
// 'general' is the catch-all bucket, so it always sinks to the bottom
const avg = items.reduce((sum, s) => sum + (rarityRank[s.rarity] ?? 9), 0) / (items.length || 1);
const score = p.cat === 'general' ? 99 : avg;
const always = ALWAYS_CATEGORIES.includes(p.cat);
const tags = always ? [] : getTagsForCategory(p.cat);
return { ...p, items, score, always, tags, owned: items.filter((s) => counts.has(s.id)).length };
}).sort((a, b) => a.score - b.score)
);
// id -> { first earned label, source task } (recentCompletions is newest-first)
let info = $derived.by(() => {
/** @type {Map<string, { first: string, task: string }>} */
const m = new Map();
for (const c of stats.recentCompletions || []) {
if (c.completedBy !== currentUser || !c.stickerId) continue;
m.set(c.stickerId, {
first: format(new Date(c.completedAt), 'd. MMM yyyy', { locale: de }),
task: c.taskTitle || ''
});
}
return m;
}); });
let collectedCount = $derived(displayedStickers.size); let collectedCount = $derived(counts.size);
let totalCount = STICKERS.length; let totalCount = STICKERS.length;
let openInfo = $state('');
// same tag icons as the /tasks page
/** @type {Record<string, any>} */
const TAG_ICONS = {
putzen: Sparkles, saugen: Wind, wischen: Brush, bad: Bath,
küche: UtensilsCrossed, kochen: CookingPot, abwasch: Droplets,
wäsche: WashingMachine, bügeln: Shirt,
pflanzen: Flower2, gießen: Droplets, düngen: Leaf, garten: Leaf,
einkaufen: ShoppingCart, müll: Trash2
};
// Recent completions with stickers // Recent completions with stickers
let recentWithStickers = $derived( let recentWithStickers = $derived(
stats.recentCompletions stats.recentCompletions
@@ -89,54 +124,59 @@
<div class="progress-bar"> <div class="progress-bar">
<div class="progress-fill" style="width: {(collectedCount / totalCount) * 100}%"></div> <div class="progress-fill" style="width: {(collectedCount / totalCount) * 100}%"></div>
</div> </div>
</header> </header>
<StickerCalendar completions={stats.recentCompletions} {currentUser} /> <StickerCalendar completions={stats.recentCompletions} {currentUser} />
<h2 class="section-title">Alle Sticker</h2> <h2 class="section-title">Alle Sticker</h2>
<div class="sticker-grid"> {#each pages as page (page.cat)}
{#each sortedStickers as sticker (sticker.id)} <section class="page">
{@const count = displayedStickers.get(sticker.id) || 0} <div class="page-head">
{@const owned = count > 0} <div class="ph-title">
<!-- svelte-ignore a11y_no_static_element_interactions --> <h3>{page.name}</h3>
<!-- svelte-ignore a11y_click_events_have_key_events --> <button
<div class="info-btn"
class="sticker-card" class:open={openInfo === page.cat}
class:owned aria-label="Wie bekomme ich diese Sticker?"
class:locked={!owned} aria-expanded={openInfo === page.cat}
animate:flip={{ duration: 300 }} onclick={() => (openInfo = openInfo === page.cat ? '' : page.cat)}
style="--rarity-color: {getRarityColor(sticker.rarity)}" >i</button>
onclick={() => owned && (selectedSticker = sticker)}
>
<div class="sticker-visual">
{#if owned}
<img class="sticker-img" src="/stickers/{sticker.image}" alt={sticker.name} />
{:else}
<span class="sticker-unknown">?</span>
{/if}
{#if count > 1}
<span class="sticker-count">x{count}</span>
{/if}
</div>
<div class="sticker-info">
<span class="sticker-name">{owned ? sticker.name : '???'}</span>
<span class="sticker-rarity" style="color: {getRarityColor(sticker.rarity)}">
{rarityLabels[sticker.rarity]}
</span>
{#if owned}
<span class="sticker-desc">{sticker.description}</span>
{/if}
</div> </div>
<span class="page-count">{page.owned}/{page.items.length}</span>
</div> </div>
{/each} {#if openInfo === page.cat}
</div> <p class="earn-info">
{#if page.always}
Diese Kätzchen können bei <strong>jeder erledigten Aufgabe</strong> auftauchen.
{:else}
Tauchen bei Aufgaben mit diesen Tags auf:
<span class="tags">
{#each page.tags as t (t)}
{@const Icon = TAG_ICONS[t]}
<span class="tag">{#if Icon}<Icon size={13} strokeWidth={1.8} />{/if}{t}</span>
{/each}
</span>
{/if}
</p>
{/if}
<div class="sheet">
{#each page.items as sticker (sticker.id)}
<VinylSticker
{sticker}
owned={counts.has(sticker.id)}
count={counts.get(sticker.id) || 0}
onpick={(/** @type {any} */ s) => (selected = s)}
/>
{/each}
</div>
</section>
{/each}
{#if recentWithStickers.length > 0} {#if recentWithStickers.length > 0}
<section class="recent-section"> <section class="recent-section">
<h2>Letzte Sticker</h2> <h2>Letzte Sticker</h2>
<div class="recent-list"> <div class="recent-list">
{#each recentWithStickers as completion} {#each recentWithStickers as completion (completion._id)}
{@const sticker = getStickerById(completion.stickerId)} {@const sticker = getStickerById(completion.stickerId)}
{#if sticker} {#if sticker}
<div class="recent-item"> <div class="recent-item">
@@ -159,8 +199,15 @@
</section> </section>
{/if} {/if}
{#if selectedSticker} {#if selected}
<StickerPopup sticker={selectedSticker} title={selectedSticker.name} buttonText="Schließen" bounce={false} onclose={() => selectedSticker = null} /> {@const meta = info.get(selected.id)}
<VinylStickerCard
sticker={selected}
count={counts.get(selected.id) || 0}
firstEarnedLabel={meta?.first || ''}
sourceTask={meta?.task || ''}
onclose={() => (selected = null)}
/>
{/if} {/if}
<div class="danger-zone"> <div class="danger-zone">
@@ -173,7 +220,7 @@
<style> <style>
.rewards-page { .rewards-page {
max-width: 900px; max-width: 1000px;
margin: 0 auto; margin: 0 auto;
padding: 1.5rem 1rem; padding: 1.5rem 1rem;
} }
@@ -209,102 +256,102 @@
transition: width 500ms ease; transition: width 500ms ease;
} }
.section-title { .section-title {
font-size: 1.1rem; font-size: 1.1rem;
font-weight: 600; font-weight: 600;
margin: 0 0 0.75rem; margin: 1.5rem 0 0.75rem;
} }
/* Sticker grid */ /* sticker album pages */
.sticker-grid { .page {
display: grid; margin-bottom: 1.25rem;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); padding: 1rem 1rem 1.25rem;
gap: 0.75rem; border-radius: var(--radius-lg);
background-color: #f3ecd9;
background-image: radial-gradient(rgba(120, 100, 70, 0.16) 1px, transparent 1.4px);
background-size: 18px 18px;
border: 1px solid #e4d9be;
box-shadow: var(--shadow-sm), inset 0 0 40px rgba(150, 130, 90, 0.08);
} }
.page-head {
.sticker-card {
display: flex; display: flex;
flex-direction: column; align-items: baseline;
align-items: center; justify-content: space-between;
padding: 1rem 0.5rem; margin: 0 0 0.5rem;
border-radius: 14px; padding-bottom: 0.4rem;
border: 1px solid var(--color-border, #e8e4dd); border-bottom: 2px dashed #cdbf9d;
background: var(--color-bg-primary, white);
transition: transform 150ms, box-shadow 150ms;
} }
.sticker-card.owned { .page-head h3 {
border-color: var(--rarity-color); margin: 0;
border-width: 1.5px; font-family: 'Fredoka', Helvetica, sans-serif;
cursor: pointer; font-weight: 600;
font-size: 1.1rem;
color: #5a4a2c;
} }
.sticker-card.owned:hover { .ph-title { display: flex; align-items: center; gap: 0.45rem; }
transform: translateY(-2px); .info-btn {
box-shadow: 0 4px 16px rgba(0,0,0,0.08); width: 18px;
} height: 18px;
.sticker-card.locked { flex-shrink: 0;
opacity: 0.4; display: inline-flex;
filter: grayscale(0.8);
}
.sticker-visual {
position: relative;
width: 60px;
height: 60px;
display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
margin-bottom: 0.4rem; border: 1.5px solid #b9a877;
} background: transparent;
.owned .sticker-visual { color: #8a7747;
background: radial-gradient(circle, var(--rarity-color) 0%, transparent 70%);
border-radius: 50%; border-radius: 50%;
opacity: 0.95; font-family: Georgia, serif;
} font-style: italic;
.sticker-img { font-size: 0.72rem;
width: 52px;
height: 52px;
object-fit: contain;
filter: drop-shadow(0 2px 4px rgba(0,0,0,0.15));
}
.sticker-unknown {
font-size: 1.6rem;
font-weight: 700; font-weight: 700;
color: var(--color-text-secondary, #ccc); line-height: 1;
opacity: 0.4; cursor: pointer;
transition: all 120ms;
} }
.sticker-count { .info-btn:hover, .info-btn.open {
position: absolute; background: #8a7747;
bottom: -2px; color: #f3ecd9;
right: -2px; border-color: #8a7747;
background: var(--nord10); }
color: white; .page-count {
font-size: 0.65rem; font-family: 'Fredoka', Helvetica, sans-serif;
font-weight: 700; font-weight: 700;
padding: 0.1rem 0.35rem; font-size: 0.8rem;
border-radius: 100px; color: #8a7747;
line-height: 1.2;
} }
.earn-info {
.sticker-info { margin: 0 0 0.7rem;
text-align: center; padding: 0.5rem 0.7rem;
display: flex;
flex-direction: column;
gap: 0.1rem;
}
.sticker-name {
font-size: 0.78rem; font-size: 0.78rem;
line-height: 1.5;
color: #5a4a2c;
background: rgba(255, 255, 255, 0.55);
border: 1px dashed #cdbf9d;
border-radius: var(--radius-md);
}
.earn-info strong { color: #5a4a2c; }
.tags { display: inline-flex; flex-wrap: wrap; gap: 0.25rem; vertical-align: middle; }
.tag {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.08rem 0.5rem;
font-size: 0.72rem;
font-weight: 600; font-weight: 600;
color: #6a5a3a;
background: color-mix(in srgb, var(--nord14) 22%, #fff);
border: 1px solid color-mix(in srgb, var(--nord14) 45%, transparent);
border-radius: var(--radius-pill);
} }
.sticker-rarity { .sheet {
font-size: 0.62rem; display: grid;
font-weight: 700; grid-template-columns: repeat(auto-fill, minmax(96px, 1fr));
text-transform: uppercase; gap: 0.4rem 0.2rem;
letter-spacing: 0.04em;
} }
.sticker-desc { /* the album sheet is a physical page — stays warm in dark mode */
font-size: 0.68rem; :global(:root[data-theme='dark']) .page,
color: var(--color-text-secondary, #999); :global(:root:not([data-theme='light'])) .page {
background-color: #ece3cb;
} }
/* Recent section */ /* Recent section */
@@ -371,13 +418,6 @@
/* Dark mode */ /* Dark mode */
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
:global(:root:not([data-theme="light"])) .sticker-card {
background: var(--nord1);
border-color: var(--nord2);
}
:global(:root:not([data-theme="light"])) .sticker-card.owned {
border-color: var(--rarity-color);
}
:global(:root:not([data-theme="light"])) .recent-item { :global(:root:not([data-theme="light"])) .recent-item {
background: var(--nord1); background: var(--nord1);
border-color: var(--nord2); border-color: var(--nord2);
@@ -386,13 +426,6 @@
background: var(--nord2); background: var(--nord2);
} }
} }
:global(:root[data-theme="dark"]) .sticker-card {
background: var(--nord1);
border-color: var(--nord2);
}
:global(:root[data-theme="dark"]) .sticker-card.owned {
border-color: var(--rarity-color);
}
:global(:root[data-theme="dark"]) .recent-item { :global(:root[data-theme="dark"]) .recent-item {
background: var(--nord1); background: var(--nord1);
border-color: var(--nord2); border-color: var(--nord2);
@@ -426,13 +459,4 @@
border-color: var(--nord11); border-color: var(--nord11);
background: rgba(191, 97, 106, 0.06); background: rgba(191, 97, 106, 0.06);
} }
@media (max-width: 600px) {
.sticker-grid {
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
gap: 0.5rem;
}
.sticker-card { padding: 0.7rem 0.3rem; }
h1 { font-size: 1.3rem; }
}
</style> </style>
+28 -14
View File
@@ -1,4 +1,5 @@
import path from 'path'; import path from 'path';
import { writeFile, mkdir } from 'fs/promises';
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';
@@ -132,21 +133,34 @@ export async function processAndSaveRecipeImage(
unhashed: unhashedFilename unhashed: unhashedFilename
}); });
// Process image with Sharp - convert to WebP format // Full size: the client photo editor already crops, scales and encodes WebP at
// Save full size - both hashed and unhashed versions // the user's chosen quality. Store that byte-for-byte so the on-disk file matches
console.log('[ImageProcessing] Converting to WebP and generating full size...'); // the size the user saw in the editor — re-encoding through sharp would silently
const fullBuffer = await sharp(buffer) // re-compress and discard their quality/size choice.
.toFormat('webp') // Fallback (non-webp upload, e.g. editor bypassed): re-encode to WebP q90 as before.
.webp({ quality: 90 }) // High quality for full size const fullDir = path.join(imageDir, 'rezepte', 'full');
.toBuffer(); const thumbDir = path.join(imageDir, 'rezepte', 'thumb');
console.log('[ImageProcessing] Full size buffer created, size:', fullBuffer.length, 'bytes'); // fs.writeFile (unlike sharp's toFile) does not create parent dirs, so ensure
// both target directories exist before writing.
await mkdir(fullDir, { recursive: true });
await mkdir(thumbDir, { recursive: true });
const fullHashedPath = path.join(imageDir, 'rezepte', 'full', hashedFilename); const fullHashedPath = path.join(fullDir, hashedFilename);
const fullUnhashedPath = path.join(imageDir, 'rezepte', 'full', unhashedFilename); const fullUnhashedPath = path.join(fullDir, unhashedFilename);
let fullBuffer: Buffer;
if (file.type === 'image/webp') {
console.log('[ImageProcessing] Client WebP detected — storing full size as-is (passthrough)');
fullBuffer = buffer;
} else {
console.log('[ImageProcessing] Non-WebP upload — re-encoding full size to WebP q90...');
fullBuffer = await sharp(buffer).toFormat('webp').webp({ quality: 90 }).toBuffer();
}
console.log('[ImageProcessing] Full size buffer ready, size:', fullBuffer.length, 'bytes');
console.log('[ImageProcessing] Saving full size to:', { fullHashedPath, fullUnhashedPath }); console.log('[ImageProcessing] Saving full size to:', { fullHashedPath, fullUnhashedPath });
await sharp(fullBuffer).toFile(fullHashedPath); await writeFile(fullHashedPath, fullBuffer);
await sharp(fullBuffer).toFile(fullUnhashedPath); await writeFile(fullUnhashedPath, fullBuffer);
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
@@ -158,8 +172,8 @@ export async function processAndSaveRecipeImage(
.toBuffer(); .toBuffer();
console.log('[ImageProcessing] Thumbnail buffer created, size:', thumbBuffer.length, 'bytes'); console.log('[ImageProcessing] Thumbnail buffer created, size:', thumbBuffer.length, 'bytes');
const thumbHashedPath = path.join(imageDir, 'rezepte', 'thumb', hashedFilename); const thumbHashedPath = path.join(thumbDir, hashedFilename);
const thumbUnhashedPath = path.join(imageDir, 'rezepte', 'thumb', unhashedFilename); const thumbUnhashedPath = path.join(thumbDir, unhashedFilename);
console.log('[ImageProcessing] Saving thumbnail to:', { thumbHashedPath, thumbUnhashedPath }); console.log('[ImageProcessing] Saving thumbnail to:', { thumbHashedPath, thumbUnhashedPath });
await sharp(thumbBuffer).toFile(thumbHashedPath); await sharp(thumbBuffer).toFile(thumbHashedPath);
+14
View File
@@ -72,6 +72,20 @@ export interface RecipeFormData {
translationMetadata?: TranslationMetadata; translationMetadata?: TranslationMetadata;
} }
/**
* Build a plain object of form values that is safe to return from a SvelteKit
* action (e.g. inside `fail(...)`). Drops File entries such as `recipe_image`,
* which devalue cannot serialize and which would otherwise crash the action
* response with a 500 ("Cannot stringify arbitrary non-POJOs").
*/
export function serializableFormValues(formData: FormData): Record<string, string> {
const out: Record<string, string> = {};
for (const [key, value] of formData.entries()) {
if (typeof value === 'string') out[key] = value;
}
return out;
}
/** /**
* Extracts recipe data from FormData * Extracts recipe data from FormData
* Handles both simple fields and complex JSON-encoded nested structures * Handles both simple fields and complex JSON-encoded nested structures
-416458
View File
File diff suppressed because one or more lines are too long
+6 -1
View File
@@ -19,7 +19,12 @@ const config = {
// If your environment is not supported or you settled on a specific environment, switch out the adapter. // If your environment is not supported or you settled on a specific environment, switch out the adapter.
// See https://kit.svelte.dev/docs/adapters for more information about adapters. // See https://kit.svelte.dev/docs/adapters for more information about adapters.
adapter: adapter({ adapter: adapter({
precompress: true // Enable brotli and gzip compression // Precompression is handled by scripts/precompress.ts in postbuild.
// The adapter's own precompress is single-threaded and brotli-q11s every
// file in build/client — including ~90 MB of already-compressed media and
// 20 MB+ text blobs — adding minutes to the build for no gain. Our step is
// parallel, skips binaries, and tunes brotli quality by size.
precompress: false
}), }),
prerender: { prerender: {
// The only intentionally-static pages are /hikes (prerender=true) and // The only intentionally-static pages are /hikes (prerender=true) and