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.
This commit is contained in:
2026-06-01 23:42:47 +02:00
parent 8bd794bccb
commit 467f9a4e71
6 changed files with 805 additions and 301 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "homepage",
"version": "1.95.4",
"version": "1.96.0",
"private": true,
"type": "module",
"scripts": {
+202 -123
View File
@@ -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>
+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 },
};
// 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;
+201 -177
View File
@@ -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>