feat(tasks): vinyl sticker album + fridge-calendar rewards redesign
CI / update (push) Has been cancelled
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:
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "homepage",
|
||||
"version": "1.95.4",
|
||||
"version": "1.96.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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} />
|
||||
{#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>
|
||||
{#if openInfo === page.cat}
|
||||
<p class="earn-info">
|
||||
{#if page.always}
|
||||
Diese Kätzchen können bei <strong>jeder erledigten Aufgabe</strong> auftauchen.
|
||||
{: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]}
|
||||
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 owned}
|
||||
<span class="sticker-desc">{sticker.description}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
Reference in New Issue
Block a user