Compare commits
5 Commits
cd7912fa8f
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
467f9a4e71
|
|||
|
8bd794bccb
|
|||
|
f52d6b4d4b
|
|||
|
9b5cfe5e49
|
|||
|
9fe9d95e36
|
@@ -7,6 +7,7 @@ node_modules
|
||||
/package
|
||||
.env
|
||||
.env.*
|
||||
.env_*
|
||||
!.env.example
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "homepage",
|
||||
"version": "1.95.0",
|
||||
"version": "1.96.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -8,7 +8,7 @@
|
||||
"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",
|
||||
"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",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
|
||||
+19
-1
@@ -51,7 +51,25 @@ echo " node $local_node (match)"
|
||||
echo ":: Installing deps (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
|
||||
|
||||
if [[ ! -d build ]]; then
|
||||
|
||||
@@ -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; // 1–4 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
@@ -467,10 +467,11 @@ a:focus-visible {
|
||||
/* ============================================
|
||||
HIKES TRANSITIONS
|
||||
Cards + filter fly in/out vertically, clicked card morphs into the hero
|
||||
map (cross-fade between thumbnail and map), photo strip slides in from
|
||||
the right. Page chrome under the hero cross-fades so nothing snaps in
|
||||
at transition end. Lives in app.css (not the page component) so the
|
||||
rules are still loaded on the OLD side of a nav AWAY from /hikes.
|
||||
map (cross-fade between thumbnail and map), and the whole below-map panel
|
||||
(an opaque sheet) slides up from the bottom. Page chrome under the hero
|
||||
cross-fades so nothing snaps in at transition end. Lives in app.css (not
|
||||
the page component) so the rules are still loaded on the OLD side of a
|
||||
nav AWAY from /hikes.
|
||||
============================================ */
|
||||
|
||||
@keyframes hikes-fly-up {
|
||||
@@ -489,15 +490,6 @@ a:focus-visible {
|
||||
from { opacity: 0; }
|
||||
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):
|
||||
* kill UA's default fade, switch blend mode so the custom fly animation
|
||||
* 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;
|
||||
}
|
||||
|
||||
/* Photo strip slides in from the right when arriving at a detail page,
|
||||
* and slides back out whenever the detail page is left for any other
|
||||
* route (back to /hikes, off to /, /hikes/route-builder, …). Both exit
|
||||
* scopes (vt-enter-hikes for the back-nav case, vt-exit-hike-detail for
|
||||
* everywhere else) trigger the same animation. */
|
||||
html.vt-enter-hike-detail::view-transition-new(hike-strip):only-child {
|
||||
animation: hike-strip-in-right 600ms cubic-bezier(0.2, 0.7, 0.2, 1) both;
|
||||
}
|
||||
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 {
|
||||
/* Everything below the hero map on a detail page — stage nav, photo strip,
|
||||
* metrics, tags, elevation chart, scroll area, meta footer — slides up from
|
||||
* the bottom on enter and back down on any exit, as one panel. The wrapper
|
||||
* carries `view-transition-name: hike-below-map` and an opaque background, so
|
||||
* the whole sheet (background included) moves; the hero map morphs separately
|
||||
* above, and the rest of the page chrome cross-fades via the root-pseudo rule. */
|
||||
html.vt-enter-hike-detail::view-transition-new(hike-below-map):only-child {
|
||||
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-exit-hike-detail::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-map):only-child {
|
||||
animation: hikes-fly-down 700ms cubic-bezier(0.4, 0.1, 0.4, 1) both;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,25 +4,50 @@
|
||||
import { getStickerById } from '$lib/utils/stickers';
|
||||
import {
|
||||
startOfMonth, endOfMonth, startOfWeek, endOfWeek,
|
||||
eachDayOfInterval, isSameMonth, isToday, format, addMonths, subMonths
|
||||
eachDayOfInterval, isSameMonth, isToday, isWeekend, format, addMonths, subMonths
|
||||
} from 'date-fns';
|
||||
import { de } from 'date-fns/locale';
|
||||
|
||||
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(
|
||||
completions
|
||||
.filter((/** @type {any} */ c) => c.stickerId)
|
||||
.filter((/** @type {any} */ c) => !currentUser || c.completedBy === currentUser)
|
||||
);
|
||||
// every sticker drop, both members
|
||||
let drops = $derived(completions.filter((/** @type {any} */ c) => c.stickerId));
|
||||
|
||||
// Build a map: "YYYY-MM-DD" -> sticker ids[]
|
||||
let stickersByDate = $derived.by(() => {
|
||||
// Who's visible on the grid. Default: just the current user; others appear
|
||||
// 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[]>} */
|
||||
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');
|
||||
if (!map.has(key)) map.set(key, []);
|
||||
map.get(key)?.push(c);
|
||||
@@ -31,59 +56,89 @@
|
||||
});
|
||||
|
||||
let calendarDays = $derived.by(() => {
|
||||
const monthStart = startOfMonth(viewDate);
|
||||
const monthEnd = endOfMonth(viewDate);
|
||||
const calStart = startOfWeek(monthStart, { locale: de });
|
||||
const calEnd = endOfWeek(monthEnd, { locale: de });
|
||||
const calStart = startOfWeek(startOfMonth(viewDate), { locale: de });
|
||||
const calEnd = endOfWeek(endOfMonth(viewDate), { locale: de });
|
||||
return eachDayOfInterval({ start: calStart, end: calEnd });
|
||||
});
|
||||
|
||||
let viewDate = $state(new Date());
|
||||
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 nextMonth() { viewDate = addMonths(viewDate, 1); }
|
||||
</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">
|
||||
<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>
|
||||
<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>
|
||||
|
||||
{#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">
|
||||
{#each weekdays as day}
|
||||
{#each weekdays as day (day)}
|
||||
<div class="cal-weekday">{day}</div>
|
||||
{/each}
|
||||
|
||||
{#each calendarDays as day}
|
||||
{#each calendarDays as day (day.toISOString())}
|
||||
{@const key = format(day, 'yyyy-MM-dd')}
|
||||
{@const dayStickers = stickersByDate.get(key) || []}
|
||||
{@const dayDrops = byDate.get(key) || []}
|
||||
{@const inMonth = isSameMonth(day, viewDate)}
|
||||
<div
|
||||
class="cal-day"
|
||||
class:outside={!inMonth}
|
||||
class:weekend={isWeekend(day)}
|
||||
class:today={isToday(day)}
|
||||
class:has-stickers={dayStickers.length > 0}
|
||||
>
|
||||
<span class="cal-day-num">{format(day, 'd')}</span>
|
||||
{#if dayStickers.length > 0}
|
||||
<div class="cal-stickers">
|
||||
{#each dayStickers.slice(0, 6) as completion}
|
||||
{@const sticker = getStickerById(completion.stickerId)}
|
||||
{#if dayDrops.length > 0}
|
||||
<div class="stuck">
|
||||
{#each dayDrops.slice(0, 4) as c (c._id)}
|
||||
{@const sticker = getStickerById(c.stickerId)}
|
||||
{#if sticker}
|
||||
<img
|
||||
class="cal-sticker-img"
|
||||
src="/stickers/{sticker.image}"
|
||||
alt={sticker.name}
|
||||
title="{sticker.name} — {completion.taskTitle}"
|
||||
/>
|
||||
{@const tilt = (hash(c._id) % 13) - 6}
|
||||
<span class="cat" style="--tilt: {tilt}deg; --pc: {personColor(c.completedBy)}">
|
||||
<img src="/stickers/{sticker.image}" alt={sticker.name} title="{sticker.name} — {c.taskTitle} ({c.completedBy})" loading="lazy" />
|
||||
<span class="who-dot"></span>
|
||||
</span>
|
||||
{/if}
|
||||
{/each}
|
||||
{#if dayStickers.length > 6}
|
||||
<span class="cal-more">+{dayStickers.length - 6}</span>
|
||||
{#if dayDrops.length > 4}
|
||||
<span class="more">+{dayDrops.length - 4}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -93,27 +148,48 @@
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.cal-container {
|
||||
background: var(--color-bg-primary, white);
|
||||
border: 1px solid var(--color-border, #e8e4dd);
|
||||
border-radius: 14px;
|
||||
padding: 1rem;
|
||||
/* warm paper page (matches the sticker album) — stays cream in both themes */
|
||||
.cal-page {
|
||||
position: relative;
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 0.75rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.cal-month {
|
||||
font-size: 1rem;
|
||||
font-family: 'Fredoka', Helvetica, sans-serif;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
text-transform: capitalize;
|
||||
min-width: 160px;
|
||||
min-width: 180px;
|
||||
text-align: center;
|
||||
color: #5a4a2c;
|
||||
}
|
||||
.cal-nav {
|
||||
display: flex;
|
||||
@@ -123,132 +199,135 @@
|
||||
height: 32px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--color-text-secondary, #888);
|
||||
color: #8a7747;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 120ms;
|
||||
}
|
||||
.cal-nav:hover {
|
||||
background: var(--color-bg-secondary, #f0ede6);
|
||||
color: var(--color-text-primary, #333);
|
||||
.cal-nav:hover { background: rgba(138, 119, 71, 0.14); color: #5a4a2c; }
|
||||
|
||||
.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 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: 1px;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.cal-weekday {
|
||||
text-align: center;
|
||||
font-size: 0.68rem;
|
||||
font-size: 0.66rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-secondary, #999);
|
||||
padding: 0.3rem 0;
|
||||
color: #9a865a;
|
||||
padding: 0.2rem 0;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.cal-day {
|
||||
position: relative;
|
||||
min-height: 80px;
|
||||
min-height: 78px;
|
||||
padding: 0.3rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid 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);
|
||||
border: 1px dashed transparent;
|
||||
}
|
||||
.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 {
|
||||
background: var(--nord10);
|
||||
color: white;
|
||||
color: #fff;
|
||||
border-radius: 50%;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
width: 19px;
|
||||
height: 19px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.cal-day.has-stickers {
|
||||
background: rgba(163, 190, 140, 0.06);
|
||||
}
|
||||
|
||||
.cal-day-num {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-secondary, #888);
|
||||
font-weight: 700;
|
||||
color: #8a7747;
|
||||
line-height: 1;
|
||||
display: block;
|
||||
margin-bottom: 0.2rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.cal-stickers {
|
||||
.stuck {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 3px;
|
||||
gap: 3px 2px;
|
||||
align-items: center;
|
||||
}
|
||||
.cal-sticker-img {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
object-fit: contain;
|
||||
transition: transform 150ms;
|
||||
/* a cat sticker "stuck" on the date — die-cut white edge + hand tilt */
|
||||
.cat {
|
||||
position: relative;
|
||||
transform: rotate(var(--tilt));
|
||||
transition: transform 150ms cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
cursor: default;
|
||||
}
|
||||
.cal-sticker-img:hover {
|
||||
transform: scale(2);
|
||||
z-index: 10;
|
||||
position: relative;
|
||||
filter: drop-shadow(0 2px 6px rgba(0,0,0,0.2));
|
||||
.cat img {
|
||||
display: block;
|
||||
width: 27px;
|
||||
height: 27px;
|
||||
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-weight: 700;
|
||||
color: var(--color-text-secondary, #aaa);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
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);
|
||||
color: #9a865a;
|
||||
align-self: center;
|
||||
padding-left: 1px;
|
||||
}
|
||||
|
||||
@media (max-width: 500px) {
|
||||
.cal-day { min-height: 56px; padding: 0.2rem; }
|
||||
.cal-sticker-img { width: 22px; height: 22px; }
|
||||
.cal-stickers { gap: 2px; }
|
||||
.cal-month { font-size: 0.9rem; min-width: 130px; }
|
||||
.cal-day { min-height: 58px; padding: 0.2rem; }
|
||||
.cat img { width: 21px; height: 21px; }
|
||||
.cal-month { font-size: 1.25rem; min-width: 140px; }
|
||||
.tape { display: none; }
|
||||
}
|
||||
</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>
|
||||
@@ -172,6 +172,18 @@ const DIFFICULTY_RARITY_WEIGHTS: Record<string, Record<string, number>> = {
|
||||
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 {
|
||||
const weights = DIFFICULTY_RARITY_WEIGHTS[difficulty || 'medium'] || DIFFICULTY_RARITY_WEIGHTS.medium;
|
||||
|
||||
|
||||
@@ -80,10 +80,10 @@
|
||||
// card pairs into the hero and the rest fly out.)
|
||||
// - vt-enter-hike-detail: arriving at a hike detail page (card → zoom).
|
||||
// - vt-exit-hike-detail: leaving a hike detail page for anywhere
|
||||
// else (back to /hikes, off to /, route-builder, …) → photo strip
|
||||
// slides back out to the right and the below-strip block flies
|
||||
// down. Excluded for slug → slug navigations (both sides share the
|
||||
// same route.id, so paired UA transitions handle them).
|
||||
// else (back to /hikes, off to /, route-builder, …) → the whole
|
||||
// below-map panel flies back down off the bottom. Excluded for
|
||||
// slug → slug navigations (both sides share the same route.id, so
|
||||
// paired UA transitions handle them).
|
||||
const intoHikesIndex = toId === '/hikes' && fromId !== '/hikes';
|
||||
const outOfHikesIndex = fromId === '/hikes' && toId !== '/hikes';
|
||||
const intoHikeDetail = toId === '/hikes/[slug]';
|
||||
|
||||
@@ -7,7 +7,8 @@ import { processAndSaveRecipeImage } from '$utils/imageProcessing';
|
||||
import {
|
||||
extractRecipeFromFormData,
|
||||
validateRecipeData,
|
||||
serializeRecipeForDatabase
|
||||
serializeRecipeForDatabase,
|
||||
serializableFormValues
|
||||
} from '$utils/recipeFormHelpers';
|
||||
|
||||
export const load: PageServerLoad = async ({locals, params}) => {
|
||||
@@ -51,7 +52,7 @@ export const actions = {
|
||||
return fail(400, {
|
||||
error: validationErrors.join(', '),
|
||||
errors: validationErrors,
|
||||
values: Object.fromEntries(formData)
|
||||
values: serializableFormValues(formData)
|
||||
});
|
||||
}
|
||||
|
||||
@@ -87,7 +88,7 @@ export const actions = {
|
||||
return fail(400, {
|
||||
error: `Failed to process image: ${message}`,
|
||||
errors: ['Image processing failed'],
|
||||
values: Object.fromEntries(formData)
|
||||
values: serializableFormValues(formData)
|
||||
});
|
||||
}
|
||||
} else {
|
||||
@@ -127,7 +128,7 @@ export const actions = {
|
||||
return fail(400, {
|
||||
error: `A recipe with the short name "${recipeData.short_name}" already exists. Please choose a different short name.`,
|
||||
errors: ['Duplicate short_name'],
|
||||
values: Object.fromEntries(formData)
|
||||
values: serializableFormValues(formData)
|
||||
});
|
||||
}
|
||||
|
||||
@@ -135,7 +136,7 @@ export const actions = {
|
||||
return fail(500, {
|
||||
error: `Failed to create recipe: ${dbMessage || 'Unknown database error'}`,
|
||||
errors: [dbMessage],
|
||||
values: Object.fromEntries(formData)
|
||||
values: serializableFormValues(formData)
|
||||
});
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
|
||||
@@ -13,7 +13,8 @@ import {
|
||||
extractRecipeFromFormData,
|
||||
validateRecipeData,
|
||||
serializeRecipeForDatabase,
|
||||
detectChangedFields
|
||||
detectChangedFields,
|
||||
serializableFormValues
|
||||
} from '$utils/recipeFormHelpers';
|
||||
|
||||
/**
|
||||
@@ -98,7 +99,7 @@ export const actions = {
|
||||
return fail(400, {
|
||||
error: 'Original short name is required for edit',
|
||||
errors: ['Missing original_short_name'],
|
||||
values: Object.fromEntries(formData)
|
||||
values: serializableFormValues(formData)
|
||||
});
|
||||
}
|
||||
|
||||
@@ -108,7 +109,7 @@ export const actions = {
|
||||
return fail(400, {
|
||||
error: validationErrors.join(', '),
|
||||
errors: validationErrors,
|
||||
values: Object.fromEntries(formData)
|
||||
values: serializableFormValues(formData)
|
||||
});
|
||||
}
|
||||
|
||||
@@ -143,7 +144,7 @@ export const actions = {
|
||||
return fail(400, {
|
||||
error: `Failed to process image: ${message}`,
|
||||
errors: ['Image processing failed'],
|
||||
values: Object.fromEntries(formData)
|
||||
values: serializableFormValues(formData)
|
||||
});
|
||||
}
|
||||
} else if (keepExistingImage && existingImagePath) {
|
||||
@@ -206,7 +207,7 @@ export const actions = {
|
||||
return fail(404, {
|
||||
error: `Recipe with short name "${originalShortName}" not found`,
|
||||
errors: ['Recipe not found'],
|
||||
values: Object.fromEntries(formData)
|
||||
values: serializableFormValues(formData)
|
||||
});
|
||||
}
|
||||
|
||||
@@ -263,7 +264,7 @@ export const actions = {
|
||||
return fail(400, {
|
||||
error: `A recipe with the short name "${recipeData.short_name}" already exists. Please choose a different short name.`,
|
||||
errors: ['Duplicate short_name'],
|
||||
values: Object.fromEntries(formData)
|
||||
values: serializableFormValues(formData)
|
||||
});
|
||||
}
|
||||
|
||||
@@ -271,7 +272,7 @@ export const actions = {
|
||||
return fail(500, {
|
||||
error: `Failed to update recipe: ${dbMessage || 'Unknown database error'}`,
|
||||
errors: [dbMessage],
|
||||
values: Object.fromEntries(formData)
|
||||
values: serializableFormValues(formData)
|
||||
});
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Task } from '$models/Task';
|
||||
import { TaskCompletion } from '$models/TaskCompletion';
|
||||
import { dbConnect } from '$utils/db';
|
||||
import { error, json } from '@sveltejs/kit';
|
||||
import { getStickerForTags } from '$lib/utils/stickers';
|
||||
import { getStickerForTags, getStickerById } from '$lib/utils/stickers';
|
||||
import { addDays } from 'date-fns';
|
||||
|
||||
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();
|
||||
|
||||
// Award a sticker based on task tags and difficulty
|
||||
const sticker = getStickerForTags(task.tags, task.difficulty || 'medium');
|
||||
// Award a sticker. The client rolls + displays it optimistically and passes
|
||||
// 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
|
||||
const completion = await TaskCompletion.create({
|
||||
|
||||
@@ -415,21 +415,22 @@
|
||||
</div>
|
||||
</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}
|
||||
<HikeStageNav {stages} />
|
||||
{/if}
|
||||
|
||||
{#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} />
|
||||
</section>
|
||||
{/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">
|
||||
{#if hike.icon}
|
||||
<img class="route-icon" src={hike.icon} alt="" aria-hidden="true" />
|
||||
@@ -985,6 +986,15 @@
|
||||
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 {
|
||||
padding-inline: 1rem;
|
||||
margin-top: 0.5rem;
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
import { flip } from 'svelte/animate';
|
||||
import TaskForm from '$lib/components/tasks/TaskForm.svelte';
|
||||
import StickerPopup from '$lib/components/tasks/StickerPopup.svelte';
|
||||
import { getStickerForTags } from '$lib/utils/stickers';
|
||||
import ProfilePicture from '$lib/components/cospend/ProfilePicture.svelte';
|
||||
|
||||
let { data } = $props();
|
||||
@@ -112,16 +113,25 @@
|
||||
* @param {string} [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`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(forUser ? { completedFor: forUser } : {})
|
||||
body: JSON.stringify({ stickerId: sticker.id, ...(forUser ? { completedFor: forUser } : {}) })
|
||||
});
|
||||
if (!res.ok) return;
|
||||
const result = await res.json();
|
||||
|
||||
awardedSticker = result.sticker;
|
||||
completeForTaskId = null;
|
||||
await refreshTasks();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,65 +1,100 @@
|
||||
<script>
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
import { confirm } from '$lib/js/confirmDialog.svelte';
|
||||
import { STICKERS, getStickerById, getRarityColor } from '$lib/utils/stickers';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { STICKERS, getStickerById, ALWAYS_CATEGORIES, getTagsForCategory } from '$lib/utils/stickers';
|
||||
import { formatDistanceToNow, format } from 'date-fns';
|
||||
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 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 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();
|
||||
|
||||
/** @type {import('$lib/utils/stickers').Sticker | null} */
|
||||
let selectedSticker = $state(null);
|
||||
let selected = $state(null);
|
||||
|
||||
let stats = $derived(data.stats || { userStats: [], userStickers: [], recentCompletions: [] });
|
||||
let currentUser = $derived(data.session?.user?.nickname || '');
|
||||
|
||||
const rarityLabels = /** @type {Record<string, string>} */ ({
|
||||
common: 'Gewöhnlich',
|
||||
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(() => {
|
||||
// id -> times earned (current user)
|
||||
let counts = $derived.by(() => {
|
||||
/** @type {Map<string, number>} */
|
||||
const collection = new Map();
|
||||
const m = new Map();
|
||||
for (const entry of stats.userStickers) {
|
||||
if (entry._id.user === currentUser) {
|
||||
collection.set(entry._id.sticker, entry.count);
|
||||
}
|
||||
if (entry._id.user === currentUser) m.set(entry._id.sticker, entry.count);
|
||||
}
|
||||
return collection;
|
||||
return m;
|
||||
});
|
||||
|
||||
// Sort stickers for display: owned first (by rarity), then unowned
|
||||
let sortedStickers = $derived.by(() => {
|
||||
return [...STICKERS].sort((a, b) => {
|
||||
const aOwned = displayedStickers.has(a.id);
|
||||
const bOwned = displayedStickers.has(b.id);
|
||||
if (aOwned && !bOwned) return -1;
|
||||
if (!aOwned && bOwned) return 1;
|
||||
const rarityDiff = (rarityOrder[a.rarity] ?? 3) - (rarityOrder[b.rarity] ?? 3);
|
||||
if (rarityDiff !== 0) return rarityDiff;
|
||||
return a.name.localeCompare(b.name, 'de');
|
||||
});
|
||||
// album "pages" by category
|
||||
const PAGES = [
|
||||
{ cat: 'general', name: 'Allerlei' },
|
||||
{ cat: 'kitchen', name: 'Küche' },
|
||||
{ cat: 'cozy', name: 'Gemütlichkeit' },
|
||||
{ cat: 'plants', name: 'Pflanzen & Garten' },
|
||||
{ cat: 'cleaning', name: 'Sauberkeit' },
|
||||
{ cat: 'errands', name: 'Erledigungen' },
|
||||
{ cat: 'achievement', name: 'Erfolge' },
|
||||
{ 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 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
|
||||
let recentWithStickers = $derived(
|
||||
stats.recentCompletions
|
||||
@@ -89,54 +124,59 @@
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" style="width: {(collectedCount / totalCount) * 100}%"></div>
|
||||
</div>
|
||||
|
||||
</header>
|
||||
|
||||
<StickerCalendar completions={stats.recentCompletions} {currentUser} />
|
||||
|
||||
<h2 class="section-title">Alle Sticker</h2>
|
||||
<div class="sticker-grid">
|
||||
{#each sortedStickers as sticker (sticker.id)}
|
||||
{@const count = displayedStickers.get(sticker.id) || 0}
|
||||
{@const owned = count > 0}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<div
|
||||
class="sticker-card"
|
||||
class:owned
|
||||
class:locked={!owned}
|
||||
animate:flip={{ duration: 300 }}
|
||||
style="--rarity-color: {getRarityColor(sticker.rarity)}"
|
||||
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}
|
||||
{#each pages as page (page.cat)}
|
||||
<section class="page">
|
||||
<div class="page-head">
|
||||
<div class="ph-title">
|
||||
<h3>{page.name}</h3>
|
||||
<button
|
||||
class="info-btn"
|
||||
class:open={openInfo === page.cat}
|
||||
aria-label="Wie bekomme ich diese Sticker?"
|
||||
aria-expanded={openInfo === page.cat}
|
||||
onclick={() => (openInfo = openInfo === page.cat ? '' : page.cat)}
|
||||
>i</button>
|
||||
</div>
|
||||
<span class="page-count">{page.owned}/{page.items.length}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{#if openInfo === page.cat}
|
||||
<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}
|
||||
<section class="recent-section">
|
||||
<h2>Letzte Sticker</h2>
|
||||
<div class="recent-list">
|
||||
{#each recentWithStickers as completion}
|
||||
{#each recentWithStickers as completion (completion._id)}
|
||||
{@const sticker = getStickerById(completion.stickerId)}
|
||||
{#if sticker}
|
||||
<div class="recent-item">
|
||||
@@ -159,8 +199,15 @@
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if selectedSticker}
|
||||
<StickerPopup sticker={selectedSticker} title={selectedSticker.name} buttonText="Schließen" bounce={false} onclose={() => selectedSticker = null} />
|
||||
{#if selected}
|
||||
{@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}
|
||||
|
||||
<div class="danger-zone">
|
||||
@@ -173,7 +220,7 @@
|
||||
|
||||
<style>
|
||||
.rewards-page {
|
||||
max-width: 900px;
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
padding: 1.5rem 1rem;
|
||||
}
|
||||
@@ -209,102 +256,102 @@
|
||||
transition: width 500ms ease;
|
||||
}
|
||||
|
||||
|
||||
.section-title {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 0.75rem;
|
||||
margin: 1.5rem 0 0.75rem;
|
||||
}
|
||||
|
||||
/* Sticker grid */
|
||||
.sticker-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
gap: 0.75rem;
|
||||
/* sticker album pages */
|
||||
.page {
|
||||
margin-bottom: 1.25rem;
|
||||
padding: 1rem 1rem 1.25rem;
|
||||
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);
|
||||
}
|
||||
|
||||
.sticker-card {
|
||||
.page-head {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 1rem 0.5rem;
|
||||
border-radius: 14px;
|
||||
border: 1px solid var(--color-border, #e8e4dd);
|
||||
background: var(--color-bg-primary, white);
|
||||
transition: transform 150ms, box-shadow 150ms;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
margin: 0 0 0.5rem;
|
||||
padding-bottom: 0.4rem;
|
||||
border-bottom: 2px dashed #cdbf9d;
|
||||
}
|
||||
.sticker-card.owned {
|
||||
border-color: var(--rarity-color);
|
||||
border-width: 1.5px;
|
||||
cursor: pointer;
|
||||
.page-head h3 {
|
||||
margin: 0;
|
||||
font-family: 'Fredoka', Helvetica, sans-serif;
|
||||
font-weight: 600;
|
||||
font-size: 1.1rem;
|
||||
color: #5a4a2c;
|
||||
}
|
||||
.sticker-card.owned:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.08);
|
||||
}
|
||||
.sticker-card.locked {
|
||||
opacity: 0.4;
|
||||
filter: grayscale(0.8);
|
||||
}
|
||||
|
||||
.sticker-visual {
|
||||
position: relative;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
display: flex;
|
||||
.ph-title { display: flex; align-items: center; gap: 0.45rem; }
|
||||
.info-btn {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
.owned .sticker-visual {
|
||||
background: radial-gradient(circle, var(--rarity-color) 0%, transparent 70%);
|
||||
border: 1.5px solid #b9a877;
|
||||
background: transparent;
|
||||
color: #8a7747;
|
||||
border-radius: 50%;
|
||||
opacity: 0.95;
|
||||
}
|
||||
.sticker-img {
|
||||
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-family: Georgia, serif;
|
||||
font-style: italic;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-secondary, #ccc);
|
||||
opacity: 0.4;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
transition: all 120ms;
|
||||
}
|
||||
.sticker-count {
|
||||
position: absolute;
|
||||
bottom: -2px;
|
||||
right: -2px;
|
||||
background: var(--nord10);
|
||||
color: white;
|
||||
font-size: 0.65rem;
|
||||
.info-btn:hover, .info-btn.open {
|
||||
background: #8a7747;
|
||||
color: #f3ecd9;
|
||||
border-color: #8a7747;
|
||||
}
|
||||
.page-count {
|
||||
font-family: 'Fredoka', Helvetica, sans-serif;
|
||||
font-weight: 700;
|
||||
padding: 0.1rem 0.35rem;
|
||||
border-radius: 100px;
|
||||
line-height: 1.2;
|
||||
font-size: 0.8rem;
|
||||
color: #8a7747;
|
||||
}
|
||||
|
||||
.sticker-info {
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.1rem;
|
||||
}
|
||||
.sticker-name {
|
||||
.earn-info {
|
||||
margin: 0 0 0.7rem;
|
||||
padding: 0.5rem 0.7rem;
|
||||
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;
|
||||
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 {
|
||||
font-size: 0.62rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
.sheet {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(96px, 1fr));
|
||||
gap: 0.4rem 0.2rem;
|
||||
}
|
||||
.sticker-desc {
|
||||
font-size: 0.68rem;
|
||||
color: var(--color-text-secondary, #999);
|
||||
/* the album sheet is a physical page — stays warm in dark mode */
|
||||
:global(:root[data-theme='dark']) .page,
|
||||
:global(:root:not([data-theme='light'])) .page {
|
||||
background-color: #ece3cb;
|
||||
}
|
||||
|
||||
/* Recent section */
|
||||
@@ -371,13 +418,6 @@
|
||||
|
||||
/* Dark mode */
|
||||
@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 {
|
||||
background: var(--nord1);
|
||||
border-color: var(--nord2);
|
||||
@@ -386,13 +426,6 @@
|
||||
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 {
|
||||
background: var(--nord1);
|
||||
border-color: var(--nord2);
|
||||
@@ -426,13 +459,4 @@
|
||||
border-color: var(--nord11);
|
||||
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>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import path from 'path';
|
||||
import { writeFile } from 'fs/promises';
|
||||
import { writeFile, mkdir } from 'fs/promises';
|
||||
import sharp from 'sharp';
|
||||
import { generateImageHashFromBuffer, getHashedFilename } from '$utils/imageHash';
|
||||
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
|
||||
// re-compress and discard their quality/size choice.
|
||||
// Fallback (non-webp upload, e.g. editor bypassed): re-encode to WebP q90 as before.
|
||||
const fullHashedPath = path.join(imageDir, 'rezepte', 'full', hashedFilename);
|
||||
const fullUnhashedPath = path.join(imageDir, 'rezepte', 'full', unhashedFilename);
|
||||
const fullDir = path.join(imageDir, 'rezepte', 'full');
|
||||
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;
|
||||
if (file.type === 'image/webp') {
|
||||
@@ -165,8 +172,8 @@ export async function processAndSaveRecipeImage(
|
||||
.toBuffer();
|
||||
console.log('[ImageProcessing] Thumbnail buffer created, size:', thumbBuffer.length, 'bytes');
|
||||
|
||||
const thumbHashedPath = path.join(imageDir, 'rezepte', 'thumb', hashedFilename);
|
||||
const thumbUnhashedPath = path.join(imageDir, 'rezepte', 'thumb', unhashedFilename);
|
||||
const thumbHashedPath = path.join(thumbDir, hashedFilename);
|
||||
const thumbUnhashedPath = path.join(thumbDir, unhashedFilename);
|
||||
console.log('[ImageProcessing] Saving thumbnail to:', { thumbHashedPath, thumbUnhashedPath });
|
||||
|
||||
await sharp(thumbBuffer).toFile(thumbHashedPath);
|
||||
|
||||
@@ -72,6 +72,20 @@ export interface RecipeFormData {
|
||||
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
|
||||
* Handles both simple fields and complex JSON-encoded nested structures
|
||||
|
||||
-416458
File diff suppressed because one or more lines are too long
+6
-1
@@ -19,7 +19,12 @@ const config = {
|
||||
// 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.
|
||||
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: {
|
||||
// The only intentionally-static pages are /hikes (prerender=true) and
|
||||
|
||||
Reference in New Issue
Block a user