Files
homepage/src/lib/components/tasks/VinylSticker.svelte
T
Alexander 467f9a4e71
CI / update (push) Has been cancelled
feat(tasks): vinyl sticker album + fridge-calendar rewards redesign
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.
2026-06-01 23:42:47 +02:00

209 lines
6.4 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>