467f9a4e71
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.
209 lines
6.4 KiB
Svelte
209 lines
6.4 KiB
Svelte
<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>
|