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:
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user