feat(figgos): port collection, showcase + card detail to SvelteKit web

- Add card images to static/images/ and shared card data module
- Collection grid with 2-column layout linking to card detail
- Showcase carousel with scroll-snap, drag-to-scroll, scale/rotation effects
- Card detail with CSS 3D flip (drag to rotate, double-click to flip)
- Back side shows backstory, stats bars, rarity badge (sized to match front)
- Layout with 3 tabs (Create, Collection, Showcase), hidden on card detail
- Scale up Create + Collection screens for better visual presence

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Chr1st1anG 2026-02-11 17:12:28 +01:00
parent 9462dfac43
commit 49c6ecc377
11 changed files with 549 additions and 83 deletions

View file

@ -0,0 +1,64 @@
import type { FigureRarity } from '@figgos/shared';
export interface CardData {
id: string;
name: string;
subtitle: string;
description: string;
rarity: FigureRarity;
image: string;
stats: { attack: number; defense: number; special: number };
}
export const CARDS: CardData[] = [
{
id: 'cole-epic',
name: 'Detective Cole',
subtitle: 'Noir City Homicide Division',
description:
'A hardboiled detective who has seen it all. Armed with nothing but a trench coat, a sharp mind, and an unhealthy coffee addiction. Solves impossible cases in the rain-soaked streets of Noir City.',
rarity: 'epic',
image: '/images/cole-epic.png',
stats: { attack: 42, defense: 68, special: 75 },
},
{
id: 'cole-rare',
name: 'Detective Cole',
subtitle: 'Noir City Homicide Division',
description:
'Fresh off his first big case, Cole is making a name for himself in the precinct. His instincts are sharp, but he still has a lot to learn about the darker side of Noir City.',
rarity: 'rare',
image: '/images/cole-rare.png',
stats: { attack: 35, defense: 52, special: 60 },
},
{
id: 'cole-legendary',
name: 'Detective Cole',
subtitle: 'Noir City Homicide Division',
description:
'The legend of Noir City. After decades on the force, Cole has become the detective other detectives tell stories about. His case closure rate is unmatched in the history of the division.',
rarity: 'legendary',
image: '/images/cole-legendary.png',
stats: { attack: 78, defense: 85, special: 95 },
},
{
id: 'cole-common',
name: 'Detective Cole',
subtitle: 'Noir City Homicide Division',
description:
'A standard-issue detective doing his best in a tough city. Nothing fancy, but reliable. Shows up every day, drinks too much coffee, and gets the job done.',
rarity: 'common',
image: '/images/cole-common.png',
stats: { attack: 22, defense: 30, special: 28 },
},
{
id: 'cole-kraft',
name: 'Detective Cole',
subtitle: 'Kraft Edition',
description:
"Limited kraft paper edition. A collector's item with a vintage feel. The same old Cole, but with that handmade, artisanal charm that cardboard enthusiasts crave.",
rarity: 'common',
image: '/images/cole-kraft.png',
stats: { attack: 25, defense: 32, special: 30 },
},
];

View file

@ -4,8 +4,7 @@
let { children } = $props();
let isCreate = $derived(page.url.pathname === '/');
let isCollection = $derived(page.url.pathname === '/collection');
let path = $derived(page.url.pathname);
</script>
<svelte:head>
@ -13,40 +12,53 @@
</svelte:head>
<div class="min-h-dvh bg-background text-foreground">
<!-- Tab Navigation -->
<nav class="fixed bottom-0 left-0 right-0 z-50 border-t-3 border-border bg-surface">
<div class="mx-auto flex max-w-md items-center justify-around py-2">
<a
href="/"
class="flex flex-col items-center gap-1 px-6 py-2 transition-opacity hover:opacity-80 {isCreate
? 'text-primary'
: 'text-muted-foreground hover:text-primary'}"
>
<svg class="h-6 w-6" fill="currentColor" viewBox="0 0 24 24">
<path
d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm5 11h-4v4h-2v-4H7v-2h4V7h2v4h4v2z"
/>
</svg>
<span class="text-xs font-black uppercase tracking-wider">Create</span>
</a>
<a
href="/collection"
class="flex flex-col items-center gap-1 px-6 py-2 transition-opacity hover:opacity-80 {isCollection
? 'text-primary'
: 'text-muted-foreground hover:text-primary'}"
>
<svg class="h-6 w-6" fill="currentColor" viewBox="0 0 24 24">
<path
d="M4 8h4V4H4v4zm6 12h4v-4h-4v4zm-6 0h4v-4H4v4zm0-6h4v-4H4v4zm6 0h4v-4h-4v4zm6-10v4h4V4h-4zm-6 4h4V4h-4v4zm6 6h4v-4h-4v4zm0 6h4v-4h-4v4z"
/>
</svg>
<span class="text-xs font-black uppercase tracking-wider">Collection</span>
</a>
</div>
</nav>
<!-- Tab Navigation (hidden on card detail) -->
{#if !path.startsWith('/card/')}
<nav class="fixed bottom-0 left-0 right-0 z-50 border-t-3 border-border bg-surface">
<div class="mx-auto flex max-w-lg items-center justify-around py-2">
<a
href="/"
class="flex flex-col items-center gap-0.5 px-3 py-2 transition-opacity hover:opacity-80 {path === '/'
? 'text-primary'
: 'text-muted-foreground hover:text-primary'}"
>
<svg class="h-5 w-5" fill="currentColor" viewBox="0 0 24 24">
<path
d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm5 11h-4v4h-2v-4H7v-2h4V7h2v4h4v2z"
/>
</svg>
<span class="text-[10px] font-black uppercase tracking-wider">Create</span>
</a>
<a
href="/collection"
class="flex flex-col items-center gap-0.5 px-3 py-2 transition-opacity hover:opacity-80 {path === '/collection'
? 'text-primary'
: 'text-muted-foreground hover:text-primary'}"
>
<svg class="h-5 w-5" fill="currentColor" viewBox="0 0 24 24">
<path
d="M4 8h4V4H4v4zm6 12h4v-4h-4v4zm-6 0h4v-4H4v4zm0-6h4v-4H4v4zm6 0h4v-4h-4v4zm6-10v4h4V4h-4zm-6 4h4V4h-4v4zm6 6h4v-4h-4v4zm0 6h4v-4h-4v4z"
/>
</svg>
<span class="text-[10px] font-black uppercase tracking-wider">Collection</span>
</a>
<a
href="/showcase"
class="flex flex-col items-center gap-0.5 px-3 py-2 transition-opacity hover:opacity-80 {path === '/showcase'
? 'text-primary'
: 'text-muted-foreground hover:text-primary'}"
>
<svg class="h-5 w-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M2 6h4v11H2zm5-1h4v13H7zm5-1h4v15h-4zm5-1h4v17h-4z" />
</svg>
<span class="text-[10px] font-black uppercase tracking-wider">Showcase</span>
</a>
</div>
</nav>
{/if}
<!-- Page Content -->
<main class="pb-24">
<main class={path.startsWith('/card/') ? '' : 'pb-24'}>
{@render children()}
</main>
</div>

View file

@ -53,11 +53,11 @@
{#if result}
<!-- ── Result Screen ── -->
<div class="mx-auto max-w-md px-6 pt-8">
<div class="mx-auto max-w-xl px-6 pt-10">
<!-- Badge -->
<div class="mb-5 flex justify-center">
<div class="mb-6 flex justify-center">
<span
class="inline-block rounded bg-secondary px-3.5 py-1 text-[11px] font-black uppercase tracking-[3px] text-secondary-foreground"
class="inline-block rounded bg-secondary px-4 py-1.5 text-sm font-black uppercase tracking-[3px] text-secondary-foreground"
style="transform: rotate(-2deg)"
>
Unboxing
@ -65,33 +65,33 @@
</div>
<!-- Figure Card -->
<div class="brutal-shadow rounded-lg">
<div class="brutal-shadow rounded-xl">
<div
class="rounded-lg border-3 border-border bg-surface p-6"
class="rounded-xl border-3 border-border bg-surface p-8"
>
<!-- Image placeholder -->
<div
class="mx-auto mb-5 flex h-[200px] w-[200px] items-center justify-center rounded-lg border-2 border-border-muted bg-input"
class="mx-auto mb-6 flex h-[260px] w-[260px] items-center justify-center rounded-xl border-2 border-border-muted bg-input"
>
<span class="text-xs text-muted-foreground">Image coming soon</span>
<span class="text-base text-muted-foreground">Image coming soon</span>
</div>
<h2 class="text-center text-[22px] font-black tracking-tight text-foreground">
<h2 class="text-center text-3xl font-black tracking-tight text-foreground">
{result.name}
</h2>
<p class="mt-3 text-center text-sm leading-5 text-muted-foreground">
<p class="mt-4 text-center text-lg leading-6 text-muted-foreground">
{result.userInput.description}
</p>
<!-- Rarity Badge -->
<div class="mt-4 flex justify-center">
<div class="mt-6 flex justify-center">
<div class="relative">
<div
class="absolute rounded-full"
style="top: 3px; left: 2px; right: -2px; bottom: -3px; background-color: {RARITY_SHADOW[result.rarity]}"
></div>
<span
class="relative inline-block rounded-full border-2 border-white/20 px-5 py-2 text-xs font-black uppercase tracking-[2px]"
class="relative inline-block rounded-full border-2 border-white/20 px-6 py-2.5 text-sm font-black uppercase tracking-[2px]"
style="background-color: var(--color-rarity-{result.rarity}); color: var(--color-rarity-{result.rarity}-foreground)"
>
{result.rarity}
@ -102,15 +102,15 @@
</div>
<!-- Create Another -->
<div class="mt-8">
<div class="mt-10">
<button onclick={handleReset} class="group w-full cursor-pointer">
<div class="relative">
<div
class="absolute rounded-lg bg-accent-dark"
class="absolute rounded-xl bg-accent-dark"
style="top: 5px; left: 3px; right: -3px; bottom: -5px"
></div>
<div
class="relative rounded-lg border-2 border-white/15 bg-accent py-4 text-center text-base font-black uppercase tracking-wider text-accent-foreground transition-opacity group-hover:opacity-90"
class="relative rounded-xl border-2 border-white/15 bg-accent py-5 text-center text-lg font-black uppercase tracking-wider text-accent-foreground transition-opacity group-hover:opacity-90"
>
Create Another
</div>
@ -120,16 +120,16 @@
</div>
{:else}
<!-- ── Create Form ── -->
<div class="mx-auto max-w-md px-6">
<div class="mx-auto max-w-xl px-6">
<!-- Header -->
<div class="flex flex-col items-center pb-8 pt-10">
<div class="flex flex-col items-center pb-10 pt-12">
<span
class="mb-2 inline-block rounded bg-secondary px-3.5 py-1 text-[11px] font-black uppercase tracking-[3px] text-secondary-foreground"
class="mb-3 inline-block rounded bg-secondary px-4 py-1.5 text-sm font-black uppercase tracking-[3px] text-secondary-foreground"
style="transform: rotate(-2deg)"
>
New Drop
</span>
<h1 class="text-center text-[32px] font-black leading-tight tracking-tight text-foreground">
<h1 class="text-center text-5xl font-black leading-tight tracking-tight text-foreground">
CREATE YOUR<br />FIGGO
</h1>
</div>
@ -138,46 +138,46 @@
<div>
<!-- Name -->
<label
class="mb-2 block text-[13px] font-black uppercase tracking-[3px] text-primary"
class="mb-2.5 block text-base font-black uppercase tracking-[3px] text-primary"
for="name"
>
Name
</label>
<div class="brutal-shadow mb-6 rounded-lg">
<div class="brutal-shadow mb-8 rounded-xl">
<input
id="name"
type="text"
bind:value={name}
maxlength={200}
placeholder="Captain Thunderstrike"
class="w-full rounded-lg border-3 border-border bg-input px-4 text-base text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
style="height: 52px"
class="w-full rounded-xl border-3 border-border bg-input px-5 text-lg text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
style="height: 60px"
/>
</div>
<!-- Story -->
<label
class="mb-2 block text-[13px] font-black uppercase tracking-[3px] text-primary"
class="mb-2.5 block text-base font-black uppercase tracking-[3px] text-primary"
for="story"
>
Story
</label>
<div class="brutal-shadow mb-6 rounded-lg">
<div class="brutal-shadow mb-8 rounded-xl">
<textarea
id="story"
bind:value={description}
maxlength={2000}
rows={4}
placeholder="A cyberpunk warrior with lightning gauntlets..."
class="w-full rounded-lg border-3 border-border bg-input px-4 py-3.5 text-base text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
style="min-height: 120px; resize: vertical"
class="w-full rounded-xl border-3 border-border bg-input px-5 py-4 text-lg text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
style="min-height: 150px; resize: vertical"
></textarea>
</div>
<!-- Error -->
{#if error}
<div class="mb-4 rounded-lg border-2 border-destructive/30 bg-destructive/10 p-3">
<p class="text-center text-sm font-semibold text-destructive">{error}</p>
<div class="mb-5 rounded-xl border-2 border-destructive/30 bg-destructive/10 p-4">
<p class="text-center text-base font-semibold text-destructive">{error}</p>
</div>
{/if}
@ -189,15 +189,15 @@
>
<div class="relative">
<div
class="absolute rounded-lg bg-primary-dark"
class="absolute rounded-xl bg-primary-dark"
style="top: 6px; left: 4px; right: -4px; bottom: -6px"
></div>
<div
class="relative rounded-lg border-3 border-[rgb(255,224,102)] bg-primary py-[18px] text-center text-lg font-black uppercase tracking-[2px] text-primary-foreground transition-opacity group-hover:opacity-90"
class="relative rounded-xl border-3 border-[rgb(255,224,102)] bg-primary py-5 text-center text-xl font-black uppercase tracking-[2px] text-primary-foreground transition-opacity group-hover:opacity-90"
>
{#if loading}
<span class="inline-flex items-center gap-2">
<svg class="h-5 w-5 animate-spin" viewBox="0 0 24 24" fill="none">
<svg class="h-6 w-6 animate-spin" viewBox="0 0 24 24" fill="none">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>

View file

@ -0,0 +1,239 @@
<script lang="ts">
import { page } from '$app/state';
import { CARDS } from '$lib/data/cards';
import type { FigureRarity } from '@figgos/shared';
const RARITY_COLORS: Record<FigureRarity, string> = {
common: 'rgb(136, 136, 170)',
rare: 'rgb(100, 180, 255)',
epic: 'rgb(180, 130, 255)',
legendary: 'rgb(255, 185, 30)',
};
const STAT_COLORS = {
attack: 'rgb(255, 51, 102)',
defense: 'rgb(0, 210, 170)',
special: 'rgb(180, 130, 255)',
};
let card = $derived(CARDS.find((c) => c.id === page.params.id));
// Measure actual image size to match front/back
let cardWidth = $state(0);
let cardHeight = $state(0);
let loaded = $state(false);
function handleImageLoad(e: Event) {
const img = e.target as HTMLImageElement;
const natW = img.naturalWidth;
const natH = img.naturalHeight;
// Available space
const maxW = window.innerWidth * 0.85;
const maxH = window.innerHeight * 0.78;
// Fit image into available space (contain)
const ratio = Math.min(maxW / natW, maxH / natH);
cardWidth = Math.round(natW * ratio);
cardHeight = Math.round(natH * ratio);
loaded = true;
}
// 3D rotation state
let rotateY = $state(0);
let savedRotateY = $state(0);
let isDragging = $state(false);
let startX = $state(0);
function handlePointerDown(e: PointerEvent) {
isDragging = true;
startX = e.clientX;
(e.target as HTMLElement).setPointerCapture(e.pointerId);
}
function handlePointerMove(e: PointerEvent) {
if (!isDragging) return;
rotateY = savedRotateY + (e.clientX - startX) * 0.5;
}
function handlePointerUp() {
if (!isDragging) return;
isDragging = false;
// Snap to 0 or 180
const normalised = ((rotateY % 360) + 360) % 360;
let target: number;
if (normalised < 90) {
target = 0;
} else if (normalised < 270) {
target = 180;
} else {
target = 360;
}
const diff = target - normalised;
const snapTo = rotateY + diff;
savedRotateY = snapTo % 360;
rotateY = snapTo;
}
function handleDoubleClick() {
const target = savedRotateY + 180;
savedRotateY = target % 360;
rotateY = target;
}
</script>
{#if card}
<!-- Header -->
<div class="mx-auto flex max-w-lg items-center justify-between px-5 py-3">
<a
href="/collection"
class="text-base font-bold text-primary transition-opacity hover:opacity-70"
>
&larr; Back
</a>
<span class="text-xs font-semibold tracking-wider text-muted-foreground">
Drag to rotate &middot; Double-click to flip
</span>
</div>
<!-- Hidden image to measure natural dimensions -->
{#if !loaded}
<img
src={card.image}
alt=""
class="invisible absolute"
onload={handleImageLoad}
/>
{/if}
<!-- Card -->
<div class="flex flex-col items-center justify-center" style="height: calc(100dvh - 52px)">
{#if loaded}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="select-none"
style="perspective: 1200px; width: {cardWidth}px; height: {cardHeight}px"
onpointerdown={handlePointerDown}
onpointermove={handlePointerMove}
onpointerup={handlePointerUp}
onpointercancel={handlePointerUp}
ondblclick={handleDoubleClick}
>
<div
class="relative h-full w-full cursor-grab active:cursor-grabbing"
style="
transform-style: preserve-3d;
transform: rotateY({rotateY}deg);
transition: {isDragging ? 'none' : 'transform 0.4s cubic-bezier(0.22, 1, 0.36, 1)'};
"
>
<!-- Front: only the image -->
<div
class="absolute inset-0 overflow-hidden rounded-xl"
style="backface-visibility: hidden"
>
<img
src={card.image}
alt={card.name}
class="h-full w-full object-cover"
draggable="false"
/>
</div>
<!-- Back -->
<div
class="absolute inset-0 flex flex-col justify-between overflow-hidden rounded-xl border-3 bg-surface p-7"
style="
backface-visibility: hidden;
transform: rotateY(180deg);
border-color: {RARITY_COLORS[card.rarity]};
"
>
<!-- Shadow layer -->
<div
class="pointer-events-none absolute -bottom-[5px] -right-[5px] left-[5px] top-[5px] rounded-xl"
style="background-color: {RARITY_COLORS[card.rarity]}; opacity: 0.15; z-index: -1"
></div>
<!-- Header -->
<div>
<span
class="mb-3 inline-block rounded bg-secondary px-3.5 py-1 text-sm font-black uppercase tracking-[2px] text-secondary-foreground"
style="transform: rotate(-2deg)"
>
Backstory
</span>
<h2 class="text-4xl font-black tracking-tight text-foreground">
{card.name}
</h2>
<p
class="mt-1.5 text-base font-extrabold uppercase tracking-[2px]"
style="color: {RARITY_COLORS[card.rarity]}"
>
{card.subtitle}
</p>
</div>
<!-- Description -->
<p class="text-lg leading-7 text-muted-foreground">
{card.description}
</p>
<!-- Stats -->
<div>
<p
class="mb-3 text-base font-black uppercase tracking-[3px]"
style="color: {RARITY_COLORS[card.rarity]}"
>
Stats
</p>
{#each [
{ label: 'ATK', value: card.stats.attack, color: STAT_COLORS.attack },
{ label: 'DEF', value: card.stats.defense, color: STAT_COLORS.defense },
{ label: 'SPL', value: card.stats.special, color: STAT_COLORS.special },
] as stat (stat.label)}
<div class="mb-3 flex items-center gap-3">
<span class="w-12 text-base font-black tracking-wider text-muted-foreground">
{stat.label}
</span>
<div
class="h-3.5 flex-1 overflow-hidden rounded-full border border-border-muted bg-input"
>
<div
class="h-full rounded-full"
style="width: {stat.value}%; background-color: {stat.color}"
></div>
</div>
<span class="w-9 text-right text-base font-extrabold text-foreground">
{stat.value}
</span>
</div>
{/each}
</div>
<!-- Bottom: rarity + ID -->
<div class="flex items-center justify-between">
<span
class="rounded-full px-5 py-2 text-sm font-black uppercase tracking-[2px]"
style="
background-color: {RARITY_COLORS[card.rarity]};
color: rgb(15, 15, 30);
"
>
{card.rarity}
</span>
<span class="text-sm font-semibold tracking-wider text-muted-foreground">
#{card.id.split('-').pop()?.toUpperCase()}
</span>
</div>
</div>
</div>
</div>
{/if}
</div>
{:else}
<div class="flex min-h-[60vh] items-center justify-center">
<p class="text-lg font-bold text-foreground">Card not found</p>
</div>
{/if}

View file

@ -1,23 +1,24 @@
<script lang="ts">
import { CARDS } from '$lib/data/cards';
</script>
<div class="flex min-h-[60vh] items-center justify-center px-8">
<!-- Empty state card -->
<div class="brutal-shadow rounded-lg">
<div
class="flex flex-col items-center rounded-lg border-3 border-border bg-surface px-8 py-8"
>
<div
class="mb-4 flex h-14 w-14 items-center justify-center rounded-lg border-2 border-border-muted bg-input"
>
<span class="text-2xl">📦</span>
</div>
<h2 class="text-lg font-black tracking-tight text-foreground">
No figures yet
</h2>
<p class="mt-2 text-center text-sm leading-5 text-muted-foreground">
Create your first Figgo<br />to start your collection.
</p>
</div>
<div class="mx-auto max-w-2xl px-5 pt-8">
<h1 class="text-4xl font-black tracking-tight text-foreground">Collection</h1>
<p class="mt-1 text-base text-muted-foreground">
{CARDS.length} {CARDS.length === 1 ? 'Figgo' : 'Figgos'}
</p>
<div class="mt-6 grid grid-cols-2 gap-4">
{#each CARDS as card (card.id)}
<a href="/card/{card.id}" class="block transition-opacity hover:opacity-80 active:opacity-70">
<div class="aspect-[1/1.45] overflow-hidden rounded-xl">
<img
src={card.image}
alt={card.name}
class="h-full w-full object-cover"
/>
</div>
</a>
{/each}
</div>
</div>

View file

@ -0,0 +1,150 @@
<script lang="ts">
import { CARDS } from '$lib/data/cards';
let activeIndex = $state(0);
let scrollContainer: HTMLDivElement;
function handleScroll() {
if (!scrollContainer) return;
const children = Array.from(scrollContainer.querySelectorAll('[data-card]'));
const scrollCenter = scrollContainer.scrollLeft + scrollContainer.clientWidth / 2;
let closest = 0;
let closestDist = Infinity;
for (let i = 0; i < children.length; i++) {
const child = children[i] as HTMLElement;
const childCenter = child.offsetLeft + child.clientWidth / 2;
const dist = Math.abs(scrollCenter - childCenter);
if (dist < closestDist) {
closestDist = dist;
closest = i;
}
}
activeIndex = closest;
}
// Pointer drag-to-scroll (works for mouse + touch)
let isDragging = $state(false);
let dragStartX = $state(0);
let scrollStartLeft = $state(0);
let hasDragged = $state(false);
function handlePointerDown(e: PointerEvent) {
// Only for mouse (touch already scrolls natively)
if (e.pointerType !== 'mouse') return;
e.preventDefault();
isDragging = true;
hasDragged = false;
dragStartX = e.clientX;
scrollStartLeft = scrollContainer.scrollLeft;
scrollContainer.style.scrollSnapType = 'none';
scrollContainer.setPointerCapture(e.pointerId);
}
function handlePointerMove(e: PointerEvent) {
if (!isDragging) return;
const dx = e.clientX - dragStartX;
if (Math.abs(dx) > 5) hasDragged = true;
scrollContainer.scrollLeft = scrollStartLeft - dx;
}
function handlePointerUp(e: PointerEvent) {
if (!isDragging) return;
isDragging = false;
scrollContainer.releasePointerCapture(e.pointerId);
scrollContainer.style.scrollSnapType = '';
// Re-snap to nearest card
const children = Array.from(scrollContainer.querySelectorAll('[data-card]'));
const scrollCenter = scrollContainer.scrollLeft + scrollContainer.clientWidth / 2;
let closest: HTMLElement | null = null;
let closestDist = Infinity;
for (const child of children) {
const el = child as HTMLElement;
const childCenter = el.offsetLeft + el.clientWidth / 2;
const dist = Math.abs(scrollCenter - childCenter);
if (dist < closestDist) {
closestDist = dist;
closest = el;
}
}
if (closest) {
const targetScroll = closest.offsetLeft - (scrollContainer.clientWidth - closest.clientWidth) / 2;
scrollContainer.scrollTo({ left: targetScroll, behavior: 'smooth' });
}
}
function handleClick(e: MouseEvent) {
if (hasDragged) {
e.preventDefault();
}
}
</script>
<style>
.showcase-scroll {
display: flex;
overflow-x: auto;
scroll-snap-type: x mandatory;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
align-items: center;
gap: 0;
cursor: grab;
user-select: none;
}
.showcase-scroll::-webkit-scrollbar {
display: none;
}
/* Side padding so first/last card can center */
.showcase-scroll::before,
.showcase-scroll::after {
content: '';
flex-shrink: 0;
width: 20%;
}
.showcase-card {
flex-shrink: 0;
width: 60%;
scroll-snap-align: center;
display: flex;
align-items: center;
justify-content: center;
touch-action: pan-x;
}
</style>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="showcase-scroll h-[calc(100dvh-72px)] w-full"
class:cursor-grabbing={isDragging}
bind:this={scrollContainer}
onscroll={handleScroll}
onpointerdown={handlePointerDown}
onpointermove={handlePointerMove}
onpointerup={handlePointerUp}
onpointercancel={handlePointerUp}
>
{#each CARDS as card, i (card.id)}
<a
href="/card/{card.id}"
class="showcase-card h-full transition-all duration-300"
data-card
onclick={handleClick}
style="
transform: scale({i === activeIndex ? 1 : 0.8}) rotate({i === activeIndex ? 0 : i < activeIndex ? -3 : 3}deg);
opacity: {i === activeIndex ? 1 : 0.45};
"
>
<img
src={card.image}
alt={card.name}
class="max-h-[85%] w-auto max-w-full object-contain"
draggable="false"
/>
</a>
{/each}
</div>

Binary file not shown.

After

Width:  |  Height:  |  Size: 912 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 943 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB