5 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
20 changed files with 1101 additions and 416831 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 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "homepage", "name": "homepage",
"version": "1.95.0", "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;
} }
+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>
+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) {
@@ -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>
+12 -5
View File
@@ -1,5 +1,5 @@
import path from 'path'; import path from 'path';
import { writeFile } from 'fs/promises'; 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';
@@ -138,8 +138,15 @@ export async function processAndSaveRecipeImage(
// the size the user saw in the editor — re-encoding through sharp would silently // the size the user saw in the editor — re-encoding through sharp would silently
// re-compress and discard their quality/size choice. // re-compress and discard their quality/size choice.
// Fallback (non-webp upload, e.g. editor bypassed): re-encode to WebP q90 as before. // Fallback (non-webp upload, e.g. editor bypassed): re-encode to WebP q90 as before.
const fullHashedPath = path.join(imageDir, 'rezepte', 'full', hashedFilename); const fullDir = path.join(imageDir, 'rezepte', 'full');
const fullUnhashedPath = path.join(imageDir, 'rezepte', 'full', unhashedFilename); const thumbDir = path.join(imageDir, 'rezepte', 'thumb');
// 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(fullDir, hashedFilename);
const fullUnhashedPath = path.join(fullDir, unhashedFilename);
let fullBuffer: Buffer; let fullBuffer: Buffer;
if (file.type === 'image/webp') { if (file.type === 'image/webp') {
@@ -165,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