This commit is contained in:
2026-04-01 18:31:33 +02:00
parent 6e23e32bb0
commit b5c7c5305a
95 changed files with 9609 additions and 2374 deletions
+119
View File
@@ -0,0 +1,119 @@
# Design Language
## Aesthetic
Dark fantasy TCG aesthetic. Warm golds and bronzes on near-black brown backgrounds. Cinzel for headings/labels/buttons, Crimson Text for body/prose/inputs.
## Colors
All core colors are available as CSS custom properties on `:root` in `app.css` (e.g. `var(--color-bg)`, `var(--color-bronze)`).
| Role | Value |
|------|-------|
| Page background | `#0d0a04` |
| Header background | `#1a1008` |
| Modal / dark surface | `#110d04` |
| Raised surface | `#3d2507` |
| Primary text / accent | `#f0d080` (gold) |
| Secondary text | `rgba(240, 180, 80, 0.6)` |
| Placeholder text | `rgba(240, 180, 80, 0.30.4)` |
| Interactive accent (buttons, borders, hover) | `#c8861a` (bronze-orange) |
| Interactive accent hover | `#e09820` |
| Border / divider | `#6b4c1e` |
| Subtle border | `rgba(107, 76, 30, 0.30.5)` |
| Button text | `#fff8e0` |
| Error / delete | `#c84040` |
| Success / positive | `#6aaa6a` |
| Energy cost indicator | `#6ea0ec` |
## Typography
- **Headings / labels / buttons**: Cinzel (Google Fonts), weights 400/700/900
- **Body / prose / inputs**: Crimson Text (Google Fonts), weights 400/600; italic for flavor/secondary text
- Button and label text: **uppercase**, `letter-spacing: 0.060.1em`
- Form labels: uppercase, `letter-spacing: 0.08em`
### Type Scale
All type scale tokens are CSS custom properties on `:root` in `app.css`.
| Token | Value | Use for |
|-------|-------|---------|
| `--text-xs` | `9px` | Fine print, badges, metadata labels |
| `--text-sm` | `11px` | Secondary text, captions, small labels |
| `--text-base` | `13px` | Default body text, buttons, card details |
| `--text-md` | `15px` | Form inputs, emphasized body text |
| `--text-lg` | `18px` | Section headings, card titles |
| `--text-xl` | `22px` | Page headings, prominent labels |
| `--text-2xl` | `28px` | Large display headings |
| `--text-3xl` | `36px` | Hero headings, splash text |
## Buttons
| Variant | Background | Border | Text |
|---------|-----------|--------|------|
| Primary | `#c8861a` | none | `#fff8e0` |
| Secondary | `#3d2507` | `1px solid rgba(107,76,30,0.4)` | `#f0d080` |
| Destructive | `rgba(180,40,40,0.8)` | none | `#fff` |
### Sizes
All button size tokens are CSS custom properties on `:root` in `app.css`.
| Size | Padding | Font-size | Border-radius | Use for |
|------|---------|-----------|---------------|---------|
| Small | `4px 10px` | `10px` | `var(--radius-sm)` | Toolbar filters, sort toggles, inline actions, edit/delete |
| Medium | `8px 18px` | `12px` | `var(--radius-md)` | Card actions, done/choose, friend, secondary, cancel |
| Large | `10px 32px` | `13px` | `var(--radius-md)` | Primary CTAs, auth submit, play, buy, accept trade |
- Font: Cinzel 700 uppercase, letter-spacing 0.060.1em
- Disabled: opacity 0.5; hover: lighten background or brighten border + text
## Inputs / Forms
- Background: `#1a1008`; border-radius: 6px; color: `#f0d080`; font: Crimson Text 15px
- Text inputs: `1.5px solid #6b4c1e`; focus border: `#c8861a`
- Selects: `1.5px solid #6b4c1e`
- Placeholder: `rgba(240, 180, 80, 0.4)`; accent-color (checkboxes, ranges): `#c8861a`
## Containers / Panels
Border-radius, shadows, z-index layers, and spacing are available as CSS custom properties on `:root` in `app.css` (e.g. `var(--radius-lg)`, `var(--shadow-card)`, `var(--z-modal)`, `var(--space-md)`).
- Border-radius: 1012px; box-shadow: `0 4px 24px rgba(0,0,0,0.5)`
- Borders: `12px solid #6b4c1e` standard, `#c8861a` for emphasis
- Backgrounds: `#1a1008` (surface), `#3d2507` (raised)
- Card hover lift: `translateY(-4px) scale(1.02)`, shadow `0 12px 40px rgba(0,0,0,0.6)`
## Transitions
- Default: `0.15s ease` on background, border-color, color, transform
- Card hover: `0.2s ease`
## Card Type Colors (CSS vars `--bg` / `--header`)
| Type | Background | Header |
|------|-----------|--------|
| person | `#f0e0c8` | `#b87830` |
| location | `#d8e8d4` | `#4a7a50` |
| artwork | `#e4d4e8` | `#7a5090` |
| life_form | `#ccdce8` | `#3a6878` |
| event | `#e8d4d4` | `#8b2020` |
| group | `#e8e4d0` | `#748c12` |
| science_thing | `#c7c5c1` | `#060c17` |
| vehicle | `#c7c1c4` | `#801953` |
| organization | `#b7c1c4` | `#3c5251` |
## Rarity Badge Colors
| Rarity | Background | Text |
|--------|-----------|------|
| common | `#c8c8c8` | `#333` |
| uncommon | `#4a7a50` | `#fff` |
| rare | `#2a5a9b` | `#fff` |
| super_rare | `#7a3a9b` | `#fff` |
| epic | `#9b3a3a` | `#fff` |
| legendary | `#b87820` | `#fff` |
## Card Component
- Outer border and internal borders: `#000` (pure black) — these are structural borders within the card face, distinct from the themed `#6b4c1e` borders on containers/panels.
- Card background: `#111`; border-radius: 12px; padding: 7px
## Spacing
- Page padding: `2rem`; section gap: `11.5rem`; component internal gap: `0.40.75rem`
+102 -1
View File
@@ -1,14 +1,115 @@
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;700;900&family=Crimson+Text:ital,wght@0,400;0,600;1,400&display=swap');
:root {
/* Colors */
--color-bg: #0d0a04;
--color-surface: #1a1008;
--color-surface-raised: #3d2507;
--color-gold: #f0d080;
--color-gold-muted: rgba(240, 180, 80, 0.8);
--color-gold-dim: rgba(240, 180, 80, 0.6);
--color-gold-faint: rgba(240, 180, 80, 0.4);
--color-bronze: #c8861a;
--color-bronze-hover: #e09820;
--color-border: #6b4c1e;
--color-border-subtle: rgba(107, 76, 30, 0.4);
--color-border-dim: rgba(107, 76, 30, 0.3);
--color-overlay: rgba(0, 0, 0, 0.5);
--color-btn-text: #fff8e0;
--color-error: #c84040;
--color-success: #6aaa6a;
--color-energy: #6ea0ec;
--color-shard: #b87820;
--color-cyan: #7ecfcf; /* shard quantity / cyan accent */
/* Border-radius */
--radius-sm: 4px;
--radius-md: 6px;
--radius-lg: 10px;
--radius-xl: 12px;
--radius-full: 50%;
/* Shadows */
--shadow-subtle: 0 2px 8px rgba(0, 0, 0, 0.3);
--shadow-card: 0 4px 24px rgba(0, 0, 0, 0.5);
--shadow-elevated: 0 12px 40px rgba(0, 0, 0, 0.6);
--shadow-glow: 0 0 20px rgba(200, 134, 26, 0.3);
/* Z-index layers */
--z-base: 1;
--z-card: 10;
--z-header: 100;
--z-dropdown: 200;
--z-modal: 300;
--z-toast: 400;
/* Type scale */
--text-xs: 9px;
--text-sm: 11px;
--text-base: 13px;
--text-md: 15px;
--text-lg: 18px;
--text-xl: 22px;
--text-2xl: 28px;
--text-3xl: 36px;
/* Button sizes */
--btn-padding-sm: 4px 10px;
--btn-font-sm: 10px;
--btn-padding-md: 8px 18px;
--btn-font-md: 12px;
--btn-padding-lg: 10px 32px;
--btn-font-lg: 13px;
/* Spacing */
--space-xs: 0.25rem;
--space-sm: 0.5rem;
--space-md: 1rem;
--space-lg: 1.5rem;
--space-xl: 2rem;
}
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
* {
scrollbar-width: thin;
scrollbar-color: var(--color-border) transparent;
}
body {
background: #0d0a04;
background: var(--color-bg);
}
html, body {
height: 100%;
overflow: hidden;
}
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--color-border);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--color-bronze);
}
@keyframes shard-pulse {
0%, 100% {
filter: brightness(1);
text-shadow: 0 0 6px rgba(126, 207, 207, 0.45);
}
50% {
filter: brightness(1.45);
text-shadow: 0 0 14px rgba(126, 207, 207, 0.9), 0 0 28px rgba(126, 207, 207, 0.35);
}
}
+69 -29
View File
@@ -1,4 +1,4 @@
<script>
<script lang="ts">
let { card, noHover = false, defenseOverride = null } = $props();
const RARITY_BADGE = {
@@ -7,7 +7,7 @@
rare: { symbol: "R", label: "Rare", bg: "#2a5a9b", color: "#fff" },
super_rare: { symbol: "SR", label: "Super Rare", bg: "#7a3a9b", color: "#fff" },
epic: { symbol: "E", label: "Epic", bg: "#9b3a3a", color: "#fff" },
legendary: { symbol: "L", label: "Legendary", bg: "#b87820", color: "#fff" },
legendary: { symbol: "L", label: "Legendary", bg: "#b87820", color: "#fff8e0" },
};
const TYPE_COLORS = {
@@ -26,13 +26,13 @@
const FOIL_RARITIES = new Set(["super_rare", "epic", "legendary"]);
let rarity = $derived(card.card_rarity);
let badge = $derived(RARITY_BADGE[rarity] ?? RARITY_BADGE.common);
let badge = $derived(RARITY_BADGE[rarity as keyof typeof RARITY_BADGE] ?? RARITY_BADGE.common);
let foil = $derived(FOIL_RARITIES.has(rarity))
let foilOffset = $derived(foil ? `${-(Math.random() * 5).toFixed(2)}s` : '0s');
let super_rare = $derived(rarity == "super_rare");
let epic = $derived(rarity == "epic");
let legendary = $derived(rarity === "legendary");
let colors = $derived(TYPE_COLORS[card.card_type] ?? TYPE_COLORS.other);
let colors = $derived(TYPE_COLORS[card.card_type as keyof typeof TYPE_COLORS] ?? TYPE_COLORS.other);
let typeLabel = $derived(card.card_type.charAt(0).toUpperCase() + card.card_type.slice(1).replace("_", " "));
let wikiUrl = $derived("https://en.wikipedia.org/wiki/" + encodeURIComponent(card.name.replace(/ /g, "_")));
</script>
@@ -47,7 +47,7 @@
<div class="card-image-wrap">
{#if card.image_link}
<img src={card.image_link} alt={card.name} class="card-image" draggable="false"/>
<img src={card.image_link} alt={card.name} class="card-image" draggable="false" loading="lazy"/>
{:else}
<div class="card-image-placeholder">
<span>{card.name[0]}</span>
@@ -56,6 +56,17 @@
<div class="rarity-badge" title={badge.label} style="--rb: {badge.bg}; --rc: {badge.color}">{badge.symbol}</div>
{#if card.willing_to_trade || card.is_favorite}
<div class="card-badges">
{#if card.willing_to_trade}
<div class="wtt-badge" title="Willing to trade"></div>
{/if}
{#if card.is_favorite}
<div class="favorite-badge" title="Favorite"></div>
{/if}
</div>
{/if}
<a href={wikiUrl} target="_blank" rel="noopener noreferrer" class="wiki-link" title="Open Wikipedia article">
<svg viewBox="0 0 50 50" xmlns="http://www.w3.org/2000/svg" width="16" height="16">
<circle cx="25" cy="25" r="24" fill="white" stroke="#888" stroke-width="1"/>
@@ -76,7 +87,7 @@
<div class="card-footer">
<span class="stat">ATK <strong>{card.attack}</strong></span>
<span class="card-date">{new Date(card.created_at).toLocaleDateString()}</span>
<span class="card-date">{new Date(card.generated_at ?? card.created_at).toLocaleDateString('en-GB', { year: 'numeric', month: '2-digit', day: '2-digit' })}</span>
<span class="stat">DEF <strong>{defenseOverride !== null ? defenseOverride : card.defense}</strong></span>
</div>
@@ -84,15 +95,13 @@
</div>
<style>
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;700&family=Crimson+Text:ital,wght@0,400;0,600;1,400&display=swap');
.card {
width: 300px;
border-radius: 12px;
border-radius: var(--radius-xl);
padding: 7px;
background: #111;
border: 2px solid #111;
box-shadow: 0 4px 24px rgba(0,0,0,0.5);
border: 2px solid #000;
box-shadow: var(--shadow-card);
font-family: 'Crimson Text', serif;
position: relative;
transition: transform 0.2s ease, box-shadow 0.2s ease;
@@ -103,14 +112,14 @@
.card:hover {
transform: translateY(-4px) scale(1.02);
box-shadow: 0 12px 40px rgba(0,0,0,0.6);
box-shadow: var(--shadow-elevated);
}
.card.foil::before {
content: "";
position: absolute;
inset: 0;
border-radius: 12px;
border-radius: var(--radius-xl);
animation: foil-shift 2.5s ease-in-out infinite alternate;
animation-delay: var(--foil-offset, 0s);
pointer-events: none;
@@ -182,7 +191,7 @@
.card-name {
font-family: 'Cinzel', serif;
font-size: 13px;
font-size: var(--text-base);
font-weight: 700;
color: #fff;
line-height: 1.3;
@@ -196,7 +205,7 @@
.card-type-badge {
font-family: 'Cinzel', serif;
font-size: 9px;
font-size: var(--text-xs);
color: rgba(255,255,255,0.95);
text-transform: uppercase;
letter-spacing: 0.05em;
@@ -232,7 +241,7 @@
justify-content: center;
background: #ddd;
font-family: 'Cinzel', serif;
font-size: 64px;
font-size: var(--text-3xl);
color: rgba(0,0,0,0.15);
}
@@ -242,12 +251,12 @@
left: 7px;
width: 26px;
height: 26px;
border-radius: 50%;
border-radius: var(--radius-full);
background: var(--rb);
border: 2.5px solid #000;
color: var(--rc);
font-family: 'Cinzel', serif;
font-size: 9px;
font-size: var(--text-xs);
font-weight: 700;
display: flex;
align-items: center;
@@ -256,13 +265,44 @@
letter-spacing: -0.02em;
}
.card-badges {
position: absolute;
bottom: 7px;
right: 7px;
display: flex;
gap: 4px;
z-index: 3;
}
.wtt-badge, .favorite-badge {
width: 28px;
height: 28px;
border-radius: var(--radius-full);
border: 2px solid #000;
display: flex;
align-items: center;
justify-content: center;
}
.wtt-badge {
background: rgba(0, 160, 160, 0.85);
color: #fff;
font-size: var(--text-base);
}
.favorite-badge {
background: rgba(255, 200, 0, 0.92);
color: #5a3a00;
font-size: var(--text-md);
}
.wiki-link {
position: absolute;
top: 7px;
right: 7px;
width: 26px;
height: 26px;
border-radius: 50%;
border-radius: var(--radius-full);
background: rgba(255,255,255,0.92);
border: 1.5px solid #000;
display: flex;
@@ -284,7 +324,7 @@
.card-text {
padding: 10px 12px;
font-size: 13px;
font-size: var(--text-base);
line-height: 1.55;
color: #1a1208;
font-style: italic;
@@ -304,21 +344,21 @@
.stat {
font-family: 'Cinzel', serif;
font-size: 11px;
font-size: var(--text-sm);
color: #2a2010;
letter-spacing: 0.03em;
}
.stat strong {
color: #000;
font-size: 15px;
font-size: var(--text-md);
}
.card-date {
font-size: 10px;
color: rgba(0,0,0,0.5);
font-size: var(--text-sm);
color: rgba(0,0,0,0.4);
font-style: italic;
font-family: 'Crimson Text', serif;
font-family: 'Cinzel', serif;
}
.cost-bubbles {
@@ -335,15 +375,15 @@
.cost-bubble {
width: 16px;
height: 16px;
border-radius: 50%;
background: #6ea0ec;
border-radius: var(--radius-full);
background: var(--color-energy);
border: 2.5px solid #000;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
color: #08152c;
font-size: 12px;
font-size: var(--text-sm);
font-weight: 700;
font-family: 'Cinzel', serif;
line-height: 1;
@@ -351,6 +391,6 @@
.card.no-hover:hover {
transform: none;
box-shadow: 0 4px 24px rgba(0,0,0,0.5);
box-shadow: var(--shadow-card);
}
</style>
+308 -108
View File
@@ -1,26 +1,24 @@
<script>
<script lang="ts">
import Card from '$lib/Card.svelte';
import { apiFetch, API_URL } from '$lib/api.js';
import { onMount } from 'svelte';
const RARITIES = ['common', 'uncommon', 'rare', 'super_rare', 'epic', 'legendary'];
const TYPES = ['person', 'location', 'artwork', 'life_form', 'event', 'group', 'science_thing', 'vehicle', 'organization', 'other'];
const RARITY_ORDER = Object.fromEntries(RARITIES.map((r, i) => [r, i]));
let {
allCards = [],
staticCards = null as any[] | null, // if provided, use this list instead of self-fetching (e.g. another user's WTT cards)
selectedIds = $bindable(new Set()),
selectedCards = $bindable([]),
selectedCost = $bindable(0),
costMap = $bindable(new Map()),
inDeckIds = new Set(),
onclose = null,
costLimit = null, // if set, prevents selecting cards that would exceed it
showFooter = true, // set false to hide the Done button (e.g. inline deck builder)
} = $props();
const selectedCost = $derived(
costLimit !== null
? allCards.filter(c => selectedIds.has(c.id)).reduce((sum, c) => sum + c.cost, 0)
: 0
);
function label(str) {
function label(str: string) {
return str.charAt(0).toUpperCase() + str.slice(1).replace(/_/g, ' ');
}
@@ -32,40 +30,159 @@
let costMax = $state(10);
let filtersOpen = $state(false);
let searchQuery = $state('');
let willingToTradeOnly = $state(false);
let filtered = $derived.by(() => {
const RARITY_ORDER = Object.fromEntries(RARITIES.map((r, i) => [r, i]));
// In static mode (staticCards provided), cards are filtered client-side.
// In self-fetch mode, they come from the server with all filtering applied.
const PAGE_SIZE = 40;
let fetchedCards: any[] = $state([]);
let total = $state(0);
let loadingMore = $state(false);
let hasMore = $derived(fetchedCards.length < total);
// Must be $state so the IntersectionObserver $effect re-runs when the element is bound
let sentinel: HTMLElement | undefined = $state();
// .grid has overflow-y: auto — it is the scroll container, not the viewport.
let gridEl: HTMLElement | undefined = $state();
// In static mode, apply client-side filter/sort to staticCards
let cards = $derived.by(() => {
if (staticCards === null) return fetchedCards;
const q = searchQuery.trim().toLowerCase();
let result = allCards.filter(c =>
let result = staticCards.filter((c: any) =>
selectedRarities.has(c.card_rarity) &&
selectedTypes.has(c.card_type) &&
c.cost >= costMin &&
c.cost <= costMax &&
(!q || c.name.toLowerCase().includes(q))
(!q || c.name.toLowerCase().includes(q)) &&
(!willingToTradeOnly || c.willing_to_trade)
);
result = result.slice().sort((a, b) => {
result = result.slice().sort((a: any, b: any) => {
let cmp = 0;
if (sortBy === 'name') cmp = a.name.localeCompare(b.name);
else if (sortBy === 'cost') cmp = b.cost - a.cost || a.name.localeCompare(b.name);
else if (sortBy === 'attack') cmp = b.attack - a.attack || a.name.localeCompare(b.name);
else if (sortBy === 'defense') cmp = b.defense - a.defense || a.name.localeCompare(b.name);
else if (sortBy === 'rarity') cmp = RARITY_ORDER[b.card_rarity] - RARITY_ORDER[a.card_rarity] || a.name.localeCompare(b.name);
else if (sortBy === 'date_generated') cmp = (b.generated_at ?? '').localeCompare(a.generated_at ?? '');
else if (sortBy === 'date_received') cmp = (b.received_at ?? b.generated_at ?? '').localeCompare(a.received_at ?? a.generated_at ?? '');
return sortAsc ? cmp : -cmp;
});
return result;
});
function toggleSort(val) {
// Keep bindable selectedCards in sync with loaded set.
$effect(() => {
selectedCards = cards.filter((c: any) => selectedIds.has(c.id));
});
// Update costMap as new cards load so we know costs even after they scroll away.
$effect(() => {
let changed = false;
for (const c of cards) {
if (!costMap.has(c.id)) changed = true;
}
if (changed) {
const m = new Map(costMap);
for (const c of cards) m.set(c.id, c.cost);
costMap = m;
}
});
// Compute cost from costMap so it includes cards not yet scrolled into view.
$effect(() => {
let sum = 0;
for (const id of selectedIds) {
sum += costMap.get(id) ?? 0;
}
selectedCost = sum;
});
// For non-name sorts the "natural" first click should show the highest/newest/rarest values.
// This matches old client-side behaviour where numeric sorts used b - a (descending first).
function sortDir() {
return sortBy === 'name'
? (sortAsc ? 'asc' : 'desc')
: (sortAsc ? 'desc' : 'asc');
}
async function fetchCards(reset = false) {
if (staticCards !== null) return; // static mode: nothing to fetch
if (reset) { fetchedCards = []; total = 0; }
if (loadingMore) return;
loadingMore = true;
const params = new URLSearchParams({
skip: String(reset ? 0 : fetchedCards.length),
limit: String(PAGE_SIZE),
search: searchQuery.trim(),
cost_min: String(costMin),
cost_max: String(costMax),
favorites_only: 'false',
wtt_only: String(willingToTradeOnly),
sort_by: sortBy,
sort_dir: sortDir(),
});
for (const r of selectedRarities) params.append('rarities', r);
for (const t of selectedTypes) params.append('types', t);
const res = await apiFetch(`${API_URL}/cards?${params}`);
if (!res.ok) { loadingMore = false; return; }
const data = await res.json();
fetchedCards = reset ? data.cards : [...fetchedCards, ...data.cards];
total = data.total;
loadingMore = false;
}
// Exposed so parents (e.g. shatter page) can trigger a full refetch after mutations
export function refresh() { fetchCards(true); }
// Debounced refetch on any filter/sort change (only in self-fetch mode).
// Skip the initial run — the component mounts and triggers the first fetch directly
// via the onMount-equivalent $effect below, avoiding a double-fetch flash.
let mounted = false;
let fetchTimer: number;
$effect(() => {
searchQuery; sortBy; sortAsc; selectedRarities; selectedTypes;
costMin; costMax; willingToTradeOnly;
if (staticCards !== null || !mounted) return;
clearTimeout(fetchTimer);
fetchTimer = setTimeout(() => fetchCards(true), 300);
});
// onMount runs once and doesn't track reactive dependencies — safe for the initial fetch.
// Using $effect here causes re-runs whenever loadingMore changes, resetting state infinitely.
onMount(() => {
if (staticCards === null) fetchCards(true).then(() => { mounted = true; });
else mounted = true;
});
// IntersectionObserver: load next page when sentinel scrolls into view (self-fetch mode only).
// Uses gridEl as root because .grid has overflow-y: auto — it is the scroll container.
// Both sentinel and gridEl are $state so this effect re-runs once both are bound.
$effect(() => {
if (!sentinel || !gridEl || staticCards !== null) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasMore && !loadingMore) fetchCards(false);
},
{ root: gridEl, rootMargin: '200px' }
);
observer.observe(sentinel);
return () => observer.disconnect();
});
function toggleSort(val: string) {
if (sortBy === val) sortAsc = !sortAsc;
else { sortBy = val; sortAsc = true; }
}
function toggleRarity(r) {
function toggleRarity(r: string) {
const s = new Set(selectedRarities);
s.has(r) ? s.delete(r) : s.add(r);
selectedRarities = s;
}
function toggleType(t) {
function toggleType(t: string) {
const s = new Set(selectedTypes);
s.has(t) ? s.delete(t) : s.add(t);
selectedTypes = s;
@@ -76,14 +193,17 @@
function toggleAllRarities() { selectedRarities = allRaritiesSelected() ? new Set() : new Set(RARITIES); }
function toggleAllTypes() { selectedTypes = allTypesSelected() ? new Set() : new Set(TYPES); }
function toggleCard(id) {
function toggleCard(id: string | number) {
const s = new Set(selectedIds);
if (s.has(id)) {
s.delete(id);
} else {
if (costLimit !== null) {
const card = allCards.find(c => c.id === id);
if (card && selectedCost + card.cost > costLimit) return;
const card = cards.find((c: any) => c.id === id);
if (costLimit !== null && card && selectedCost + card.cost > costLimit) return;
if (card && !costMap.has(id)) {
const m = new Map(costMap);
m.set(id, card.cost);
costMap = m;
}
s.add(id);
}
@@ -101,7 +221,7 @@
<div class="toolbar">
<div class="sort-row">
<span class="toolbar-label">Sort by</span>
{#each [['name','Name'],['cost','Cost'],['attack','Attack'],['defense','Defense'],['rarity','Rarity']] as [val, lbl]}
{#each [['name','Name'],['cost','Cost'],['attack','Attack'],['defense','Defense'],['rarity','Rarity'],['date_generated','Generated'],['date_received','Received']] as [val, lbl]}
<button class="sort-btn" class:active={sortBy === val} onclick={() => toggleSort(val)}>
{lbl}
{#if sortBy === val}<span class="sort-arrow">{sortAsc ? '↑' : '↓'}</span>{/if}
@@ -115,12 +235,21 @@
bind:value={searchQuery}
/>
<button class="filter-toggle" onclick={() => filtersOpen = !filtersOpen}>
{filtersOpen ? 'Hide filters' : 'Filter'}
{#if selectedRarities.size < RARITIES.length || selectedTypes.size < TYPES.length || costMin > 1 || costMax < 10}
<span class="filter-dot"></span>
{/if}
</button>
<div class="filter-actions">
<button
class="filter-toggle"
class:active={willingToTradeOnly}
onclick={() => willingToTradeOnly = !willingToTradeOnly}
title="Show willing to trade only"
></button>
<button class="filter-toggle" class:active={filtersOpen} onclick={() => filtersOpen = !filtersOpen}>
Filters
{#if selectedRarities.size < RARITIES.length || selectedTypes.size < TYPES.length || costMin > 1 || costMax < 10}
<span class="filter-dot"></span>
{/if}
</button>
</div>
</div>
{#if filtersOpen}
@@ -171,36 +300,42 @@
{/if}
</div>
{#if filtered.length === 0}
{#if !loadingMore && cards.length === 0}
<p class="status">No cards match your filters.</p>
{:else}
<div class="grid">
{#each filtered as card (card.id)}
<button
class="card-wrap"
class:selected={selectedIds.has(card.id)}
class:disabled={costLimit !== null && !selectedIds.has(card.id) && selectedCost + card.cost > costLimit}
onclick={() => toggleCard(card.id)}
>
<Card {card} noHover={true} />
{#if selectedIds.has(card.id)}
<div class="selected-badge"></div>
<div class="grid" bind:this={gridEl}>
{#each cards as card (card.id)}
<div class="card-item">
<button
class="card-wrap"
class:selected={selectedIds.has(card.id)}
class:disabled={costLimit !== null && !selectedIds.has(card.id) && selectedCost + card.cost > costLimit}
onclick={() => toggleCard(card.id)}
>
<Card {card} noHover={true} />
{#if selectedIds.has(card.id)}
<div class="selected-badge"></div>
{/if}
{#if inDeckIds.has(card.id)}
<div class="in-deck-badge"></div>
{/if}
</button>
{#if sortBy === 'date_received' && card.received_at}
<span class="received-label">Received {new Date(card.received_at).toLocaleDateString('en-GB', { year: 'numeric', month: '2-digit', day: '2-digit' })}</span>
{/if}
{#if inDeckIds.has(card.id)}
<div class="in-deck-badge" title="In a deck"></div>
{/if}
</button>
</div>
{/each}
<!-- Sentinel inside the scroll container (.grid) for the IntersectionObserver root -->
<div bind:this={sentinel} class="scroll-sentinel"></div>
{#if loadingMore}<p class="status loading-more">Loading...</p>{/if}
</div>
{/if}
</div>
<style>
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;700&family=Crimson+Text:ital,wght@0,400;0,600;1,400&display=swap');
.selector {
background: #0d0a04;
background: var(--color-bg);
width: 100%;
height: 100%;
display: flex;
@@ -211,7 +346,7 @@
.toolbar {
flex-shrink: 0;
padding: 1.5rem 2rem 1rem;
border-bottom: 1px solid rgba(107, 76, 30, 0.3);
border-bottom: 1px solid var(--color-border-dim);
display: flex;
flex-direction: column;
gap: 0.75rem;
@@ -226,23 +361,23 @@
.search-input {
font-family: 'Crimson Text', serif;
font-size: 15px;
background: rgba(255,255,255,0.04);
border: 1px solid rgba(107, 76, 30, 0.4);
border-radius: 4px;
color: #f0d080;
font-size: var(--text-md);
background: var(--color-surface);
border: 1.5px solid var(--color-bronze);
border-radius: var(--radius-md);
color: var(--color-gold);
padding: 5px 10px;
outline: none;
width: 220px;
margin-left: auto;
transition: border-color 0.15s;
}
.search-input:focus { border-color: #c8861a; }
.search-input::placeholder { color: rgba(240, 180, 80, 0.3); }
.search-input:focus { border-color: var(--color-gold); }
.search-input::placeholder { color: var(--color-gold-faint); }
.toolbar-label {
font-family: 'Cinzel', serif;
font-size: 11px;
font-size: var(--text-sm);
color: rgba(240, 180, 80, 0.5);
letter-spacing: 0.08em;
text-transform: uppercase;
@@ -251,41 +386,47 @@
.sort-btn {
font-family: 'Cinzel', serif;
font-size: 11px;
font-size: var(--btn-font-sm);
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
background: none;
border: 1px solid rgba(107, 76, 30, 0.4);
border-radius: 4px;
color: rgba(240, 180, 80, 0.6);
padding: 4px 10px;
border: 1px solid var(--color-border-subtle);
border-radius: var(--radius-sm);
color: var(--color-gold-dim);
padding: var(--btn-padding-sm);
cursor: pointer;
transition: all 0.15s;
}
.sort-btn:hover { border-color: #c8861a; color: #f0d080; }
.sort-btn.active { background: #3d2507; border-color: #c8861a; color: #f0d080; }
.sort-arrow { font-size: 10px; margin-left: 3px; }
.sort-btn:hover { border-color: var(--color-bronze); color: var(--color-gold); }
.sort-btn.active { background: var(--color-surface-raised); border-color: var(--color-bronze); color: var(--color-gold); }
.filter-toggle.active { background: var(--color-surface-raised); border-color: var(--color-bronze); color: var(--color-gold); }
.sort-arrow { font-size: var(--text-xs); margin-left: 3px; }
.filter-toggle {
font-family: 'Cinzel', serif;
font-size: 11px;
font-size: var(--btn-font-sm);
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
background: none;
border: 1px solid rgba(107, 76, 30, 0.4);
border-radius: 4px;
color: rgba(240, 180, 80, 0.6);
padding: 4px 10px;
border: 1px solid var(--color-border-subtle);
border-radius: var(--radius-sm);
color: var(--color-gold-dim);
padding: var(--btn-padding-sm);
cursor: pointer;
margin-left: 0.5rem;
position: relative;
transition: all 0.15s;
}
.filter-toggle:hover { border-color: #c8861a; color: #f0d080; }
.filter-actions {
display: flex;
gap: 0.5rem;
margin-left: 0.75rem;
}
.filter-toggle:hover { border-color: var(--color-bronze); color: var(--color-gold); }
.filter-dot {
position: absolute;
@@ -293,8 +434,8 @@
right: -3px;
width: 7px;
height: 7px;
border-radius: 50%;
background: #c8861a;
border-radius: var(--radius-full);
background: var(--color-bronze);
}
.filters {
@@ -309,7 +450,7 @@
.filter-group-label {
font-family: 'Cinzel', serif;
font-size: 11px;
font-size: var(--text-sm);
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
@@ -318,7 +459,7 @@
.select-all {
font-family: 'Crimson Text', serif;
font-size: 12px;
font-size: var(--text-sm);
font-style: italic;
background: none;
border: none;
@@ -329,7 +470,7 @@
transition: color 0.15s;
}
.select-all:hover { color: #f0d080; }
.select-all:hover { color: var(--color-gold); }
.checkboxes { display: flex; flex-wrap: wrap; gap: 0.4rem 1rem; }
@@ -338,13 +479,13 @@
align-items: center;
gap: 0.4rem;
font-family: 'Crimson Text', serif;
font-size: 14px;
color: rgba(240, 180, 80, 0.8);
font-size: var(--text-base);
color: var(--color-gold-muted);
cursor: pointer;
}
.checkbox-label input {
accent-color: #c8861a;
accent-color: var(--color-bronze);
width: 14px;
height: 14px;
cursor: pointer;
@@ -354,13 +495,13 @@
.range-label {
font-family: 'Cinzel', serif;
font-size: 11px;
font-size: var(--text-sm);
color: rgba(240, 180, 80, 0.7);
min-width: 60px;
}
input[type=range] {
accent-color: #c8861a;
accent-color: var(--color-bronze);
width: 160px;
}
@@ -374,22 +515,46 @@
padding: 2rem 2rem 0;
}
.card-item {
position: relative;
}
.received-label {
position: absolute;
top: 44px;
left: 50%;
transform: translateX(-50%);
white-space: nowrap;
pointer-events: none;
background: rgba(13, 10, 4, 0.88);
backdrop-filter: blur(6px);
border: 1px solid rgba(200, 134, 26, 0.55);
border-radius: 20px;
padding: 3px 12px;
font-size: var(--text-sm);
font-family: 'Cinzel', serif;
font-weight: 600;
letter-spacing: 0.07em;
color: var(--color-gold);
text-transform: uppercase;
}
.card-wrap {
all: unset;
position: relative;
cursor: pointer;
display: block;
border-radius: 12px;
border-radius: var(--radius-xl);
transition: transform 0.15s, box-shadow 0.15s;
}
.card-wrap:hover {
transform: translateY(-4px) scale(1.02);
box-shadow: 0 12px 40px rgba(0,0,0,0.6);
box-shadow: var(--shadow-elevated);
}
.card-wrap.selected {
box-shadow: 0 0 0 3px #c8861a, 0 0 20px rgba(200, 134, 26, 0.4);
box-shadow: 0 0 0 3px var(--color-bronze), var(--shadow-glow);
}
.card-wrap.disabled {
@@ -402,69 +567,104 @@
top: 80px;
left: 50%;
transform: translateX(-50%);
background: #c8861a;
color: #fff8e0;
background: var(--color-bronze);
color: var(--color-btn-text);
font-family: 'Cinzel', serif;
font-size: 23.875px;
font-weight: 1000;
font-size: var(--text-xl);
font-weight: 900;
padding: 4px 10px;
border-radius: 23px;
border: black 3px solid;
pointer-events: none;
z-index: 10;
z-index: var(--z-card);
}
.in-deck-badge::after {
content: 'In deck';
position: absolute;
top: calc(100% + 6px);
right: 0;
background: #000;
color: #7ecfcf;
font-size: var(--text-sm);
font-weight: 600;
white-space: nowrap;
padding: 4px 7px;
border-radius: var(--radius-sm);
border: 1px solid rgba(126, 207, 207, 0.5);
pointer-events: none;
opacity: 0;
transition: opacity 0.15s;
z-index: var(--z-toast);
}
.in-deck-badge:hover::after {
opacity: 1;
}
.in-deck-badge {
position: absolute;
top: 6px;
right: 6px;
background: rgba(13, 8, 2, 0.75);
background: #000;
color: #7ecfcf;
font-size: 16px;
line-height: 1;
padding: 3px 5px;
border-radius: 6px;
border: 1px solid rgba(126, 207, 207, 0.5);
pointer-events: none;
z-index: 10;
font-size: var(--text-lg);
font-weight: 700;
width: 34px;
height: 34px;
padding: 0;
border-radius: var(--radius-md);
border: 1px solid rgba(126, 207, 207, 0.9);
box-shadow: 0 0 6px rgba(126, 207, 207, 0.4);
display: flex;
align-items: center;
justify-content: center;
z-index: var(--z-card);
}
.scroll-sentinel { height: 1px; width: 100%; flex-basis: 100%; }
.status {
font-family: 'Crimson Text', serif;
font-size: 16px;
font-size: var(--text-md);
font-style: italic;
color: rgba(240, 180, 80, 0.5);
text-align: center;
margin-top: 4rem;
}
.loading-more {
flex-basis: 100%;
margin-top: 1rem;
}
.top-bar {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 2rem;
border-bottom: 1px solid rgba(107, 76, 30, 0.3);
background: #0d0a04;
border-bottom: 1px solid var(--color-border-dim);
background: var(--color-bg);
}
.counter {
font-family: 'Cinzel', serif;
font-size: 13px;
color: rgba(240, 180, 80, 0.6);
font-size: var(--text-base);
color: var(--color-gold-dim);
}
.done-btn {
font-family: 'Cinzel', serif;
font-size: 11px;
font-size: var(--btn-font-md);
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
background: #3d2507;
border: 1px solid #c8861a;
border-radius: 4px;
color: #f0d080;
padding: 8px 24px;
background: var(--color-surface-raised);
border: 1px solid var(--color-bronze);
border-radius: var(--radius-md);
color: var(--color-gold);
padding: var(--btn-padding-md);
cursor: pointer;
transition: background 0.15s;
}
+3 -2
View File
@@ -11,11 +11,12 @@
<style>
.type-badge {
font-family: 'Cinzel', serif;
font-size: 10px;
font-size: var(--text-xs);
font-weight: 700;
letter-spacing: 0.05em;
padding: 3px 8px;
border-radius: 3px;
white-space: nowrap;
border-radius: var(--radius-sm);
cursor: default;
display: inline-block;
}
+5
View File
@@ -1,4 +1,5 @@
let isRefreshing = false;
/** @type {Promise<string> | null} */
let refreshPromise = null;
import { PUBLIC_API_URL } from '$env/static/public';
export const API_URL = PUBLIC_API_URL;
@@ -27,6 +28,10 @@ async function refreshTokens() {
return data.access_token;
}
/**
* @param {string} url
* @param {RequestInit} [options]
*/
export async function apiFetch(url, options = {}) {
const token = localStorage.getItem('token');
+32
View File
@@ -0,0 +1,32 @@
const cache = {};
const FILES = {
cardFlip: '/sounds/card-flip.mp3',
packOpen: '/sounds/pack-open.mp3',
packRip: '/sounds/pack-rip.mp3',
cardPlay: '/sounds/card-play.mp3',
attack: '/sounds/attack.mp3',
defend: '/sounds/defend.mp3',
cardDestroy: '/sounds/card-destroy.mp3',
cardShatter: '/sounds/card-shatter.mp3',
win: '/sounds/win.mp3',
loss: '/sounds/loss.mp3',
buttonClick: '/sounds/button-click.mp3',
};
const VOLUMES = {
cardShatter: 0.1,
attack: 0.1,
defend: 0.1,
};
const DEFAULT_VOLUME = 0.3;
export function play(name) {
if (!FILES[name]) return;
if (!cache[name]) cache[name] = new Audio(FILES[name]);
// cloneNode allows the same sound to overlap itself
const audio = cache[name].cloneNode();
audio.volume = VOLUMES[name] ?? DEFAULT_VOLUME;
audio.play().catch(() => {});
}
+803 -35
View File
@@ -1,21 +1,224 @@
<script>
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { onMount, onDestroy } from 'svelte';
import { apiFetch, WS_URL, API_URL } from '$lib/api.js';
let menuOpen = $state(false);
let socialOpen = $state(false);
let shardsOpen = $state(false);
let notifOpen = $state(false);
let notifications = $state([]);
let notifErrors = $state({});
let notifWs = null;
let reconnectTimer = null;
let notifReconnectDelay = 1000;
let notifReconnecting = $state(false);
let unreadCount = $derived(notifications.filter(n => !n.read).length);
const links = [
{ href: '/', label: 'Booster Packs' },
{ href: '/cards', label: 'Cards' },
{ href: '/decks', label: 'Decks' },
{ href: '/play', label: 'Play' },
{ href: '/trade', label: 'Trade' },
{ href: '/store', label: 'Store' },
{ href: '/', label: 'Booster Packs' },
{ href: '/cards', label: 'Cards' },
{ href: '/decks', label: 'Decks' },
{ href: '/play', label: 'Play' },
];
const socialLinks = [
{ href: '/trade', label: 'Trade' },
{ href: '/users', label: 'Users' },
];
const shardsLinks = [
{ href: '/store', label: 'Store' },
{ href: '/shatter', label: 'Shatter' },
];
function close() { menuOpen = false; }
function closeDropdowns() {
socialOpen = false;
shardsOpen = false;
notifOpen = false;
}
function handleWindowClick(e) {
if (!e.target.closest('.dropdown')) {
closeDropdowns();
}
}
function connectNotificationWS() {
const token = typeof localStorage !== 'undefined' ? localStorage.getItem('token') : null;
if (!token) return;
notifWs = new WebSocket(`${WS_URL}/ws/notifications`);
notifWs.onopen = () => {
notifWs.send(token);
notifReconnecting = false;
notifReconnectDelay = 1000;
};
notifWs.onmessage = (e) => {
const msg = JSON.parse(e.data);
if (msg.type === 'flush') {
notifications = msg.notifications;
} else if (msg.type === 'push') {
notifications = [...notifications, msg.notification];
} else if (msg.type === 'delete') {
notifications = notifications.filter(n => n.id !== msg.notification_id);
}
};
notifWs.onclose = () => {
notifReconnecting = true;
reconnectTimer = setTimeout(() => {
notifReconnectDelay = Math.min(notifReconnectDelay * 2, 30000);
connectNotificationWS();
}, notifReconnectDelay);
};
}
onMount(() => {
connectNotificationWS();
});
onDestroy(() => {
clearTimeout(reconnectTimer);
notifWs?.close();
});
async function markAllRead() {
const unread = notifications.filter(n => !n.read);
await Promise.all(
unread.map(n => apiFetch(`${API_URL}/notifications/${n.id}/read`, { method: 'POST' }))
);
notifications = notifications.map(n => ({ ...n, read: true }));
}
async function deleteNotification(id) {
await apiFetch(`${API_URL}/notifications/${id}`, { method: 'DELETE' });
notifications = notifications.filter(n => n.id !== id);
}
async function markRead(notif) {
if (notif.read) return;
await apiFetch(`${API_URL}/notifications/${notif.id}/read`, { method: 'POST' });
notifications = notifications.map(n => n.id === notif.id ? { ...n, read: true } : n);
}
// Countdown state: map of notif.id → remaining seconds
let countdowns = $state({});
let countdownInterval = null;
function startCountdowns() {
if (countdownInterval) clearInterval(countdownInterval);
countdownInterval = setInterval(() => {
const now = Date.now();
const updated = {};
for (const notif of notifications) {
if (notif.type === 'game_challenge' && notif.expires_at && !notif.payload.status) {
const secs = Math.max(0, Math.floor((new Date(notif.expires_at).getTime() - now) / 1000));
updated[notif.id] = secs;
}
}
countdowns = updated;
}, 1000);
}
$effect(() => {
const hasChallenges = notifications.some(n => n.type === 'game_challenge' && !n.payload.status);
if (hasChallenges) startCountdowns();
else if (countdownInterval) { clearInterval(countdownInterval); countdownInterval = null; }
});
// Load decks when the notification panel opens and there are incoming challenges
$effect(() => {
if (notifOpen && notifications.some(n => n.type === 'game_challenge' && !n.payload.status)) {
loadChallengeDecks();
}
});
// Per-challenge deck selection state
let challengeDeckSelections = $state({}); // notif.id → deck_id
let challengeDecks = $state([]);
let challengeDecksLoaded = $state(false);
async function loadChallengeDecks() {
if (challengeDecksLoaded) return;
const res = await apiFetch(`${API_URL}/decks`);
if (res.ok) {
const data = await res.json();
challengeDecks = data.filter(d => !d.deleted);
if (challengeDecks.length) {
// Set default deck for all pending incoming challenges
const updated = { ...challengeDeckSelections };
for (const n of notifications) {
if (n.type === 'game_challenge' && !n.payload.status && !updated[n.id]) {
updated[n.id] = challengeDecks[0].id;
}
}
challengeDeckSelections = updated;
}
challengeDecksLoaded = true;
}
}
async function acceptChallenge(notif) {
const deckId = challengeDeckSelections[notif.id];
if (!deckId) return;
const res = await apiFetch(`${API_URL}/challenges/${notif.payload.challenge_id}/accept`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ deck_id: deckId }),
});
if (res.ok) {
const data = await res.json();
closeDropdowns();
goto(`/play?game_id=${data.game_id}`);
} else {
notifErrors = { ...notifErrors, [notif.id]: 'Failed to accept challenge.' };
}
}
async function declineChallenge(notif) {
const res = await apiFetch(`${API_URL}/challenges/${notif.payload.challenge_id}/decline`, { method: 'POST' });
if (!res.ok) { notifErrors = { ...notifErrors, [notif.id]: 'Failed to decline.' }; return; }
notifications = notifications.filter(n => n.id !== notif.id);
}
async function acceptFriendRequest(notif) {
const res = await apiFetch(`${API_URL}/friendships/${notif.payload.friendship_id}/accept`, { method: 'POST' });
if (!res.ok) { notifErrors = { ...notifErrors, [notif.id]: 'Failed to accept.' }; return; }
await apiFetch(`${API_URL}/notifications/${notif.id}/read`, { method: 'POST' });
notifications = notifications.filter(n => n.id !== notif.id);
}
async function declineFriendRequest(notif) {
const res = await apiFetch(`${API_URL}/friendships/${notif.payload.friendship_id}/decline`, { method: 'POST' });
if (!res.ok) { notifErrors = { ...notifErrors, [notif.id]: 'Failed to decline.' }; return; }
notifications = notifications.filter(n => n.id !== notif.id);
}
function relativeTime(isoString) {
const diff = Date.now() - new Date(isoString).getTime();
const mins = Math.floor(diff / 60000);
if (mins < 1) return 'just now';
if (mins < 60) return `${mins}m ago`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h ago`;
return `${Math.floor(hrs / 24)}d ago`;
}
function typeLabel(type) {
return { friend_request: 'Friend Request', trade_offer: 'Trade Offer', trade_response: 'Trade Response', game_challenge: 'Game Challenge' }[type] ?? type;
}
</script>
<svelte:window onclick={handleWindowClick} />
<header>
<a href="/" class="logo" onclick={close}>WikiTCG</a>
@@ -23,7 +226,168 @@
{#each links as link}
<a href={link.href} class:active={$page.url.pathname === link.href}>{link.label}</a>
{/each}
<a href="/profile" class:active={$page.url.pathname === '/profile'}>Profile</a>
<!-- Social dropdown: Trade + Users -->
<div class="dropdown">
<button
class="dropdown-trigger"
class:active={$page.url.pathname.startsWith('/trade') || $page.url.pathname.startsWith('/users')}
class:open={socialOpen}
onclick={(e) => { e.stopPropagation(); shardsOpen = false; notifOpen = false; socialOpen = !socialOpen; }}
>
Social <span class="chevron" class:open={socialOpen}>▾</span>
</button>
{#if socialOpen}
<div class="dropdown-menu">
{#each socialLinks as link}
<a href={link.href} onclick={closeDropdowns}>{link.label}</a>
{/each}
</div>
{/if}
</div>
<!-- Shards dropdown: Store + Shatter -->
<div class="dropdown">
<button
class="dropdown-trigger"
class:active={$page.url.pathname === '/store' || $page.url.pathname === '/shatter'}
class:open={shardsOpen}
onclick={(e) => { e.stopPropagation(); socialOpen = false; notifOpen = false; shardsOpen = !shardsOpen; }}
>
Shards <span class="chevron" class:open={shardsOpen}>▾</span>
</button>
{#if shardsOpen}
<div class="dropdown-menu">
{#each shardsLinks as link}
<a href={link.href} onclick={closeDropdowns}>{link.label}</a>
{/each}
</div>
{/if}
</div>
<!-- Profile icon -->
<a href="/profile" class="profile-icon" class:active={$page.url.pathname === '/profile'} aria-label="Profile">
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="12" cy="8" r="4" fill="currentColor"/>
<path d="M4 20c0-4 3.6-7 8-7s8 3 8 7" fill="currentColor"/>
</svg>
</a>
<!-- Notification bell -->
<div class="dropdown">
<button
class="bell-btn"
class:active={notifOpen}
aria-label="Notifications"
onclick={(e) => { e.stopPropagation(); socialOpen = false; shardsOpen = false; notifOpen = !notifOpen; }}
>
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" class="bell-icon">
<path d="M12 2a7 7 0 0 0-7 7v4l-2 3h18l-2-3V9a7 7 0 0 0-7-7z" fill="currentColor"/>
<path d="M10 19a2 2 0 0 0 4 0" fill="currentColor"/>
</svg>
{#if unreadCount > 0}
<span class="badge">{unreadCount > 9 ? '9+' : unreadCount}</span>
{/if}
{#if notifReconnecting}
<span class="reconnecting-dot" title="Reconnecting..."></span>
{/if}
</button>
{#if notifOpen}
<div class="notif-panel">
<div class="notif-header">
<span class="notif-title">Notifications</span>
{#if unreadCount > 0}
<button class="mark-all-btn" onclick={markAllRead}>Mark all read</button>
{/if}
</div>
{#if notifications.length === 0}
<div class="notif-empty">No notifications</div>
{:else}
<ul class="notif-list">
{#each notifications as notif (notif.id)}
<li class="notif-item" class:unread={!notif.read}>
<div class="notif-top">
<span class="notif-type">{typeLabel(notif.type)}</span>
<span class="notif-time">{relativeTime(notif.created_at)}</span>
<button class="dismiss-btn" onclick={() => deleteNotification(notif.id)} aria-label="Dismiss"></button>
</div>
{#if notif.type === 'friend_request'}
<p class="notif-body"><strong>{notif.payload.from_username ?? 'Someone'}</strong> sent you a friend request.</p>
<div class="notif-actions">
<button class="action-btn accept" onclick={() => acceptFriendRequest(notif)}>Accept</button>
<button class="action-btn decline" onclick={() => declineFriendRequest(notif)}>Decline</button>
</div>
{:else if notif.type === 'trade_offer'}
<p class="notif-body">
<strong>{notif.payload.from_username ?? 'Someone'}</strong> sent you a trade offer
({notif.payload.offered_count ?? 0} offered, {notif.payload.requested_count ?? 0} requested).
</p>
<div class="notif-actions">
<button class="action-btn accept" onclick={() => { markRead(notif); closeDropdowns(); goto('/profile'); }}>View Proposals</button>
</div>
{:else if notif.type === 'trade_response'}
<p class="notif-body">
<strong>{notif.payload.from_username ?? 'Someone'}</strong>
{notif.payload.status === 'accepted' ? 'accepted' : 'declined'} your trade offer.
</p>
<div class="notif-actions">
<a href="/trade/proposal/{notif.payload.proposal_id}" class="action-btn accept" onclick={() => { markRead(notif); closeDropdowns(); }}>View Trade</a>
</div>
{:else if notif.type === 'game_challenge'}
{#if notif.payload.status === 'accepted'}
<!-- Response notification sent to challenger -->
<p class="notif-body"><strong>{notif.payload.from_username ?? 'Someone'}</strong> accepted your challenge!</p>
<div class="notif-actions">
<a href="/play?game_id={notif.payload.game_id}" class="action-btn accept" onclick={() => { markRead(notif); closeDropdowns(); }}>Join Game</a>
</div>
{:else}
<!-- Incoming challenge for the challenged player -->
{@const secs = countdowns[notif.id] ?? Math.max(0, Math.floor((new Date(notif.expires_at ?? 0).getTime() - Date.now()) / 1000))}
{@const expired = secs <= 0}
<p class="notif-body">
<strong>{notif.payload.from_username ?? 'Someone'}</strong> challenged you with <em>{notif.payload.deck_name ?? 'a deck'}</em>.
</p>
{#if expired}
<span class="challenge-expired">Expired</span>
{:else}
<span class="challenge-countdown" class:urgent={secs <= 60}>{Math.floor(secs / 60)}:{String(secs % 60).padStart(2, '0')} remaining</span>
<div class="notif-actions">
{#if challengeDecks.length > 0}
<select
class="challenge-deck-select"
value={challengeDeckSelections[notif.id] ?? ''}
onchange={(e) => challengeDeckSelections = { ...challengeDeckSelections, [notif.id]: e.target.value }}
>
{#each challengeDecks as deck}
<option value={deck.id}>{deck.name}</option>
{/each}
</select>
{/if}
<button class="action-btn accept" onclick={() => acceptChallenge(notif)} disabled={!challengeDeckSelections[notif.id]}>Accept</button>
<button class="action-btn decline" onclick={() => declineChallenge(notif)}>Decline</button>
</div>
{/if}
{/if}
{:else}
<p class="notif-body">{notif.payload.message ?? ''}</p>
{/if}
{#if notifErrors[notif.id]}
<p class="notif-error">{notifErrors[notif.id]}</p>
{/if}
</li>
{/each}
</ul>
{/if}
</div>
{/if}
</div>
</nav>
<button class="hamburger" onclick={() => menuOpen = !menuOpen} aria-label="Toggle menu">
@@ -39,56 +403,460 @@
{#each links as link}
<a href={link.href} class:active={$page.url.pathname === link.href} onclick={close}>{link.label}</a>
{/each}
{#each socialLinks as link}
<a href={link.href} class:active={$page.url.pathname === link.href} onclick={close}>{link.label}</a>
{/each}
{#each shardsLinks as link}
<a href={link.href} class:active={$page.url.pathname === link.href} onclick={close}>{link.label}</a>
{/each}
<a href="/profile" class:active={$page.url.pathname === '/profile'} onclick={close}>
Profile{unreadCount > 0 ? ` (${unreadCount})` : ''}
</a>
</nav>
{/if}
<style>
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;700&family=Crimson+Text:ital,wght@0,400;0,600;1,400&display=swap');
header {
position: sticky;
top: 0;
z-index: 100;
z-index: var(--z-header);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 2rem;
padding: 0 var(--space-xl);
height: 56px;
background: #1a1008;
border-bottom: 2px solid #6b4c1e;
background: var(--color-surface);
border-bottom: 2px solid var(--color-border);
}
.logo {
font-family: 'Cinzel', serif;
font-size: 18px;
font-size: var(--text-lg);
font-weight: 700;
color: #f0d080;
color: var(--color-gold);
text-decoration: none;
letter-spacing: 0.05em;
}
nav.desktop {
display: flex;
gap: 2rem;
align-items: center;
gap: var(--space-lg);
}
nav.desktop a {
nav.desktop > a {
font-family: 'Cinzel', serif;
font-size: 13px;
font-size: var(--text-base);
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: rgba(240, 180, 80, 0.8);
color: var(--color-gold-muted);
text-decoration: none;
transition: color 0.15s;
padding: 4px 0;
border-bottom: 1.5px solid transparent;
}
nav.desktop a:hover,
nav.desktop a.active {
color: #f0d080;
border-bottom-color: #f0d080;
nav.desktop > a:hover,
nav.desktop > a.active {
color: var(--color-gold);
border-bottom-color: var(--color-gold);
}
/* Dropdown container */
.dropdown {
position: relative;
display: flex;
align-items: center;
}
.dropdown-trigger {
font-family: 'Cinzel', serif;
font-size: var(--text-base);
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--color-gold-muted);
background: none;
border: none;
border-bottom: 1.5px solid transparent;
cursor: pointer;
padding: 4px 0;
display: flex;
align-items: center;
gap: 3px;
transition: color 0.15s;
width: auto;
}
.dropdown-trigger:hover,
.dropdown-trigger.active {
color: var(--color-gold);
border-bottom-color: var(--color-gold);
}
.chevron {
font-size: var(--text-xs);
line-height: 1;
transition: transform 0.15s;
display: inline-block;
}
.chevron.open {
transform: rotate(180deg);
}
.dropdown-menu {
position: absolute;
top: calc(100% + 8px);
left: 0;
background: var(--color-surface);
border: 2px solid var(--color-border);
border-radius: var(--radius-md);
padding: 0.4rem 0;
min-width: 130px;
z-index: var(--z-dropdown);
}
.dropdown-menu a {
display: block;
padding: 0.55rem 1rem;
font-family: 'Cinzel', serif;
font-size: var(--text-sm);
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--color-gold-muted);
text-decoration: none;
transition: color 0.15s, background 0.15s;
}
.dropdown-menu a:hover {
color: var(--color-gold);
background: var(--color-border-dim);
}
/* Profile icon link */
.profile-icon {
display: flex;
align-items: center;
color: var(--color-gold-muted);
text-decoration: none;
padding: 4px 0;
border-bottom: 1.5px solid transparent;
transition: color 0.15s;
}
.profile-icon:hover,
.profile-icon.active {
color: var(--color-gold);
border-bottom-color: var(--color-gold);
}
.profile-icon svg {
width: 20px;
height: 20px;
}
/* Bell button */
.bell-btn {
position: relative;
display: flex;
align-items: center;
justify-content: center;
background: none;
border: none;
border-bottom: 1.5px solid transparent;
cursor: pointer;
padding: 4px 0;
color: var(--color-gold-muted);
transition: color 0.15s;
width: auto;
}
.bell-btn:hover,
.bell-btn.active {
color: var(--color-gold);
border-bottom-color: var(--color-gold);
}
.bell-icon {
width: 20px;
height: 20px;
}
.badge {
position: absolute;
top: -2px;
right: -6px;
background: var(--color-bronze);
color: var(--color-btn-text);
font-family: 'Cinzel', serif;
font-size: var(--text-xs);
font-weight: 700;
line-height: 1;
min-width: 16px;
height: 16px;
border-radius: var(--radius-full);
display: flex;
align-items: center;
justify-content: center;
padding: 0 3px;
pointer-events: none;
}
/* Notification panel */
.notif-panel {
position: absolute;
top: calc(100% + 8px);
right: 0;
width: min(320px, 90vw);
background: var(--color-surface);
border: 2px solid var(--color-border);
border-radius: var(--radius-md);
z-index: var(--z-dropdown);
overflow: hidden;
}
.notif-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.6rem 1rem;
border-bottom: 1px solid var(--color-border-subtle);
}
.notif-title {
font-family: 'Cinzel', serif;
font-size: var(--text-sm);
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--color-gold-dim);
}
.mark-all-btn {
font-family: 'Cinzel', serif;
font-size: var(--text-xs);
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--color-bronze);
background: none;
border: none;
cursor: pointer;
padding: 0;
transition: color 0.15s;
width: auto;
}
.mark-all-btn:hover {
color: var(--color-gold);
}
.notif-empty {
padding: 1.5rem 1rem;
font-family: 'Cinzel', serif;
font-size: var(--text-sm);
color: var(--color-gold-faint);
text-align: center;
letter-spacing: 0.06em;
}
.notif-list {
list-style: none;
margin: 0;
padding: 0;
max-height: 380px;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: var(--color-border) transparent;
}
.notif-list::-webkit-scrollbar {
width: 4px;
}
.notif-list::-webkit-scrollbar-track {
background: transparent;
}
.notif-list::-webkit-scrollbar-thumb {
background: var(--color-border);
border-radius: var(--radius-md);
}
.notif-item {
padding: 0.7rem 1rem;
border-bottom: 1px solid var(--color-border-dim);
transition: background 0.15s;
}
.notif-item:last-child {
border-bottom: none;
}
.notif-item.unread {
background: var(--color-border-dim);
}
.notif-item:hover {
background: var(--color-border-dim);
}
.notif-top {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.35rem;
}
.notif-type {
font-family: 'Cinzel', serif;
font-size: var(--text-xs);
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--color-bronze);
flex: 1;
}
.notif-time {
font-size: var(--text-sm);
color: var(--color-gold-faint);
font-family: 'Crimson Text', serif;
white-space: nowrap;
}
.dismiss-btn {
background: none;
border: none;
cursor: pointer;
color: var(--color-gold-faint);
font-size: var(--text-sm);
padding: 0;
line-height: 1;
transition: color 0.15s;
width: auto;
flex-shrink: 0;
}
.dismiss-btn:hover {
color: var(--color-error);
}
.notif-body {
font-family: 'Crimson Text', serif;
font-size: var(--text-base);
color: var(--color-gold-muted);
margin: 0 0 0.5rem 0;
line-height: 1.4;
}
.notif-body strong {
color: var(--color-gold);
font-weight: 600;
}
.notif-actions {
display: flex;
gap: 0.5rem;
}
.notif-error {
font-size: var(--text-sm);
color: var(--color-error);
margin: 4px 0 0;
}
.action-btn {
font-family: 'Cinzel', serif;
font-size: var(--btn-font-sm);
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
padding: var(--btn-padding-sm);
border-radius: var(--radius-sm);
cursor: pointer;
border: 1px solid var(--color-border-subtle);
transition: background 0.15s, color 0.15s;
text-decoration: none;
display: inline-flex;
align-items: center;
}
.action-btn.accept {
background: var(--color-surface-raised);
color: var(--color-gold);
}
.action-btn.accept:hover {
background: var(--color-bronze);
color: var(--color-btn-text);
border-color: var(--color-bronze);
}
.action-btn.decline {
background: transparent;
color: var(--color-gold-dim);
}
.action-btn.decline:hover {
background: rgba(180, 40, 40, 0.3);
color: var(--color-gold);
border-color: rgba(180, 40, 40, 0.5);
}
.challenge-countdown {
font-family: 'Cinzel', serif;
font-size: var(--text-xs);
font-weight: 700;
letter-spacing: 0.05em;
color: var(--color-gold-dim);
margin-bottom: 0.4rem;
display: block;
}
.challenge-countdown.urgent { color: var(--color-error); }
.challenge-expired {
font-family: 'Cinzel', serif;
font-size: var(--text-xs);
font-weight: 700;
letter-spacing: 0.05em;
color: rgba(180, 80, 80, 0.5);
text-transform: uppercase;
}
.challenge-deck-select {
background: var(--color-bg);
border: 1px solid var(--color-border-subtle);
border-radius: var(--radius-sm);
color: var(--color-gold);
font-family: 'Crimson Text', serif;
font-size: var(--text-base);
padding: 4px 8px;
cursor: pointer;
flex-shrink: 1;
min-width: 0;
max-width: 140px;
}
.challenge-deck-select:focus { outline: none; border-color: var(--color-bronze); }
.reconnecting-dot {
position: absolute;
bottom: 1px;
right: -3px;
width: 6px;
height: 6px;
background: var(--color-bronze);
border-radius: var(--radius-full);
pointer-events: none;
animation: pulse-dot 1.2s ease-in-out infinite;
}
@keyframes pulse-dot {
0%, 100% { opacity: 0.4; }
50% { opacity: 1; }
}
.hamburger {
@@ -106,7 +874,7 @@
display: block;
width: 22px;
height: 2px;
background: #f0d080;
background: var(--color-gold);
border-radius: 2px;
transition: transform 0.2s, opacity 0.2s;
}
@@ -118,8 +886,8 @@
.mobile-backdrop {
position: fixed;
inset: 56px 0 0 0;
z-index: 99;
background: rgba(0,0,0,0.5);
z-index: var(--z-header);
background: var(--color-overlay);
}
nav.mobile {
@@ -128,34 +896,34 @@
right: 0;
bottom: 0;
width: 240px;
z-index: 100;
background: #1a1008;
border-left: 2px solid #6b4c1e;
z-index: var(--z-header);
background: var(--color-surface);
border-left: 2px solid var(--color-border);
display: flex;
flex-direction: column;
padding: 1.5rem;
padding: var(--space-lg);
gap: 0.25rem;
}
nav.mobile a {
font-family: 'Cinzel', serif;
font-size: 13px;
font-size: var(--text-base);
letter-spacing: 0.08em;
text-transform: uppercase;
color: rgba(240, 180, 80, 0.6);
color: var(--color-gold-dim);
text-decoration: none;
padding: 0.75rem 0;
border-bottom: 1px solid rgba(107, 76, 30, 0.3);
border-bottom: 1px solid var(--color-border-dim);
transition: color 0.15s;
}
nav.mobile a:hover,
nav.mobile a.active {
color: #f0d080;
color: var(--color-gold);
}
@media (max-width: 640px) {
nav.desktop { display: none; }
.hamburger { display: flex; }
}
</style>
</style>
+162
View File
@@ -0,0 +1,162 @@
<script>
import { page } from '$app/stores';
$: is404 = $page.status === 404;
$: title = is404 ? 'Page Not Found' : 'Something Went Wrong';
$: message = is404 ? null : ($page.error?.message || 'An unexpected error occurred. Please try again.');
</script>
<div class="error-page">
<div class="card-ghost" aria-hidden="true"></div>
<div class="content">
<div class="rune-divider" aria-hidden="true">
<span class="rune"></span>
<span class="line"></span>
<span class="rune"></span>
</div>
<p class="status-code">{$page.status}</p>
<h1 class="title">{title}</h1>
{#if message}<p class="message">{message}</p>{/if}
<a href="/" class="btn-home">Return Home</a>
</div>
</div>
<style>
.error-page {
min-height: calc(100vh - 56px);
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
padding: 40px 24px;
}
/* Ghost card decorative background element */
.card-ghost {
position: absolute;
right: 12%;
top: 50%;
transform: translateY(-50%) rotate(8deg);
width: 180px;
height: 260px;
border: 2px solid #f0d080;
border-radius: 10px;
opacity: 0.04;
pointer-events: none;
animation: ghost-float 6s ease-in-out infinite;
}
@keyframes ghost-float {
0%, 100% { transform: translateY(-50%) rotate(8deg); }
50% { transform: translateY(calc(-50% - 12px)) rotate(8deg); }
}
/* Second ghost card, mirror left */
.card-ghost::before {
content: '';
position: absolute;
left: calc(-100% - 60vw + 180px);
top: 0;
width: 100%;
height: 100%;
border: 2px solid #f0d080;
border-radius: 10px;
transform: rotate(-12deg);
}
.content {
position: relative;
z-index: 1;
max-width: 480px;
width: 100%;
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
gap: 0;
}
.rune-divider {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 32px;
width: 200px;
}
.rune {
font-size: var(--text-xs);
color: rgba(240, 180, 80, 0.3);
flex-shrink: 0;
}
.line {
flex: 1;
height: 1px;
background: rgba(107, 76, 30, 0.5);
}
.status-code {
font-family: 'Cinzel', serif;
font-size: clamp(6rem, 18vw, 9rem);
font-weight: 900;
color: #f0d080;
line-height: 0.9;
margin: 0 0 12px;
letter-spacing: -0.02em;
/* Faint inner glow */
text-shadow:
0 0 60px rgba(200, 134, 26, 0.2),
0 0 120px rgba(200, 134, 26, 0.1);
}
.title {
font-family: 'Cinzel', serif;
font-size: clamp(1rem, 3.5vw, 1.4rem);
font-weight: 700;
color: rgba(240, 180, 80, 0.7);
text-transform: uppercase;
letter-spacing: 0.12em;
margin: 0 0 28px;
}
.message {
font-family: 'Crimson Text', serif;
font-size: var(--text-lg);
font-style: italic;
color: rgba(240, 180, 80, 0.5);
line-height: 1.6;
margin: 0 0 40px;
}
.btn-home {
display: inline-block;
background: #c8861a;
color: #fff8e0;
font-family: 'Cinzel', serif;
font-size: var(--btn-font-lg);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
text-decoration: none;
padding: var(--btn-padding-lg);
border-radius: var(--radius-md);
border: none;
transition: background 0.15s ease, transform 0.15s ease, box-shadow 0.15s ease;
box-shadow: 0 2px 12px rgba(200, 134, 26, 0.3);
}
.btn-home:hover {
background: #e09820;
transform: translateY(-1px);
box-shadow: 0 4px 20px rgba(200, 134, 26, 0.45);
}
.btn-home:active {
transform: translateY(0);
}
</style>
+11 -8
View File
@@ -1,17 +1,20 @@
<script lang="ts">
import '../app.css';
import Header from "$lib/header.svelte";
import favicon from '$lib/assets/favicon.svg';
import { page } from '$app/state';
import '../app.css';
import Header from "$lib/header.svelte";
import favicon from '$lib/assets/favicon.svg';
import { page } from '$app/state';
let { children } = $props();
let { children } = $props();
const showHeader = $derived(!['auth', 'forgot-password'].some(p => page.url.pathname.startsWith(`/${p}`)));
const showHeader = $derived(
!['auth', 'forgot-password'].some(p => page.url.pathname.startsWith(`/${p}`)) &&
!/^\/decks\/.+/.test(page.url.pathname)
);
</script>
<svelte:head>
<title>WikiTCG</title>
<link rel="icon" href={favicon} />
<title>WikiTCG</title>
<link rel="icon" href={favicon} />
</svelte:head>
<div class="layout">
+241 -36
View File
@@ -1,27 +1,28 @@
<script>
<script lang="ts">
import { API_URL, WS_URL } from '$lib/api.js';
import { apiFetch } from '$lib/api.js';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import Card from '$lib/Card.svelte';
import { play } from '$lib/audio.js';
let cards = $state([]);
let cards: any[] = $state([]);
let loading = $state(false);
let boosters = $state(null);
let countdown = $state(null);
let boosters: number | null = $state(null);
let countdown: Date | null = $state(null);
let emailVerified = $state(true);
let countdownDisplay = $state('');
let countdownInterval = null;
let countdownInterval: number | undefined = undefined;
let phase = $state('idle');
let flippedCards = $state([]);
let flippedCards: boolean[] = $state([]);
let fanVisible = $state(false);
let packRef = $state(null);
let overlayPackRef = $state(null);
let packRef: HTMLDivElement | null = $state(null);
let overlayPackRef: HTMLElement | null = $state(null);
onMount(async () => {
onMount(() => {
if (!localStorage.getItem('token')) { goto('/auth'); return; }
await fetchBoosters();
fetchBoosters();
return () => clearInterval(countdownInterval);
});
@@ -37,10 +38,11 @@
function startCountdown() {
clearInterval(countdownInterval);
if (!countdown || boosters >= 5) return;
const cd = countdown;
if (!cd || boosters === null || boosters >= 5) return;
countdownInterval = setInterval(() => {
const nextTick = new Date(countdown.getTime() + 5 * 60 * 60 * 1000);
const diff = nextTick - Date.now();
const nextTick = new Date(cd.getTime() + 5 * 60 * 60 * 1000);
const diff = nextTick.getTime() - Date.now();
if (diff <= 0) { clearInterval(countdownInterval); fetchBoosters(); return; }
const h = Math.floor(diff / 3600000);
const m = Math.floor((diff % 3600000) / 60000);
@@ -49,7 +51,7 @@
}, 1000);
}
function delay(ms) { return new Promise(r => setTimeout(r, ms)); }
function delay(ms: number) { return new Promise(r => setTimeout(r, ms)); }
// Get the screen position of the idle pack so the overlay pack starts there
function getPackRect() {
@@ -71,6 +73,7 @@
await delay(600);
phase = 'ripping';
play('packRip');
await delay(900);
phase = 'dropping';
@@ -86,12 +89,14 @@
if (!res.ok) { phase = 'idle'; loading = false; return; }
cards = await res.json();
flippedCards = new Array(cards.length).fill(false);
boosters -= 1;
if (boosters < 5 && !countdown) { countdown = new Date(); startCountdown(); }
cardActions = cards.map(() => ({ favorited: false, tradeListed: false, shattered: false, shardGain: 0 }));
if (boosters !== null) boosters -= 1;
if (boosters !== null && boosters < 5 && !countdown) { countdown = new Date(); startCountdown(); }
phase = 'fanning';
await delay(50);
fanVisible = true;
play('packOpen');
await delay(800);
phase = 'flipping';
@@ -102,6 +107,7 @@
for (let i of indices) {
await delay(350);
play('cardFlip');
flippedCards = flippedCards.map((v, idx) => idx === i ? true : v);
}
@@ -113,13 +119,51 @@
phase = 'idle';
cards = [];
flippedCards = [];
cardActions = [];
fanVisible = false;
}
const FOIL_RARITIES = new Set(['super_rare', 'epic', 'legendary']);
// Per-card action states for the pack reveal
let cardActions: { favorited: boolean; tradeListed: boolean; shattered: boolean; shardGain: number }[] = $state([]);
async function packToggleFavorite(i: number) {
const card = cards[i];
const res = await apiFetch(`${API_URL}/cards/${card.id}/favorite`, { method: 'POST' });
if (res.ok) {
const data = await res.json();
cards[i] = { ...cards[i], is_favorite: data.is_favorite };
cardActions[i] = { ...cardActions[i], favorited: data.is_favorite };
}
}
async function packToggleTrade(i: number) {
const card = cards[i];
const res = await apiFetch(`${API_URL}/cards/${card.id}/willing-to-trade`, { method: 'POST' });
if (res.ok) {
const data = await res.json();
cards[i] = { ...cards[i], willing_to_trade: data.willing_to_trade };
cardActions[i] = { ...cardActions[i], tradeListed: data.willing_to_trade };
}
}
async function packShatter(i: number) {
const card = cards[i];
const res = await apiFetch(`${API_URL}/shards/shatter`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ card_ids: [card.id] }),
});
if (res.ok) {
const data = await res.json();
play('cardShatter');
cardActions[i] = { ...cardActions[i], shattered: true, shardGain: data.gained };
}
}
// Compute fan positions for each card
function fanStyle(i, total) {
function fanStyle(i: number, total: number) {
const isMobile = window.innerWidth <= 640;
if (isMobile) {
return `--tx: 0px; --ty: 0px;`;
@@ -136,9 +180,9 @@
<h1 class="pack-count">
{#if boosters !== null}{boosters}/5 BOOSTER PACKS REMAINING{/if}
</h1>
{#if boosters !== null && boosters < 5 && countdownDisplay}
<p class="countdown">{countdownDisplay} until next pack</p>
{/if}
<p class="countdown" class:invisible={!(boosters !== null && boosters < 5 && countdownDisplay)}>
{countdownDisplay || ''} until next pack
</p>
</div>
<!-- Idle pack -->
@@ -157,6 +201,11 @@
</div>
</button>
</div>
{:else if boosters !== null && boosters === 0 && phase === 'idle'}
<div class="no-packs">
<p class="no-packs-msg">No packs remaining</p>
<a href="/store" class="btn-buy-packs">Buy more packs</a>
</div>
{/if}
{#if phase !== 'idle'}
@@ -180,16 +229,44 @@
{#each cards as card, i}
{@const flipped = flippedCards[i]}
{@const foil = FOIL_RARITIES.has(card.card_rarity)}
{@const action = cardActions[i] ?? {}}
<div
class="fan-card"
class:fan-visible={fanVisible}
class:foil-reveal={flipped && foil}
style="--i: {i};"
>
<div class="card-flipper" class:flipped>
<div class="card-face back"><div class="card-back-face"></div></div>
<div class="card-face front"><Card {card} /></div>
<div class="card-shatter-wrap" class:shattered={action.shattered}>
<div class="card-flipper" class:flipped>
{#if !action.shattered}
<div class="card-face back"><div class="card-back-face"></div></div>
{/if}
<div class="card-face front"><Card {card} /></div>
</div>
</div>
<div class="pack-card-actions" class:actions-visible={phase === 'done' && flipped}>
{#if action.shattered}
<span class="shard-gained">+{action.shardGain}</span>
{:else}
<button
class="pack-action-btn fav"
class:active={action.favorited}
onclick={() => packToggleFavorite(i)}
title="Favorite"
>★</button>
<button
class="pack-action-btn trade"
class:active={action.tradeListed}
onclick={() => packToggleTrade(i)}
title="Mark for Trade"
>⇄</button>
<button
class="pack-action-btn shatter"
onclick={() => packShatter(i)}
title="Shatter for shards"
>◈</button>
{/if}
</div>
</div>
{/each}
</div>
@@ -204,12 +281,10 @@
</main>
<style>
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;700;900&family=Crimson+Text:ital,wght@0,400;0,600;1,400&display=swap');
main {
height: 100vh;
overflow: hidden;
background: #0d0a04;
background: var(--color-bg);
padding: 2rem;
display: flex;
flex-direction: column;
@@ -225,28 +300,67 @@
font-family: 'Cinzel', serif;
font-size: clamp(16px, 3vw, 26px);
font-weight: 900;
color: #f0d080;
color: var(--color-gold);
letter-spacing: 0.1em;
margin: 0 0 0.4rem;
}
.countdown {
font-family: 'Crimson Text', serif;
font-size: 15px;
font-size: var(--text-md);
font-style: italic;
color: rgba(240, 180, 80, 0.6);
color: var(--color-gold-dim);
margin: 0;
}
.invisible {
visibility: hidden;
}
.verify-notice {
font-family: 'Crimson Text', serif;
font-size: 18px;
font-size: var(--text-lg);
font-style: italic;
color: rgba(240, 180, 80, 0.55);
color: var(--color-gold-dim);
text-align: center;
margin-top: 4rem;
}
.no-packs {
display: flex;
flex-direction: column;
align-items: center;
gap: 1.2rem;
margin-top: 4rem;
}
.no-packs-msg {
font-family: 'Crimson Text', serif;
font-size: var(--text-lg);
font-style: italic;
color: var(--color-gold-dim);
margin: 0;
}
.btn-buy-packs {
font-family: 'Cinzel', serif;
font-size: var(--btn-font-lg);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--color-btn-text);
background: var(--color-bronze);
border: none;
border-radius: var(--radius-md);
padding: var(--btn-padding-lg);
text-decoration: none;
transition: background 0.15s ease;
}
.btn-buy-packs:hover {
background: var(--color-bronze-hover);
}
.pack-wrap {
display: flex;
justify-content: center;
@@ -340,7 +454,7 @@
position: fixed;
overflow: hidden;
inset: 0;
z-index: 200;
z-index: var(--z-dropdown);
background: rgba(0,0,0,0);
transition: background 0.6s ease;
display: flex;
@@ -432,6 +546,9 @@
transform: translateY(80vh);
transition: opacity 0.5s ease, transform 0.7s cubic-bezier(0.2, 0.8, 0.3, 1);
transition-delay: calc(var(--i) * 0.1s);
display: flex;
flex-direction: column;
align-items: center;
}
.fan-card.fan-visible {
@@ -463,6 +580,18 @@
transform: rotateY(180deg);
}
/* Wrapper holds the animation — kept separate from card-flipper so that
CSS filter doesn't conflict with transform-style: preserve-3d */
.card-shatter-wrap.shattered {
animation: shatter-fade 0.7s ease-out forwards;
}
@keyframes shatter-fade {
0% { opacity: 1; filter: brightness(1); transform: scale(1); }
30% { opacity: 1; filter: brightness(2.5) saturate(3) hue-rotate(160deg); transform: scale(1.04); }
100% { opacity: 0; filter: brightness(1); transform: scale(0.97); }
}
.card-face {
position: absolute;
inset: 0;
@@ -501,12 +630,13 @@
transform: translateX(-50%);
padding: 10px 32px;
background: rgba(60,30,5,0.85);
color: #f0d080;
border: 1.5px solid #c8861a;
border-radius: 6px;
color: var(--color-gold);
border: 1.5px solid var(--color-bronze);
border-radius: var(--radius-md);
font-family: 'Cinzel', serif;
font-size: 13px;
font-size: var(--text-base);
font-weight: 700;
text-transform: uppercase;
cursor: pointer;
letter-spacing: 0.08em;
transition: background 0.15s;
@@ -517,6 +647,81 @@
background: rgba(100,60,10,0.9);
}
/* ── Pack card actions ── */
.pack-card-actions {
display: flex;
gap: 8px;
justify-content: center;
align-items: center;
margin-top: 8px;
height: 44px;
visibility: hidden;
}
.pack-card-actions.actions-visible {
visibility: visible;
}
.pack-action-btn {
font-family: 'Cinzel', serif;
font-size: var(--text-lg);
font-weight: 700;
line-height: 1;
width: 44px;
height: 44px;
border-radius: var(--radius-full);
border: 1.5px solid;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s;
}
.pack-action-btn.fav {
background: rgba(30, 20, 0, 0.7);
border-color: rgba(200, 160, 0, 0.5);
color: rgba(240, 200, 0, 0.6);
}
.pack-action-btn.fav:hover, .pack-action-btn.fav.active {
background: rgba(60, 45, 0, 0.9);
border-color: #c8a000;
color: #f0c800;
}
.pack-action-btn.trade {
background: rgba(0, 25, 25, 0.7);
border-color: rgba(0, 150, 150, 0.5);
color: rgba(0, 190, 190, 0.6);
}
.pack-action-btn.trade:hover, .pack-action-btn.trade.active {
background: rgba(0, 50, 50, 0.9);
border-color: #00a0a0;
color: var(--color-cyan);
}
.pack-action-btn.shatter {
background: rgba(0, 20, 30, 0.7);
border-color: rgba(100, 200, 200, 0.4);
color: rgba(126, 207, 207, 0.6);
}
.pack-action-btn.shatter:hover {
background: rgba(0, 40, 50, 0.9);
border-color: var(--color-cyan);
color: var(--color-cyan);
}
.shard-gained {
font-family: 'Cinzel', serif;
font-size: var(--text-md);
font-weight: 700;
color: var(--color-cyan);
padding: 8px 16px;
background: rgba(0, 40, 50, 0.8);
border: 1.5px solid rgba(126, 207, 207, 0.5);
border-radius: 22px;
}
/* ── Mobile ── */
@media (max-width: 640px) {
.fan-wrap {
+116 -40
View File
@@ -1,4 +1,4 @@
<script>
<script lang="ts">
import { API_URL, WS_URL } from '$lib/api.js';
import { goto } from '$app/navigation';
@@ -12,6 +12,7 @@
function validate() {
if (!username.trim()) return 'Username is required';
if (username.length < 2) return 'Username must be at least 2 characters';
if (username.length > 16) return 'Username must be 16 characters or fewer';
if (mode === 'register') {
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return 'Please enter a valid email';
@@ -40,20 +41,18 @@
return;
}
const form = new FormData();
form.append('username', username);
form.append('password', password);
const res = await fetch(`${API_URL}/login`, {
method: 'POST',
body: form,
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ username, password }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.detail);
localStorage.setItem('token', data.access_token);
localStorage.setItem('refresh_token', data.refresh_token);
goto('/');
} catch (e) {
error = e.message;
} catch (e: any) {
error = e.message || 'Connection failed — check your network and try again';
} finally {
loading = false;
}
@@ -61,7 +60,15 @@
</script>
<main>
<div class="card-ghost" aria-hidden="true"></div>
<div class="card">
<div class="wordmark">WikiTCG</div>
<div class="rune-divider" aria-hidden="true">
<span class="line"></span>
<span class="rune"></span>
<span class="line"></span>
</div>
<h1>{mode === 'login' ? 'Sign In' : 'Register'}</h1>
<div class="fields">
@@ -103,33 +110,99 @@
</main>
<style>
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;700&family=Crimson+Text:ital,wght@0,400;0,600;1,400&display=swap');
main {
min-height: 100vh;
background: #0d0a04;
background: var(--color-bg);
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
}
/* Faint ghost card silhouettes in the background */
.card-ghost {
position: absolute;
right: 12%;
top: 50%;
transform: translateY(-50%) rotate(8deg);
width: 180px;
height: 260px;
border: 2px solid var(--color-gold);
border-radius: var(--radius-lg);
opacity: 0.04;
pointer-events: none;
animation: ghost-float 6s ease-in-out infinite;
}
.card-ghost::before {
content: '';
position: absolute;
left: calc(-100% - 60vw + 180px);
top: 0;
width: 100%;
height: 100%;
border: 2px solid var(--color-gold);
border-radius: var(--radius-lg);
transform: rotate(-12deg);
}
@keyframes ghost-float {
0%, 100% { transform: translateY(-50%) rotate(8deg); }
50% { transform: translateY(calc(-50% - 12px)) rotate(8deg); }
}
.card {
position: relative;
z-index: var(--z-base);
width: 340px;
background: #2e1c05;
border: 2px solid #6b4c1e;
border-radius: 12px;
background: var(--color-surface);
border: 2px solid var(--color-bronze);
border-radius: var(--radius-xl);
padding: 2rem;
display: flex;
flex-direction: column;
gap: 1.2rem;
}
.wordmark {
font-family: 'Cinzel', serif;
font-size: var(--text-xl);
font-weight: 900;
color: var(--color-gold);
text-align: center;
letter-spacing: 0.12em;
text-transform: uppercase;
margin-bottom: -0.4rem;
}
.rune-divider {
display: flex;
align-items: center;
gap: 10px;
}
.rune-divider .line {
flex: 1;
height: 1px;
background: var(--color-border-subtle);
}
.rune-divider .rune {
font-size: var(--text-xs);
color: rgba(240, 180, 80, 0.35);
flex-shrink: 0;
}
h1 {
font-family: 'Cinzel', serif;
font-size: 20px;
color: #f0d080;
font-size: var(--text-md);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--color-gold-dim);
text-align: center;
margin: 0;
margin: -0.4rem 0 0;
}
.fields {
@@ -141,39 +214,42 @@
input {
width: 100%;
padding: 9px 12px;
background: #1a1008;
border: 1.5px solid #8b6420;
border-radius: 6px;
color: #f0d080;
background: var(--color-surface);
border: 1.5px solid var(--color-border);
border-radius: var(--radius-md);
color: var(--color-gold);
font-family: 'Crimson Text', serif;
font-size: 15px;
font-size: var(--text-md);
box-sizing: border-box;
outline: none;
}
input::placeholder {
color: rgba(240, 180, 80, 0.4);
color: var(--color-gold-faint);
}
input:focus {
border-color: #f0d080;
border-color: var(--color-bronze);
}
button {
width: 100%;
padding: 10px;
background: #6b4c1e;
color: #f0d080;
border: 1.5px solid #8b6420;
border-radius: 6px;
padding: var(--btn-padding-lg);
background: var(--color-bronze);
color: var(--color-btn-text);
border: none;
border-radius: var(--radius-md);
font-family: 'Cinzel', serif;
font-size: 13px;
font-size: var(--btn-font-lg);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
cursor: pointer;
transition: background 0.15s;
}
button:hover:not(:disabled) {
background: #8b6420;
background: var(--color-bronze-hover);
}
button:disabled {
@@ -182,44 +258,44 @@
}
.error {
color: #c84040;
color: var(--color-error);
font-family: 'Crimson Text', serif;
font-size: 13px;
font-size: var(--text-base);
text-align: center;
margin: 0;
}
.toggle {
font-family: 'Crimson Text', serif;
font-size: 13px;
color: rgba(240, 180, 80, 0.7);
font-size: var(--text-base);
color: var(--color-gold-dim);
text-align: center;
margin: 0;
}
.link {
all: unset;
color: #f0d080;
color: var(--color-gold);
cursor: pointer;
text-decoration: underline;
width: auto;
padding: 0;
font-size: 13px;
font-size: var(--text-base);
font-family: 'Crimson Text', serif;
}
.link:hover {
color: #f0d080;
color: var(--color-gold);
}
.forgot-link {
font-family: 'Crimson Text', serif;
font-size: 13px;
color: rgba(245, 208, 96, 0.45);
font-size: var(--text-base);
color: var(--color-gold-faint);
text-align: center;
text-decoration: none;
transition: color 0.15s;
}
.forgot-link:hover { color: rgba(245, 208, 96, 0.8); }
.forgot-link:hover { color: var(--color-gold-muted); }
</style>
+524 -128
View File
@@ -1,11 +1,10 @@
<script>
<script lang="ts">
import { API_URL, WS_URL } from '$lib/api.js';
import { apiFetch } from '$lib/api.js';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import Card from '$lib/Card.svelte';
let allCards = $state([]);
let loading = $state(true);
const token = () => localStorage.getItem('token');
@@ -21,53 +20,106 @@
let filtersOpen = $state(false);
const RARITY_ORDER = Object.fromEntries(RARITIES.map((r, i) => [r, i]));
function label(str) {
function label(str: string) {
return str.charAt(0).toUpperCase() + str.slice(1).replace(/_/g, ' ');
}
let sortAsc = $state(true);
let costMin = $state(1);
let costMax = $state(10);
let searchQuery = $state('');
let favoritesOnly = $state(false);
let willingToTradeOnly = $state(false);
let filtered = $derived.by(() => {
const q = searchQuery.trim().toLowerCase();
let result = allCards.filter(c =>
selectedRarities.has(c.card_rarity) &&
selectedTypes.has(c.card_type) &&
c.cost >= costMin &&
c.cost <= costMax &&
(!q || c.name.toLowerCase().includes(q))
);
// Selection mode for bulk actions
let selectionMode = $state(false);
let selectedIds = $state(new Set<string>());
let bulkLoading = $state(false);
result = result.slice().sort((a, b) => {
let cmp = 0;
if (sortBy === 'name') cmp = a.name.localeCompare(b.name);
else if (sortBy === 'cost') cmp = b.cost - a.cost || a.name.localeCompare(b.name);
else if (sortBy === 'attack') cmp = b.attack - a.attack || a.name.localeCompare(b.name);
else if (sortBy === 'defense') cmp = b.defense - a.defense || a.name.localeCompare(b.name);
else if (sortBy === 'rarity') cmp = RARITY_ORDER[b.card_rarity] - RARITY_ORDER[a.card_rarity] || a.name.localeCompare(b.name);
return sortAsc ? cmp : -cmp;
// Server-side fetch state
const PAGE_SIZE = 40;
let cards: any[] = $state([]);
let total = $state(0);
let loadingMore = $state(false);
let hasMore = $derived(cards.length < total);
// Must be $state so the IntersectionObserver $effect re-runs when the element is bound
let sentinel: HTMLElement | undefined = $state();
// <main> has overflow-y: auto — it is the scroll container, not the viewport.
// The IntersectionObserver root must be the actual scroll container.
let scrollContainer: HTMLElement | undefined = $state();
// For non-name sorts the "natural" first click should show the highest/newest/rarest values,
// which is descending. This matches the old client-side behaviour (b.cost - a.cost etc.).
function sortDir() {
return sortBy === 'name'
? (sortAsc ? 'asc' : 'desc')
: (sortAsc ? 'desc' : 'asc');
}
async function fetchCards(reset = false) {
if (reset) { cards = []; total = 0; }
if (loadingMore) return;
loadingMore = true;
const params = new URLSearchParams({
skip: String(reset ? 0 : cards.length),
limit: String(PAGE_SIZE),
search: searchQuery.trim(),
cost_min: String(costMin),
cost_max: String(costMax),
favorites_only: String(favoritesOnly),
wtt_only: String(willingToTradeOnly),
sort_by: sortBy,
sort_dir: sortDir(),
});
for (const r of selectedRarities) params.append('rarities', r);
for (const t of selectedTypes) params.append('types', t);
const res = await apiFetch(`${API_URL}/cards?${params}`);
if (res.status === 401) { goto('/auth'); loadingMore = false; return; }
const data = await res.json();
cards = reset ? data.cards : [...cards, ...data.cards];
total = data.total;
loadingMore = false;
}
return result;
// Debounced refetch when any filter/sort state changes.
// Skip the initial run on mount — onMount handles the first fetch directly so
// there's no double-fetch (which would cause a visible flash as cards reset).
let mounted = false;
let fetchTimer: number;
$effect(() => {
searchQuery; sortBy; sortAsc; selectedRarities; selectedTypes;
costMin; costMax; favoritesOnly; willingToTradeOnly;
if (!mounted) return;
clearTimeout(fetchTimer);
fetchTimer = setTimeout(() => fetchCards(true), 300);
});
function toggleSort(val) {
// IntersectionObserver: load next page when sentinel scrolls into view.
// Both sentinel and scrollContainer are $state so this effect re-runs once bound.
$effect(() => {
if (!sentinel || !scrollContainer) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasMore && !loadingMore) fetchCards(false);
},
{ root: scrollContainer, rootMargin: '200px' }
);
observer.observe(sentinel);
return () => observer.disconnect();
});
function toggleSort(val: string) {
if (sortBy === val) sortAsc = !sortAsc;
else { sortBy = val; sortAsc = true; }
}
function toggleRarity(r) {
function toggleRarity(r: string) {
const s = new Set(selectedRarities);
s.has(r) ? s.delete(r) : s.add(r);
selectedRarities = s;
}
function toggleType(t) {
function toggleType(t: string) {
const s = new Set(selectedTypes);
s.has(t) ? s.delete(t) : s.add(t);
selectedTypes = s;
@@ -86,35 +138,65 @@
onMount(async () => {
if (!token()) { goto('/auth'); return; }
const res = await apiFetch(`${API_URL}/cards`);
if (res.status === 401) { goto('/auth'); return; }
allCards = await res.json();
await fetchCards(true);
loading = false;
mounted = true;
});
let selectedCard = $state(null);
let refreshStatus = $state(null);
let selectedCard: any = $state(null);
let refreshStatus: { can_refresh: boolean; next_refresh_at: string | null } | null = $state(null);
let countdownDisplay = $state('');
let countdownInterval = null;
let countdownInterval: number | undefined = undefined;
let reportLoading = $state(false);
let refreshLoading = $state(false);
let actionMessage = $state('');
let popupEl: HTMLElement;
let previousFocus: Element | null = null;
// Move focus to first focusable element when modal opens
$effect(() => {
if (selectedCard && popupEl) {
const first = popupEl.querySelector<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
first?.focus();
}
});
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape' && selectedCard) closeCard();
}
function trapFocus(e: KeyboardEvent) {
if (e.key !== 'Tab') return;
const focusable = [...popupEl.querySelectorAll<HTMLElement>(
'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
)];
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey) {
if (document.activeElement === first) { e.preventDefault(); last.focus(); }
} else {
if (document.activeElement === last) { e.preventDefault(); first.focus(); }
}
}
async function fetchRefreshStatus() {
const res = await apiFetch(`${API_URL}/profile/refresh-status`);
refreshStatus = await res.json();
if (!refreshStatus.can_refresh && refreshStatus.next_refresh_at) {
if (refreshStatus && !refreshStatus.can_refresh && refreshStatus.next_refresh_at) {
startRefreshCountdown(new Date(refreshStatus.next_refresh_at));
}
}
function startRefreshCountdown(nextRefreshAt) {
function startRefreshCountdown(nextRefreshAt: Date) {
clearInterval(countdownInterval);
countdownInterval = setInterval(() => {
const diff = nextRefreshAt - Date.now();
const diff = nextRefreshAt.getTime() - Date.now();
if (diff <= 0) {
clearInterval(countdownInterval);
refreshStatus = { can_refresh: true, next_refresh_at: null };
refreshStatus = { can_refresh: true, next_refresh_at: null } as typeof refreshStatus;
countdownDisplay = '';
return;
}
@@ -125,7 +207,54 @@
}, 1000);
}
function openCard(card) {
function toggleSelectionMode() {
selectionMode = !selectionMode;
if (!selectionMode) selectedIds = new Set();
}
function toggleCardSelection(id: string) {
const s = new Set(selectedIds);
s.has(id) ? s.delete(id) : s.add(id);
selectedIds = s;
}
function selectAllLoaded() {
if (cards.every(c => selectedIds.has(c.id))) {
selectedIds = new Set();
} else {
selectedIds = new Set(cards.map((c: any) => c.id));
}
}
async function bulkFavorite(setTo: boolean) {
bulkLoading = true;
const targets = cards.filter(c => selectedIds.has(c.id) && c.is_favorite !== setTo);
await Promise.all(targets.map(async (c: any) => {
const res = await apiFetch(`${API_URL}/cards/${c.id}/favorite`, { method: 'POST' });
if (res.ok) {
const data = await res.json();
cards = cards.map(x => x.id === c.id ? { ...x, is_favorite: data.is_favorite } : x);
}
}));
bulkLoading = false;
}
async function bulkWTT(setTo: boolean) {
bulkLoading = true;
const targets = cards.filter(c => selectedIds.has(c.id) && c.willing_to_trade !== setTo);
await Promise.all(targets.map(async (c: any) => {
const res = await apiFetch(`${API_URL}/cards/${c.id}/willing-to-trade`, { method: 'POST' });
if (res.ok) {
const data = await res.json();
cards = cards.map(x => x.id === c.id ? { ...x, willing_to_trade: data.willing_to_trade } : x);
}
}));
bulkLoading = false;
}
function openCard(card: any) {
if (selectionMode) return;
previousFocus = document.activeElement;
selectedCard = card;
actionMessage = '';
fetchRefreshStatus();
@@ -136,6 +265,8 @@
clearInterval(countdownInterval);
countdownDisplay = '';
actionMessage = '';
(previousFocus as HTMLElement)?.focus();
previousFocus = null;
}
async function reportCard() {
@@ -146,13 +277,31 @@
reportLoading = false;
if (res.ok) {
selectedCard = { ...selectedCard, reported: true };
allCards = allCards.map(c => c.id === selectedCard.id ? { ...c, reported: true } : c);
cards = cards.map(c => c.id === selectedCard.id ? { ...c, reported: true } : c);
actionMessage = 'Card reported. Thank you!';
} else {
actionMessage = 'Failed to report card.';
}
}
async function toggleFavorite() {
const res = await apiFetch(`${API_URL}/cards/${selectedCard.id}/favorite`, { method: 'POST' });
if (res.ok) {
const data = await res.json();
selectedCard = { ...selectedCard, is_favorite: data.is_favorite };
cards = cards.map(c => c.id === selectedCard.id ? { ...c, is_favorite: data.is_favorite } : c);
}
}
async function toggleWillingToTrade() {
const res = await apiFetch(`${API_URL}/cards/${selectedCard.id}/willing-to-trade`, { method: 'POST' });
if (res.ok) {
const data = await res.json();
selectedCard = { ...selectedCard, willing_to_trade: data.willing_to_trade };
cards = cards.map(c => c.id === selectedCard.id ? { ...c, willing_to_trade: data.willing_to_trade } : c);
}
}
async function refreshCard() {
refreshLoading = true;
actionMessage = '';
@@ -162,10 +311,9 @@
refreshLoading = false;
if (res.ok) {
const updated = await res.json();
// Update card in allCards list
allCards = allCards.map(c => c.id === updated.id ? updated : c);
cards = cards.map(c => c.id === updated.id ? updated : c);
selectedCard = updated;
refreshStatus = { can_refresh: false, next_refresh_at: null };
refreshStatus = { can_refresh: false, next_refresh_at: null } as typeof refreshStatus;
await fetchRefreshStatus();
actionMessage = 'Card refreshed!';
} else {
@@ -175,11 +323,13 @@
}
</script>
<main>
<svelte:window onkeydown={handleKeydown} />
<main bind:this={scrollContainer}>
<div class="toolbar">
<div class="sort-row">
<span class="toolbar-label">Sort by</span>
{#each [['name','Name'],['cost','Cost'],['attack','Attack'],['defense','Defense'],['rarity','Rarity']] as [val, lbl]}
{#each [['name','Name'],['cost','Cost'],['attack','Attack'],['defense','Defense'],['rarity','Rarity'],['date_generated','Generated'],['date_received','Received']] as [val, lbl]}
<button
class="sort-btn"
class:active={sortBy === val}
@@ -199,12 +349,32 @@
bind:value={searchQuery}
/>
<button class="filter-toggle" onclick={() => filtersOpen = !filtersOpen}>
{filtersOpen ? 'Hide filters' : 'Filter'}
{#if selectedRarities.size < RARITIES.length || selectedTypes.size < TYPES.length || costMin > 1 || costMax < 10}
<span class="filter-dot"></span>
{/if}
</button>
<div class="filter-actions">
<button
class="filter-toggle"
class:active={favoritesOnly}
onclick={() => favoritesOnly = !favoritesOnly}
title="Show favorites only"
>★</button>
<button
class="filter-toggle"
class:active={willingToTradeOnly}
onclick={() => willingToTradeOnly = !willingToTradeOnly}
title="Show willing to trade only"
>⇄</button>
<button class="filter-toggle" class:active={filtersOpen} onclick={() => filtersOpen = !filtersOpen}>
Filters
{#if selectedRarities.size < RARITIES.length || selectedTypes.size < TYPES.length || costMin > 1 || costMax < 10}
<span class="filter-dot"></span>
{/if}
</button>
<button class="filter-toggle" class:active={selectionMode} onclick={toggleSelectionMode}>
{selectionMode ? 'Done' : 'Select'}
</button>
</div>
</div>
{#if filtersOpen}
@@ -263,21 +433,53 @@
{#if loading}
<p class="status">Loading your cards...</p>
{:else if filtered.length === 0}
{:else if !loadingMore && cards.length === 0}
<p class="status">No cards match your filters.</p>
{:else}
<p class="card-count">{filtered.length} card{filtered.length === 1 ? '' : 's'}</p>
<div class="grid">
{#each filtered as card (card.id)}
<button class="card-btn" onclick={() => openCard(card)}>
<Card {card} />
</button>
<p class="card-count">{total} card{total === 1 ? '' : 's'}</p>
<div class="grid" style={selectionMode ? 'padding-bottom: 100px' : ''}>
{#each cards as card (card.id)}
<div class="card-item">
<button
class="card-btn"
class:selected={selectionMode && selectedIds.has(card.id)}
onclick={() => selectionMode ? toggleCardSelection(card.id) : openCard(card)}
>
<Card {card} noHover={selectionMode} />
{#if selectionMode && selectedIds.has(card.id)}
<div class="selected-badge"></div>
{/if}
</button>
{#if sortBy === 'date_received' && card.received_at}
<span class="received-label" aria-label="Date received">Received {new Date(card.received_at).toLocaleDateString('en-GB', { year: 'numeric', month: '2-digit', day: '2-digit' })}</span>
{/if}
</div>
{/each}
</div>
<div bind:this={sentinel} class="scroll-sentinel"></div>
{#if loadingMore}<p class="status">Loading more...</p>{/if}
{/if}
{#if selectionMode}
<div class="bulk-bar">
<span class="bulk-count">{selectedIds.size} card{selectedIds.size === 1 ? '' : 's'} selected</span>
<div class="bulk-actions">
<button class="bulk-btn" disabled={bulkLoading} onclick={() => bulkFavorite(true)}>★ Favorite</button>
<button class="bulk-btn" disabled={bulkLoading} onclick={() => bulkFavorite(false)}>★ Unfavorite</button>
<button class="bulk-btn" disabled={bulkLoading} onclick={() => bulkWTT(true)}>⇄ Mark WTT</button>
<button class="bulk-btn" disabled={bulkLoading} onclick={() => bulkWTT(false)}>⇄ Unmark WTT</button>
</div>
<div class="bulk-secondary">
<button class="bulk-select-all" onclick={selectAllLoaded}>
{cards.every(c => selectedIds.has(c.id)) ? 'Deselect All' : 'Select All'}
</button>
</div>
</div>
{/if}
{#if selectedCard}
<div class="backdrop" onclick={closeCard}>
<div class="card-popup" onclick={(e) => e.stopPropagation()}>
<div class="card-popup" onclick={(e) => e.stopPropagation()} onkeydown={trapFocus} bind:this={popupEl} role="dialog" aria-modal="true" aria-label="Card details">
<Card card={selectedCard} />
<div class="popup-actions">
<div class="action-col">
@@ -289,6 +491,24 @@
{selectedCard.reported ? 'Already Reported' : reportLoading ? 'Reporting...' : 'Report Error'}
</button>
</div>
<div class="action-col">
<button
class="fav-btn"
class:fav-active={selectedCard.is_favorite}
onclick={toggleFavorite}
>
{selectedCard.is_favorite ? '★ Favorited' : '★ Favorite'}
</button>
</div>
<div class="action-col">
<button
class="wtt-btn"
class:wtt-active={selectedCard.willing_to_trade}
onclick={toggleWillingToTrade}
>
{selectedCard.willing_to_trade ? '⇄ Listed for Trade' : '⇄ Mark for Trade'}
</button>
</div>
<div class="action-col">
<button
class="refresh-btn"
@@ -310,12 +530,10 @@
</main>
<style>
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;700&family=Crimson+Text:ital,wght@0,400;0,600;1,400&display=swap');
main {
height: 100vh;
overflow-y: auto;
background: #0d0a04;
background: var(--color-bg);
padding: 0 2rem 2rem 2rem;
}
@@ -323,9 +541,9 @@
position: sticky;
top: 0px;
z-index: 50;
background: #0d0a04;
background: var(--color-bg);
padding-bottom: 1rem;
border-bottom: 1px solid rgba(107, 76, 30, 0.3);
border-bottom: 1px solid var(--color-border-dim);
margin-bottom: 2rem;
padding-top: 32px;
}
@@ -339,23 +557,23 @@
.search-input {
font-family: 'Crimson Text', serif;
font-size: 15px;
background: rgba(255,255,255,0.04);
border: 1px solid rgba(107, 76, 30, 0.4);
border-radius: 4px;
color: #f0d080;
font-size: var(--text-md);
background: var(--color-surface);
border: 1.5px solid var(--color-bronze);
border-radius: var(--radius-md);
color: var(--color-gold);
padding: 5px 10px;
outline: none;
width: 220px;
margin-left: auto;
transition: border-color 0.15s;
}
.search-input:focus { border-color: #c8861a; }
.search-input:focus { border-color: var(--color-gold); }
.search-input::placeholder { color: rgba(240, 180, 80, 0.3); }
.toolbar-label {
font-family: 'Cinzel', serif;
font-size: 11px;
font-size: var(--text-sm);
color: rgba(240, 180, 80, 0.5);
letter-spacing: 0.08em;
text-transform: uppercase;
@@ -364,50 +582,61 @@
.sort-btn {
font-family: 'Cinzel', serif;
font-size: 11px;
font-size: var(--btn-font-sm);
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
background: none;
border: 1px solid rgba(107, 76, 30, 0.4);
border-radius: 4px;
color: rgba(240, 180, 80, 0.6);
padding: 4px 10px;
border: 1px solid var(--color-border-subtle);
border-radius: var(--radius-sm);
color: var(--color-gold-dim);
padding: var(--btn-padding-sm);
cursor: pointer;
transition: all 0.15s;
}
.sort-btn:hover {
border-color: #c8861a;
color: #f0d080;
border-color: var(--color-bronze);
color: var(--color-gold);
}
.sort-btn.active {
background: #3d2507;
border-color: #c8861a;
color: #f0d080;
background: var(--color-surface-raised);
border-color: var(--color-bronze);
color: var(--color-gold);
}
.filter-toggle {
font-family: 'Cinzel', serif;
font-size: 11px;
font-size: var(--btn-font-sm);
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
background: none;
border: 1px solid rgba(107, 76, 30, 0.4);
border-radius: 4px;
color: rgba(240, 180, 80, 0.6);
padding: 4px 10px;
border: 1px solid var(--color-border-subtle);
border-radius: var(--radius-sm);
color: var(--color-gold-dim);
padding: var(--btn-padding-sm);
cursor: pointer;
margin-left: 0.5rem;
position: relative;
transition: all 0.15s;
}
.filter-actions {
display: flex;
gap: 0.5rem;
margin-left: 0.75rem;
}
.filter-toggle:hover {
border-color: #c8861a;
color: #f0d080;
border-color: var(--color-bronze);
color: var(--color-gold);
}
.filter-toggle.active {
background: var(--color-surface-raised);
border-color: var(--color-bronze);
color: var(--color-gold);
}
.filter-dot {
@@ -416,8 +645,8 @@
right: -3px;
width: 7px;
height: 7px;
border-radius: 50%;
background: #c8861a;
border-radius: var(--radius-full);
background: var(--color-bronze);
}
.filters {
@@ -441,7 +670,7 @@
.filter-group-label {
font-family: 'Cinzel', serif;
font-size: 11px;
font-size: var(--text-sm);
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
@@ -450,7 +679,7 @@
.select-all {
font-family: 'Crimson Text', serif;
font-size: 12px;
font-size: var(--text-sm);
font-style: italic;
background: none;
border: none;
@@ -462,7 +691,7 @@
}
.select-all:hover {
color: #f0d080;
color: var(--color-gold);
}
.checkboxes {
@@ -476,23 +705,25 @@
align-items: center;
gap: 0.4rem;
font-family: 'Crimson Text', serif;
font-size: 14px;
color: rgba(240, 180, 80, 0.8);
font-size: var(--text-base);
color: var(--color-gold-muted);
cursor: pointer;
}
.checkbox-label input {
accent-color: #c8861a;
accent-color: var(--color-bronze);
width: 14px;
height: 14px;
cursor: pointer;
}
.scroll-sentinel { height: 1px; }
.card-count {
font-family: 'Crimson Text', serif;
font-size: 16px;
font-size: var(--text-md);
font-style: italic;
color: rgba(240, 180, 80, 0.4);
color: var(--color-gold-faint);
margin-bottom: 1.5rem;
}
@@ -506,7 +737,7 @@
.status {
font-family: 'Crimson Text', serif;
font-size: 16px;
font-size: var(--text-md);
font-style: italic;
color: rgba(240, 180, 80, 0.5);
text-align: center;
@@ -514,7 +745,7 @@
}
.sort-arrow {
font-size: 10px;
font-size: var(--text-xs);
margin-left: 3px;
}
@@ -526,21 +757,25 @@
.range-label {
font-family: 'Cinzel', serif;
font-size: 11px;
color: rgba(240, 180, 80, 0.7);
font-size: var(--text-sm);
color: var(--color-gold-dim);
min-width: 60px;
}
input[type=range] {
accent-color: #c8861a;
accent-color: var(--color-bronze);
width: 160px;
}
.card-item {
position: relative;
}
.card-btn {
all: unset;
cursor: pointer;
display: block;
border-radius: 12px;
border-radius: var(--radius-xl);
transition: transform 0.15s;
}
@@ -548,6 +783,26 @@
transform: translateY(-4px);
}
.received-label {
position: absolute;
top: 44px;
left: 50%;
transform: translateX(-50%);
white-space: nowrap;
pointer-events: none;
background: rgba(13, 10, 4, 0.88);
backdrop-filter: blur(6px);
border: 1px solid rgba(200, 134, 26, 0.55);
border-radius: 20px;
padding: 3px 12px;
font-size: var(--text-sm);
font-family: 'Cinzel', serif;
font-weight: 600;
letter-spacing: 0.07em;
color: var(--color-gold);
text-transform: uppercase;
}
.backdrop {
position: fixed;
inset: 0;
@@ -555,7 +810,7 @@
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
z-index: var(--z-header);
backdrop-filter: blur(6px);
}
@@ -565,12 +820,17 @@
flex-direction: column;
align-items: center;
gap: 1rem;
padding-top: 5rem;
}
.popup-actions {
display: flex;
gap: 1rem;
justify-content: center;
flex-direction: column;
gap: 0.5rem;
align-items: stretch;
width: 100%;
max-width: 260px;
margin: 0 auto;
}
.action-col {
@@ -578,37 +838,70 @@
flex-direction: column;
align-items: center;
gap: 0.4rem;
min-height: 60px;
}
.report-btn, .refresh-btn {
.fav-btn, .wtt-btn, .report-btn, .refresh-btn {
font-family: 'Cinzel', serif;
font-size: 11px;
font-size: var(--btn-font-md);
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
border-radius: 4px;
padding: 8px 18px;
border-radius: var(--radius-sm);
padding: var(--btn-padding-md);
cursor: pointer;
transition: background 0.15s;
width: 100%;
}
.fav-btn {
background: rgba(30, 20, 0, 0.6);
border: 1px solid rgba(200, 160, 0, 0.4);
color: rgba(240, 200, 0, 0.6);
}
.fav-btn:hover {
border-color: rgba(200, 160, 0, 0.8);
color: #f0c800;
}
.fav-btn.fav-active {
background: rgba(60, 45, 0, 0.8);
border-color: #c8a000;
color: #f0c800;
}
.wtt-btn {
background: rgba(0, 30, 30, 0.6);
border: 1px solid rgba(0, 160, 160, 0.4);
color: rgba(0, 200, 200, 0.6);
}
.wtt-btn:hover {
border-color: rgba(0, 180, 180, 0.8);
color: #7ecfcf;
}
.wtt-btn.wtt-active {
background: rgba(0, 50, 50, 0.8);
border-color: #00a0a0;
color: #7ecfcf;
}
.report-btn {
background: rgba(180, 60, 60, 0.5);
border: 1px solid rgba(240,250,240,0.8);
border: 1px solid rgba(200, 60, 60, 0.5);
color: white;
}
.report-btn:hover:not(:disabled) {
/* border-color: #c84040; */
color: #E0E0E0;
background: rgba(180, 40, 40, 0.9);
}
.refresh-btn {
background: #3d2507;
border: 1px solid #c8861a;
color: #f0d080;
background: var(--color-surface-raised);
border: 1px solid var(--color-bronze);
color: var(--color-gold);
}
.refresh-btn:hover:not(:disabled) {
@@ -622,16 +915,16 @@
.refresh-countdown {
font-family: 'Crimson Text', serif;
font-size: 12px;
font-size: var(--text-sm);
font-style: italic;
color: rgba(240, 180, 80, 0.5);
}
.action-message {
font-family: 'Crimson Text', serif;
font-size: 14px;
font-size: var(--text-base);
font-style: italic;
color: rgba(240, 180, 80, 0.7);
color: var(--color-gold-dim);
margin: 0;
min-height: 1.4em;
text-align: center;
@@ -639,15 +932,15 @@
.close-btn {
position: absolute;
top: -12px;
top: calc(5rem - 14px);
right: -12px;
width: 28px;
height: 28px;
border-radius: 50%;
background: #1a1008;
border: 1px solid #6b4c1e;
color: rgba(240, 180, 80, 0.7);
font-size: 12px;
border-radius: var(--radius-full);
background: var(--color-surface);
border: 1px solid var(--color-border);
color: var(--color-gold-dim);
font-size: var(--text-sm);
display: flex;
align-items: center;
justify-content: center;
@@ -656,7 +949,110 @@
}
.close-btn:hover {
border-color: #c8861a;
color: #f0d080;
border-color: var(--color-bronze);
color: var(--color-gold);
}
.card-btn.selected {
box-shadow: 0 0 0 3px var(--color-bronze), var(--shadow-glow);
}
.selected-badge {
position: absolute;
top: 80px;
left: 50%;
transform: translateX(-50%);
background: var(--color-bronze);
color: var(--color-btn-text);
font-family: 'Cinzel', serif;
font-size: var(--text-xl);
font-weight: 900;
padding: 4px 10px;
border-radius: 23px;
border: black 3px solid;
pointer-events: none;
z-index: var(--z-card);
}
.bulk-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 60;
background: var(--color-surface);
border-top: 1px solid var(--color-border);
padding: 12px 2rem;
display: flex;
align-items: center;
gap: 1rem;
flex-wrap: wrap;
box-shadow: 0 -4px 20px rgba(0,0,0,0.6);
}
.bulk-count {
font-family: 'Cinzel', serif;
font-size: var(--text-sm);
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--color-gold);
white-space: nowrap;
min-width: 100px;
}
.bulk-actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
flex: 1;
}
.bulk-btn {
font-family: 'Cinzel', serif;
font-size: var(--btn-font-md);
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
background: var(--color-surface-raised);
border: 1px solid rgba(107, 76, 30, 0.6);
border-radius: var(--radius-sm);
color: var(--color-gold);
padding: var(--btn-padding-md);
cursor: pointer;
transition: all 0.15s;
}
.bulk-btn:hover:not(:disabled) {
border-color: var(--color-bronze);
background: #5a3510;
color: var(--color-btn-text);
}
.bulk-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.bulk-secondary {
margin-left: auto;
}
.bulk-select-all {
font-family: 'Crimson Text', serif;
font-size: var(--text-base);
font-style: italic;
background: none;
border: none;
color: rgba(240, 180, 80, 0.5);
cursor: pointer;
padding: 0;
text-decoration: underline;
transition: color 0.15s;
white-space: nowrap;
}
.bulk-select-all:hover {
color: var(--color-gold);
}
</style>
+72 -91
View File
@@ -1,15 +1,15 @@
<script>
<script lang="ts">
import { API_URL, WS_URL } from '$lib/api.js';
import { apiFetch } from '$lib/api.js';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import DeckTypeBadge from '$lib/DeckTypeBadge.svelte';
let decks = $state([]);
let decks: any[] = $state([]);
let loading = $state(true);
let editConfirm = $state(null); // deck object pending edit confirmation
let deleteConfirm = $state(null); // deck object pending delete confirmation
let editConfirm: any = $state(null); // deck object pending edit confirmation
let deleteConfirm: any = $state(null); // deck object pending delete confirmation
const token = () => localStorage.getItem('token');
@@ -33,7 +33,7 @@
goto(`/decks/${deck.id}`);
}
function clickEdit(deck) {
function clickEdit(deck: any) {
if (deck.times_played > 0) {
editConfirm = deck;
} else {
@@ -41,7 +41,7 @@
}
}
function clickDelete(deck) {
function clickDelete(deck: any) {
deleteConfirm = deck;
}
@@ -53,7 +53,7 @@
deleteConfirm = null;
}
function winRate(deck) {
function winRate(deck: any) {
if (deck.times_played === 0) return null;
return Math.round((deck.wins / deck.times_played) * 100);
}
@@ -160,12 +160,10 @@
</main>
<style>
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;700&family=Crimson+Text:ital,wght@0,400;0,600;1,400&display=swap');
main {
height: 100vh;
overflow-y: auto;
background: #0d0a04;
background: var(--color-bg);
padding: 2rem;
}
@@ -174,29 +172,31 @@
align-items: baseline;
justify-content: space-between;
margin-bottom: 2rem;
border-bottom: 1px solid rgba(107, 76, 30, 0.3);
border-bottom: 1px solid var(--color-border-dim);
padding-bottom: 1rem;
}
.title {
font-family: 'Cinzel', serif;
font-size: 22px;
font-weight: 700;
color: #f0d080;
font-size: var(--text-xl);
font-weight: 900;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--color-gold);
margin: 0;
}
.new-btn {
font-family: 'Cinzel', serif;
font-size: 11px;
font-size: var(--btn-font-md);
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
background: #3d2507;
border: 1px solid #c8861a;
border-radius: 4px;
color: #f0d080;
padding: 6px 14px;
background: var(--color-surface-raised);
border: 1px solid var(--color-bronze);
border-radius: var(--radius-md);
color: var(--color-gold);
padding: var(--btn-padding-md);
cursor: pointer;
transition: background 0.15s;
}
@@ -210,16 +210,16 @@
}
thead tr {
border-bottom: 1px solid rgba(107, 76, 30, 0.5);
border-bottom: 1px solid var(--color-border-subtle);
}
th {
font-family: 'Cinzel', serif;
font-size: 10px;
font-size: var(--text-xs);
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: rgba(240, 180, 80, 0.4);
color: var(--color-gold-faint);
padding: 0 1rem 0.75rem 0;
text-align: left;
}
@@ -237,62 +237,43 @@
}
.deck-name {
font-size: 17px;
color: #e8d090;
font-size: var(--text-lg);
color: var(--color-gold);
}
.deck-count {
font-family: 'Cinzel', serif;
font-size: 13px;
font-size: var(--text-base);
font-weight: 700;
color: rgba(240, 180, 80, 0.6);
color: var(--color-gold-dim);
width: 60px;
}
.deck-cost {
font-family: 'Cinzel', serif;
font-size: 13px;
font-size: var(--text-base);
font-weight: 700;
color: #6aaa6a;
color: var(--color-success);
width: 60px;
}
.deck-cost.over-budget { color: #c85050; }
.deck-cost.over-budget { color: var(--color-error); }
.deck-type { width: 90px; }
.type-badge {
font-family: 'Cinzel', serif;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.05em;
padding: 3px 8px;
border-radius: 3px;
cursor: default;
}
.type-wall { background: rgba(58, 104, 120, 0.3); color: #a0d4e8; border: 1px solid rgba(58, 104, 120, 0.5); }
.type-aggro { background: rgba(139, 32, 32, 0.3); color: #e89090; border: 1px solid rgba(139, 32, 32, 0.5); }
.type-god-card { background: rgba(184, 120, 32, 0.3); color: #f5d880; border: 1px solid rgba(184, 120, 32, 0.5); }
.type-rush { background: rgba(74, 122, 80, 0.3); color: #a8dca8; border: 1px solid rgba(74, 122, 80, 0.5); }
.type-control { background: rgba(122, 80, 144, 0.3); color: #d0a0e8; border: 1px solid rgba(122, 80, 144, 0.5); }
.type-unplayable { background: rgba(60, 60, 60, 0.3); color: #909090; border: 1px solid rgba(60, 60, 60, 0.5); }
.type-pantheon { background: rgba(184, 150, 60, 0.3); color: #fce8a0; border: 1px solid rgba(184, 150, 60, 0.5); }
.type-balanced { background: rgba(106, 104, 96, 0.3); color: #c8c6c0; border: 1px solid rgba(106, 104, 96, 0.5); }
.deck-type { white-space: nowrap; }
.deck-stat {
font-family: 'Cinzel', serif;
font-size: 13px;
color: rgba(240, 180, 80, 0.6);
font-size: var(--text-base);
color: var(--color-gold-dim);
width: 60px;
}
.wins { color: #6aaa6a; }
.losses { color: #c85050; }
.wins { color: var(--color-success); }
.losses { color: var(--color-error); }
.separator { color: rgba(240, 180, 80, 0.3); }
.good-wr { color: #6aaa6a; }
.bad-wr { color: #c85050; }
.good-wr { color: var(--color-success); }
.bad-wr { color: var(--color-error); }
.no-data {
color: rgba(240, 180, 80, 0.2);
@@ -306,25 +287,25 @@
.edit-btn, .delete-btn {
font-family: 'Cinzel', serif;
font-size: 10px;
font-size: var(--btn-font-sm);
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
border-radius: 4px;
padding: 4px 10px;
border-radius: var(--radius-sm);
padding: var(--btn-padding-sm);
cursor: pointer;
transition: background 0.15s;
}
.edit-btn {
background: none;
border: 1px solid rgba(107, 76, 30, 0.5);
color: rgba(240, 180, 80, 0.7);
border: 1px solid var(--color-border-subtle);
color: var(--color-gold-dim);
}
.edit-btn:hover {
border-color: #c8861a;
color: #f0d080;
border-color: var(--color-bronze);
color: var(--color-gold);
}
.delete-btn {
@@ -334,13 +315,13 @@
}
.delete-btn:hover {
border-color: #c84040;
color: #e05050;
border-color: var(--color-error);
color: var(--color-error);
}
.status {
font-family: 'Crimson Text', serif;
font-size: 16px;
font-size: var(--text-md);
font-style: italic;
color: rgba(240, 180, 80, 0.5);
text-align: center;
@@ -354,13 +335,13 @@
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
z-index: var(--z-header);
}
.popup {
background: #1a1008;
border: 1px solid #6b4c1e;
border-radius: 10px;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: 2rem;
max-width: 400px;
width: calc(100% - 2rem);
@@ -371,22 +352,22 @@
.popup-title {
font-family: 'Cinzel', serif;
font-size: 18px;
font-size: var(--text-lg);
font-weight: 700;
color: #f0d080;
color: var(--color-gold);
margin: 0;
}
.popup-body {
font-family: 'Crimson Text', serif;
font-size: 15px;
color: rgba(240, 180, 80, 0.7);
font-size: var(--text-md);
color: var(--color-gold-dim);
margin: 0;
line-height: 1.6;
}
.popup-body strong {
color: #f0d080;
color: var(--color-gold);
}
.popup-actions {
@@ -397,52 +378,52 @@
.popup-cancel {
font-family: 'Cinzel', serif;
font-size: 11px;
font-size: var(--btn-font-md);
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
background: none;
border: 1px solid rgba(107, 76, 30, 0.4);
border-radius: 4px;
color: rgba(240, 180, 80, 0.6);
padding: 7px 16px;
border: 1px solid var(--color-border-subtle);
border-radius: var(--radius-sm);
color: var(--color-gold-dim);
padding: var(--btn-padding-md);
cursor: pointer;
transition: all 0.15s;
}
.popup-cancel:hover {
border-color: #c8861a;
color: #f0d080;
border-color: var(--color-bronze);
color: var(--color-gold);
}
.popup-confirm {
font-family: 'Cinzel', serif;
font-size: 11px;
font-size: var(--btn-font-md);
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
background: #c8861a;
background: var(--color-bronze);
border: none;
border-radius: 4px;
color: #fff8e0;
padding: 7px 16px;
border-radius: var(--radius-sm);
color: var(--color-btn-text);
padding: var(--btn-padding-md);
cursor: pointer;
transition: background 0.15s;
}
.popup-confirm:hover { background: #e09820; }
.popup-confirm:hover { background: var(--color-bronze-hover); }
.popup-delete {
font-family: 'Cinzel', serif;
font-size: 11px;
font-size: var(--btn-font-md);
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
background: rgba(180, 40, 40, 0.8);
border: none;
border-radius: 4px;
border-radius: var(--radius-sm);
color: #fff;
padding: 7px 16px;
padding: var(--btn-padding-md);
cursor: pointer;
transition: background 0.15s;
}
+88 -51
View File
@@ -1,4 +1,4 @@
<script>
<script lang="ts">
import { API_URL } from '$lib/api.js';
import { apiFetch } from '$lib/api.js';
import { goto } from '$app/navigation';
@@ -9,17 +9,18 @@
const deckId = $derived($page.params.id);
const token = () => localStorage.getItem('token');
let allCards = $state([]);
let selectedIds = $state(new Set());
let selectedCost = $state(0);
let costMap: Map<string, number> = $state(new Map());
let deckName = $state('');
let editingName = $state(false);
let nameInput = $state('');
let loading = $state(true);
let saving = $state(false);
let nameError = $state('');
let saveError = $state('');
const selectedCost = $derived(
allCards.filter(c => selectedIds.has(c.id)).reduce((sum, c) => sum + c.cost, 0)
);
const MAX_NAME = 64;
function startEditName() {
nameInput = deckName;
@@ -27,13 +28,20 @@
}
function commitName() {
if (nameInput.trim()) deckName = nameInput.trim();
const trimmed = nameInput.trim();
if (trimmed && trimmed.length <= MAX_NAME) {
deckName = trimmed;
nameError = '';
} else if (trimmed.length > MAX_NAME) {
nameError = `Name must be ${MAX_NAME} characters or fewer`;
}
editingName = false;
}
async function save() {
saving = true;
await apiFetch(`${API_URL}/decks/${deckId}`, {
saveError = '';
const res = await apiFetch(`${API_URL}/decks/${deckId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -42,26 +50,29 @@
}),
});
saving = false;
if (!res.ok) {
saveError = 'Failed to save deck. Please try again.';
return;
}
goto('/decks');
}
onMount(async () => {
if (!token()) { goto('/auth'); return; }
const [cardsRes, deckCardsRes] = await Promise.all([
apiFetch(`${API_URL}/cards`),
const [deckCardsRes, decksRes] = await Promise.all([
apiFetch(`${API_URL}/decks/${deckId}/cards`),
apiFetch(`${API_URL}/decks`),
]);
if (cardsRes.status === 401) { goto('/auth'); return; }
if (deckCardsRes.status === 401) { goto('/auth'); return; }
allCards = await cardsRes.json();
const currentCardIds = await deckCardsRes.json();
selectedIds = new Set(currentCardIds);
const deckCards = await deckCardsRes.json();
selectedIds = new Set(deckCards.map((c: any) => c.id));
costMap = new Map(deckCards.map((c: any) => [c.id, c.cost]));
const decksRes = await apiFetch(`${API_URL}/decks`);
const decks = await decksRes.json();
const deck = decks.find(d => d.id === deckId);
const deck = decks.find((d: any) => d.id === deckId);
deckName = deck?.name ?? 'Untitled Deck';
loading = false;
@@ -71,25 +82,31 @@
<main>
<div class="toolbar">
<div class="deck-header">
{#if editingName}
<input
class="name-input"
bind:value={nameInput}
onblur={commitName}
onkeydown={e => e.key === 'Enter' && commitName()}
autofocus
/>
{:else}
<button class="name-btn" onclick={startEditName}>{deckName} ✎</button>
{/if}
<div class="name-area">
{#if editingName}
<input
class="name-input"
bind:value={nameInput}
onblur={commitName}
onkeydown={e => e.key === 'Enter' && commitName()}
autofocus
/>
{:else}
<button class="name-btn" onclick={startEditName}>{deckName} ✎</button>
{/if}
<p class="field-error">{nameError}</p>
</div>
<div class="header-right">
<span class="card-counter" class:full={selectedCost === 50} class:over={selectedCost > 50} class:empty={selectedIds.size === 0}>
{selectedIds.size} cards · {selectedCost}/50
</span>
<button class="done-btn" onclick={save} disabled={saving}>
{saving ? 'Saving...' : 'Done'}
</button>
<div class="save-area">
<button class="done-btn" onclick={save} disabled={saving}>
{saving ? 'Saving...' : 'Done'}
</button>
<p class="field-error">{saveError}</p>
</div>
</div>
</div>
</div>
@@ -98,8 +115,9 @@
<p class="status">Loading...</p>
{:else}
<CardSelector
allCards={allCards}
bind:selectedIds={selectedIds}
bind:selectedCost={selectedCost}
bind:costMap={costMap}
costLimit={50}
showFooter={false}
/>
@@ -107,21 +125,19 @@
</main>
<style>
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;700&family=Crimson+Text:ital,wght@0,400;0,600;1,400&display=swap');
main {
height: 100vh;
height: 100%;
overflow: hidden;
background: #0d0a04;
background: var(--color-bg);
display: flex;
flex-direction: column;
}
.toolbar {
flex-shrink: 0;
background: #0d0a04;
background: var(--color-bg);
padding: 1.5rem 2rem 1rem;
border-bottom: 1px solid rgba(107, 76, 30, 0.3);
border-bottom: 1px solid var(--color-border-dim);
}
.deck-header {
@@ -133,9 +149,9 @@
.name-btn {
font-family: 'Cinzel', serif;
font-size: 18px;
font-size: var(--text-lg);
font-weight: 700;
color: #f0d080;
color: var(--color-gold);
background: none;
border: none;
cursor: pointer;
@@ -148,12 +164,12 @@
.name-input {
font-family: 'Cinzel', serif;
font-size: 18px;
font-size: var(--text-lg);
font-weight: 700;
color: #f0d080;
color: var(--color-gold);
background: transparent;
border: none;
border-bottom: 1.5px solid #c8861a;
border-bottom: 1.5px solid var(--color-bronze);
outline: none;
padding: 0 0 2px 0;
min-width: 200px;
@@ -167,27 +183,27 @@
.card-counter {
font-family: 'Cinzel', serif;
font-size: 14px;
font-size: var(--text-base);
font-weight: 700;
color: rgba(240, 180, 80, 0.7);
transition: color 0.2s;
}
.card-counter.full { color: #6aaa6a; }
.card-counter.over { color: #c85050; }
.card-counter.full { color: var(--color-success); }
.card-counter.over { color: var(--color-error); }
.card-counter.empty { color: rgba(240, 180, 80, 0.3); }
.done-btn {
font-family: 'Cinzel', serif;
font-size: 11px;
font-size: var(--btn-font-md);
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
background: #3d2507;
border: 1px solid #c8861a;
border-radius: 4px;
color: #f0d080;
padding: 6px 16px;
background: var(--color-surface-raised);
border: 1px solid var(--color-bronze);
border-radius: var(--radius-md);
color: var(--color-gold);
padding: var(--btn-padding-md);
cursor: pointer;
transition: background 0.15s;
}
@@ -195,9 +211,30 @@
.done-btn:hover:not(:disabled) { background: #5a3510; }
.done-btn:disabled { opacity: 0.5; cursor: default; }
.name-area {
display: flex;
flex-direction: column;
gap: 2px;
}
.save-area {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 2px;
}
.field-error {
font-family: 'Crimson Text', serif;
font-size: var(--text-sm);
color: var(--color-error);
margin: 0;
min-height: 1em;
}
.status {
font-family: 'Crimson Text', serif;
font-size: 16px;
font-size: var(--text-md);
font-style: italic;
color: rgba(240, 180, 80, 0.5);
text-align: center;
@@ -48,11 +48,9 @@
</main>
<style>
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;700&family=Crimson+Text:ital,wght@0,400;0,600;1,400&display=swap');
main {
min-height: 100vh;
background: #0d0a04;
background: var(--color-bg);
display: flex;
align-items: center;
justify-content: center;
@@ -61,9 +59,9 @@
.card {
width: 340px;
background: #2e1c05;
border: 2px solid #6b4c1e;
border-radius: 12px;
background: var(--color-surface);
border: 2px solid var(--color-bronze);
border-radius: var(--radius-xl);
padding: 2rem;
display: flex;
flex-direction: column;
@@ -72,16 +70,16 @@
.title {
font-family: 'Cinzel', serif;
font-size: 20px;
color: #f0d080;
font-size: var(--text-xl);
color: var(--color-gold);
text-align: center;
margin: 0;
}
.hint {
font-family: 'Crimson Text', serif;
font-size: 15px;
color: rgba(245, 208, 96, 0.7);
font-size: var(--text-md);
color: var(--color-gold-dim);
margin: 0;
text-align: center;
line-height: 1.6;
@@ -90,38 +88,41 @@
input {
width: 100%;
padding: 9px 12px;
background: #1a1008;
border: 1.5px solid #8b6420;
border-radius: 6px;
color: #f0d080;
background: var(--color-surface);
border: 1.5px solid var(--color-border);
border-radius: var(--radius-md);
color: var(--color-gold);
font-family: 'Crimson Text', serif;
font-size: 15px;
font-size: var(--text-md);
box-sizing: border-box;
outline: none;
}
input::placeholder {
color: rgba(240, 180, 80, 0.4);
color: var(--color-gold-faint);
}
input:focus { border-color: #f5d060; }
input:focus { border-color: var(--color-bronze); }
button {
width: 100%;
padding: 10px;
background: #6b4c1e;
color: #f0d080;
border: 1.5px solid #8b6420;
border-radius: 6px;
padding: var(--btn-padding-lg);
background: var(--color-bronze);
color: var(--color-btn-text);
border: none;
border-radius: var(--radius-md);
font-family: 'Cinzel', serif;
font-size: 13px;
font-size: var(--btn-font-lg);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
cursor: pointer;
transition: background 0.15s;
}
button:hover:not(:disabled) {
background: #8b6420;
background: var(--color-bronze-hover);
}
button:disabled {
@@ -131,19 +132,19 @@
.back-link {
font-family: 'Crimson Text', serif;
font-size: 14px;
color: rgba(245, 208, 96, 0.5);
font-size: var(--text-base);
color: var(--color-gold-faint);
text-align: center;
text-decoration: none;
transition: color 0.15s;
}
.back-link:hover { color: #f5d060; }
.back-link:hover { color: var(--color-gold); }
.error {
font-family: 'Crimson Text', serif;
font-size: 14px;
color: #f06060;
font-size: var(--text-base);
color: var(--color-error);
margin: 0;
min-height: 1.4em;
text-align: center;
@@ -71,11 +71,9 @@
</main>
<style>
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;700&family=Crimson+Text:ital,wght@0,400;0,600;1,400&display=swap');
main {
min-height: 100vh;
background: #0d0a04;
background: var(--color-bg);
display: flex;
align-items: center;
justify-content: center;
@@ -84,9 +82,9 @@
.card {
width: 380px;
background: #3d2507;
border: 2px solid #c8861a;
border-radius: 12px;
background: var(--color-surface);
border: 2px solid var(--color-bronze);
border-radius: var(--radius-xl);
padding: 2rem;
display: flex;
flex-direction: column;
@@ -95,17 +93,17 @@
.title {
font-family: 'Cinzel', serif;
font-size: 20px;
font-size: var(--text-xl);
font-weight: 700;
color: #f5d060;
color: var(--color-gold);
margin: 0;
text-align: center;
}
.hint {
font-family: 'Crimson Text', serif;
font-size: 15px;
color: rgba(245, 208, 96, 0.7);
font-size: var(--text-md);
color: var(--color-gold-dim);
margin: 0;
text-align: center;
line-height: 1.6;
@@ -119,52 +117,54 @@
.field-label {
font-family: 'Cinzel', serif;
font-size: 10px;
font-size: var(--text-xs);
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: rgba(245, 208, 96, 0.5);
color: var(--color-gold-faint);
}
input {
width: 100%;
padding: 9px 12px;
background: #221508;
border: 1.5px solid #c8861a;
border-radius: 6px;
color: #f5d060;
background: var(--color-surface);
border: 1.5px solid var(--color-border);
border-radius: var(--radius-md);
color: var(--color-gold);
font-family: 'Crimson Text', serif;
font-size: 15px;
font-size: var(--text-md);
box-sizing: border-box;
outline: none;
margin-bottom: 0.4rem;
}
input:focus { border-color: #f5d060; }
input::placeholder { color: rgba(245, 208, 96, 0.35); }
input:focus { border-color: var(--color-bronze); }
input::placeholder { color: var(--color-gold-faint); }
.btn {
width: 100%;
padding: 10px;
background: #c8861a;
color: #fff8e0;
padding: var(--btn-padding-lg);
background: var(--color-bronze);
color: var(--color-btn-text);
border: none;
border-radius: 6px;
border-radius: var(--radius-md);
font-family: 'Cinzel', serif;
font-size: 13px;
font-size: var(--btn-font-lg);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
cursor: pointer;
transition: background 0.15s;
display: block;
}
.btn:hover:not(:disabled) { background: #e09820; }
.btn:hover:not(:disabled) { background: var(--color-bronze-hover); }
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.error {
font-family: 'Crimson Text', serif;
font-size: 14px;
color: #f06060;
font-size: var(--text-base);
color: var(--color-error);
margin: 0;
min-height: 1.4em;
text-align: center;
+37 -236
View File
@@ -1,5 +1,5 @@
<script>
import { onMount } from 'svelte';
import Card from '$lib/Card.svelte';
// A fake card for display purposes
const exampleCard = {
@@ -12,7 +12,7 @@
attack: 351,
defense: 222,
cost: 5,
created_at: new Date().toISOString(),
generated_at: new Date().toISOString(),
reported: false,
};
@@ -48,34 +48,7 @@
<div class="card-explainer">
<div class="card-annotated">
<div class="card-display">
<!-- Inline card rendering matching Card.svelte visuals -->
<div class="demo-card">
<div class="demo-inner" style="--bg: #f0e0c8; --header: #b87830">
<div class="demo-header">
<span class="demo-name">{exampleCard.name}</span>
<span class="demo-type-badge">Person</span>
</div>
<div class="demo-image-wrap">
<img src={exampleCard.image_link} alt={exampleCard.name} class="demo-image" />
<div class="demo-rarity" style="background: #2a5a9b; color: #fff">R</div>
<a href="https://en.wikipedia.org/wiki/Harald_Bluetooth" target="_blank" rel="noopener" class="demo-wiki">
<svg viewBox="0 0 50 50" width="14" height="14"><circle cx="25" cy="25" r="24" fill="white" stroke="#888" stroke-width="1"/><text x="25" y="33" text-anchor="middle" font-family="serif" font-size="28" font-weight="bold" fill="#000">W</text></svg>
</a>
<div class="demo-cost-bubbles">
{#each { length: exampleCard.cost } as _}
<div class="demo-cost-bubble"></div>
{/each}
</div>
</div>
<div class="demo-divider"></div>
<div class="demo-text">{exampleCard.text}</div>
<div class="demo-footer" style="background: #e8d8b8">
<span class="demo-stat">ATK <strong>{exampleCard.attack}</strong></span>
<span class="demo-date">{new Date(exampleCard.created_at).toLocaleDateString()}</span>
<span class="demo-stat">DEF <strong>{exampleCard.defense}</strong></span>
</div>
</div>
</div>
<Card card={exampleCard} noHover={true} />
</div>
<!-- Annotation markers -->
@@ -195,12 +168,10 @@
</main>
<style>
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;700&family=Crimson+Text:ital,wght@0,400;0,600;1,400&display=swap');
main {
height: 100vh;
overflow-y: auto;
background: #0d0a04;
background: var(--color-bg);
}
.content {
@@ -211,11 +182,12 @@
.page-title {
font-family: 'Cinzel', serif;
font-size: 28px;
font-weight: 700;
color: #f0d080;
font-size: clamp(22px, 4vw, 32px);
font-weight: 900;
color: var(--color-gold);
margin: 0 0 2rem;
letter-spacing: 0.08em;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.section {
@@ -224,14 +196,14 @@
.section-title {
font-family: 'Cinzel', serif;
font-size: 14px;
font-size: var(--text-base);
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: #f0d080AA;
color: var(--color-gold-dim);
margin: 0 0 1.25rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid #f0d08055;
border-bottom: 1px solid var(--color-gold-faint);
}
.body-text ul {
@@ -240,8 +212,8 @@
.body-text {
font-family: 'Crimson Text', serif;
font-size: 17px;
color: rgba(240, 180, 80, 0.75);
font-size: var(--text-lg);
color: var(--color-gold-muted);
line-height: 1.7;
margin: 0 0 1rem;
}
@@ -272,23 +244,23 @@
.marker {
position: absolute;
transform: translate(-50%, -50%);
z-index: 10;
z-index: var(--z-card);
}
.marker-bubble {
width: 22px;
height: 22px;
border-radius: 50%;
background: #c8861a;
border: 2px solid #fff;
color: #fff;
background: var(--color-bronze);
border: 2px solid var(--color-btn-text);
color: var(--color-btn-text);
font-family: 'Cinzel', serif;
font-size: 11px;
font-size: var(--text-sm);
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 8px rgba(0,0,0,0.6);
box-shadow: var(--shadow-subtle);
}
/* ── Annotation list ── */
@@ -313,10 +285,10 @@
width: 22px;
height: 22px;
border-radius: 50%;
background: #c8861a;
color: #fff;
background: var(--color-bronze);
color: var(--color-btn-text);
font-family: 'Cinzel', serif;
font-size: 11px;
font-size: var(--text-sm);
font-weight: 700;
display: flex;
align-items: center;
@@ -333,16 +305,16 @@
.annotation-label {
font-family: 'Cinzel', serif;
font-size: 12px;
font-size: var(--text-sm);
font-weight: 700;
color: #f0d080;
color: var(--color-gold);
letter-spacing: 0.04em;
}
.annotation-desc {
font-family: 'Crimson Text', serif;
font-size: 14px;
color: rgba(240, 180, 80, 0.6);
font-size: var(--text-base);
color: var(--color-gold-dim);
line-height: 1.5;
}
@@ -350,13 +322,13 @@
.rules-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 12px;
gap: 0.75rem;
}
.rule-card {
background: #1a1008;
border: 1px solid rgba(107, 76, 30, 0.3);
border-radius: 8px;
background: var(--color-surface);
border: 1px solid var(--color-border-dim);
border-radius: var(--radius-lg);
padding: 1.25rem;
display: flex;
flex-direction: column;
@@ -364,198 +336,27 @@
}
.rule-icon {
color: #f0d080AA;
font-size: 20px;
color: var(--color-gold-dim);
font-size: var(--text-xl);
line-height: 1;
}
.rule-title {
font-family: 'Cinzel', serif;
font-size: 12px;
font-size: var(--text-sm);
font-weight: 700;
letter-spacing: 0.06em;
color: #f0d080;
color: var(--color-gold);
margin: 0;
}
.rule-body {
font-family: 'Crimson Text', serif;
font-size: 14px;
color: rgba(240, 180, 80, 0.6);
font-size: var(--text-base);
color: var(--color-gold-dim);
line-height: 1.55;
margin: 0;
}
/* ── Demo card ── */
.demo-card {
width: 300px;
border-radius: 12px;
padding: 7px;
background: #111;
border: 2px solid #111;
box-shadow: 0 4px 24px rgba(0,0,0,0.5);
font-family: 'Crimson Text', serif;
position: relative;
user-select: none;
}
.demo-inner {
border-radius: 8px;
overflow: hidden;
background: var(--bg);
border: 2px solid #000;
display: flex;
flex-direction: column;
}
.demo-header {
padding: 9px 12px 7px;
background: var(--header);
border-bottom: 2px solid #000;
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 8px;
}
.demo-name {
font-family: 'Cinzel', serif;
font-size: 13px;
font-weight: 700;
color: #fff;
text-shadow: 0 1px 3px rgba(0,0,0,0.6);
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.demo-type-badge {
font-family: 'Cinzel', serif;
font-size: 9px;
color: rgba(255,255,255,0.95);
text-transform: uppercase;
letter-spacing: 0.05em;
background: rgba(0,0,0,0.25);
padding: 1px 5px;
border-radius: 3px;
white-space: nowrap;
}
.demo-image-wrap {
position: relative;
width: 100%;
aspect-ratio: 4/3;
overflow: hidden;
border-bottom: 2px solid #000;
}
.demo-image {
width: 100%;
height: 100%;
object-fit: cover;
object-position: top;
display: block;
}
.demo-rarity {
position: absolute;
top: 7px;
left: 7px;
width: 26px;
height: 26px;
border-radius: 50%;
border: 2.5px solid #000;
font-family: 'Cinzel', serif;
font-size: 9px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
z-index: 3;
}
.demo-wiki {
position: absolute;
top: 7px;
right: 7px;
width: 26px;
height: 26px;
border-radius: 50%;
background: rgba(255,255,255,0.92);
border: 1.5px solid #000;
display: flex;
align-items: center;
justify-content: center;
z-index: 3;
}
.demo-cost-bubbles {
position: absolute;
bottom: 6px;
left: 8px;
display: flex;
gap: 3px;
flex-wrap: wrap;
max-width: calc(100% - 16px);
}
.demo-cost-bubble {
width: 16px;
height: 16px;
border-radius: 50%;
background: #6ea0ec;
border: 2.5px solid #000;
display: flex;
align-items: center;
justify-content: center;
color: #08152c;
font-size: 12px;
font-weight: 700;
font-family: 'Cinzel', serif;
line-height: 1;
}
.demo-divider {
height: 2px;
background: #000;
}
.demo-text {
padding: 10px 12px;
font-size: 13px;
line-height: 1.55;
color: #1a1208;
font-style: italic;
background: #f0e6cc;
border-bottom: 2px solid #000;
height: 110px;
overflow: hidden;
}
.demo-footer {
padding: 7px 12px;
display: flex;
justify-content: space-between;
align-items: center;
}
.demo-stat {
font-family: 'Cinzel', serif;
font-size: 11px;
color: #2a2010;
letter-spacing: 0.03em;
}
.demo-stat strong {
color: #000;
font-size: 15px;
}
.demo-date {
font-size: 10px;
color: rgba(0,0,0,0.5);
font-style: italic;
font-family: 'Crimson Text', serif;
}
</style>
+219 -120
View File
@@ -1,24 +1,30 @@
<script>
<script lang="ts">
import { API_URL, WS_URL } from '$lib/api.js';
import { apiFetch } from '$lib/api.js';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { get } from 'svelte/store';
import { onMount, onDestroy } from 'svelte';
import Card from '$lib/Card.svelte';
import DeckTypeBadge from '$lib/DeckTypeBadge.svelte';
import { play } from '$lib/audio.js';
const token = () => localStorage.getItem('token');
let queueWs = null;
let gameWs = null;
let queueWs: WebSocket | null = null;
let gameWs: WebSocket | null = null;
let phase = $state('idle');
let error = $state('');
let reconnecting = $state(false);
let gameReconnectDelay = 1000;
let gameReconnectTimer: number | undefined = undefined;
let decks = $state([]);
let decks: any[] = $state([]);
let selectedDeckId = $state('');
let selectedDeck = $derived(decks.find(d => d.id === selectedDeckId));
let gameId = $state('');
let gameState = $state(null);
let gameState: any = $state(null);
let myId = $state('');
let viewingBoard = $state(false);
@@ -34,7 +40,7 @@
'Expert'
);
let selectedHandIndex = $state(null);
let selectedHandIndex: number | null = $state(null);
let combatAnimating = $state(false);
let lunging = $state(new Set());
let lungingDown = $state(new Set());
@@ -46,24 +52,25 @@
let gameOver = $derived(!!gameState?.result);
let sacrificeMode = $state(false);
let displayedDefense = $state({});
let displayedDefense: Record<string, number> = $state({});
let destroying = $state(new Set());
let destroyed = $state(new Set());
let displayedLife = $state({});
let displayedLife: Record<string, number> = $state({});
const TURN_TIME_LIMIT = 120; // seconds
const TIMER_WARNING = 30; // show timer when this many seconds remain
let turnStartedAt = $state(null);
let turnStartedAt: Date | null = $state(null);
let secondsRemaining = $state(TURN_TIME_LIMIT);
let timerInterval = null
let timerInterval: number | undefined = undefined;
$effect(() => {
if (!gameState?.turn_started_at) return;
turnStartedAt = new Date(gameState.turn_started_at);
const ts = new Date(gameState.turn_started_at);
turnStartedAt = ts;
clearInterval(timerInterval);
timerInterval = setInterval(async () => {
const elapsed = (Date.now() - turnStartedAt) / 1000;
const elapsed = (Date.now() - ts.getTime()) / 1000;
secondsRemaining = Math.max(0, TURN_TIME_LIMIT - elapsed);
if (secondsRemaining <= 0 && !isMyTurn && gameState && !gameState.result) {
@@ -75,6 +82,7 @@
onDestroy(() => {
clearInterval(timerInterval);
clearTimeout(gameReconnectTimer);
});
async function claimTimeoutWin() {
@@ -103,7 +111,7 @@
...(gameState.you.board.filter(Boolean) || []),
...(gameState.opponent.board.filter(Boolean) || []),
];
const next = {};
const next: Record<string, number> = {};
for (const card of all) next[card.instance_id] = card.defense;
displayedDefense = next;
});
@@ -115,6 +123,21 @@
onMount(async () => {
if (!token()) { goto('/auth'); return; }
// Support joining a direct challenge game via ?game_id=... query param
const challengeGameId = get(page).url.searchParams.get('game_id');
if (challengeGameId) {
gameId = challengeGameId;
phase = 'playing';
connectToGame();
// Load decks in the background so the lobby is ready if the connection fails
apiFetch(`${API_URL}/decks`).then(r => r.json()).then(data => {
decks = data;
if (decks.length > 0) selectedDeckId = decks[0].id;
});
return;
}
const res = await apiFetch(`${API_URL}/decks`);
decks = await res.json();
if (decks.length > 0) selectedDeckId = decks[0].id;
@@ -130,12 +153,12 @@
error = '';
phase = 'queuing';
queueWs = new WebSocket(`${WS_URL}/ws/queue?deck_id=${selectedDeckId}`);
queueWs.onopen = () => queueWs.send(token());
queueWs.onopen = () => queueWs!.send(token()!);
queueWs.onmessage = (e) => {
const msg = JSON.parse(e.data);
if (msg.type === 'game_start') {
gameId = msg.game_id;
queueWs.close();
queueWs!.close();
connectToGame();
} else if (msg.type === 'error') {
error = msg.message;
@@ -147,7 +170,11 @@
function connectToGame() {
gameWs = new WebSocket(`${WS_URL}/ws/game/${gameId}`);
gameWs.onopen = () => gameWs.send(token());
gameWs.onopen = () => {
gameWs!.send(token()!);
reconnecting = false;
gameReconnectDelay = 1000;
};
gameWs.onmessage = async (e) => {
const msg = JSON.parse(e.data);
if (msg.type === 'state') {
@@ -165,6 +192,7 @@
phase = newState.result ? 'ended' : 'playing';
} else if (msg.type === 'sacrifice_animation') {
const id = msg.instance_id;
play('cardShatter');
destroying = new Set([...destroying, id]);
await delay(600);
destroying = new Set([...destroying].filter(i => i !== id));
@@ -174,10 +202,26 @@
setTimeout(() => error = '', 3000);
}
};
gameWs.onclose = (e) => {
// 1008 = Policy Violation — server rejects unknown/expired game_id.
// Fall back to the normal lobby and strip the stale query param.
if (e.code === 1008 && phase !== 'ended') {
phase = 'idle';
history.replaceState({}, '', '/play');
return;
}
if (phase === 'playing') {
reconnecting = true;
gameReconnectTimer = setTimeout(() => {
gameReconnectDelay = Math.min(gameReconnectDelay * 2, 30000);
connectToGame();
}, gameReconnectDelay);
}
};
gameWs.onerror = () => { error = 'Connection lost'; };
}
async function animateCombat(newState) {
async function animateCombat(newState: any) {
combatAnimating = true;
// The attacker is whoever was active when end_turn was called.
@@ -187,7 +231,7 @@
// active_player_id hasn't switched yet.
const attackerId = newState.result
? newState.active_player_id
: newState.player_order.find(id => id !== newState.active_player_id);
: newState.player_order.find((id: string) => id !== newState.active_player_id);
const attackerIsMe = attackerId === myId;
@@ -206,7 +250,11 @@
} else {
lungingDown = new Set([...lungingDown, attacker.instance_id]);
}
if (defender) shaking = new Set([...shaking, defender.instance_id]);
play('attack');
if (defender) {
shaking = new Set([...shaking, defender.instance_id]);
play('defend');
}
await delay(220);
if (defender) {
const newDefense = Math.max(0, (displayedDefense[defender.instance_id] ?? defender.defense) - attacker.attack);
@@ -222,6 +270,7 @@
lungingDown = new Set([...lungingDown].filter(id => id !== attacker.instance_id));
if (defender) shaking = new Set([...shaking].filter(id => id !== defender.instance_id));
if (defender && (displayedDefense[defender.instance_id] ?? defender.defense) <= 0) {
play('cardShatter');
destroying = new Set([...destroying, defender.instance_id]);
await delay(600);
destroying = new Set([...destroying].filter(id => id !== defender.instance_id));
@@ -234,25 +283,27 @@
combatAnimating = false;
}
function delay(ms) { return new Promise(r => setTimeout(r, ms)); }
function delay(ms: number) { return new Promise(r => setTimeout(r, ms)); }
function send(msg) { gameWs?.send(JSON.stringify(msg)); }
function send(msg: unknown) { gameWs?.send(JSON.stringify(msg)); }
function selectHandCard(index) {
function selectHandCard(index: number) {
if (!isMyTurn || combatAnimating) return;
selectedHandIndex = selectedHandIndex === index ? null : index;
}
function clickSlot(slot) {
function clickSlot(slot: number) {
if (!isMyTurn || combatAnimating || selectedHandIndex === null) return;
play('cardPlay');
send({ type: 'play_card', hand_index: selectedHandIndex, slot });
selectedHandIndex = null;
}
async function sacrifice(slot) {
async function sacrifice(slot: number) {
if (!isMyTurn || combatAnimating) return;
const card = me.board[slot];
if (!card) return;
play('cardShatter');
destroying = new Set([...destroying, card.instance_id]);
await delay(600);
destroying = new Set([...destroying].filter(id => id !== card.instance_id));
@@ -267,7 +318,7 @@
send({ type: 'end_turn' });
}
function handleHandCardMouseMove(e, node) {
function handleHandCardMouseMove(e: MouseEvent, node: HTMLElement) {
const rect = node.getBoundingClientRect();
const cy = rect.top + rect.height / 2;
const dy = (e.clientY - cy) / (rect.height / 2);
@@ -275,7 +326,7 @@
node.style.setProperty('--peek-y', `${ty}px`);
}
function handleHandCardMouseLeave(node) {
function handleHandCardMouseLeave(node: HTMLElement) {
node.style.setProperty('--peek-y', '0px');
}
@@ -337,10 +388,17 @@
{:else if (phase === 'playing' || (phase === 'ended' && viewingBoard)) && gameState}
<div class="game">
{#if reconnecting}
<div class="reconnecting-banner">Reconnecting...</div>
{/if}
<div class="sidebar left-sidebar">
<div class="sidebar-section top-section">
<div class="sidebar-name opp-name">{opp.username}</div>
{#if opp.user_id === 'ai'}
<span class="sidebar-name opp-name">{opp.username}</span>
{:else}
<a href="/profile/{opp.username}" target="_blank" class="sidebar-name opp-name opp-profile-link">{opp.username}</a>
{/if}
<DeckTypeBadge deckType={opp.deck_type} />
<div class="sidebar-life">{displayedLife[opp.user_id] ?? opp.life}</div>
<div class="sidebar-deck">Deck: {opp.deck_size}</div>
@@ -389,7 +447,7 @@
<div class="divider">
<span class="turn-indicator" class:my-turn={isMyTurn}>
{phase === 'ended' ? 'Game Ended' : isMyTurn ? 'Your turn' : `${opp.username}'s turn`}
{#if phase === 'ended'}Game Ended{:else if isMyTurn}Your turn{:else}{opp.username}'s turn{/if}
</span>
{#if secondsRemaining <= TIMER_WARNING}
<span class="turn-timer" class:urgent={secondsRemaining <= 10}>
@@ -436,7 +494,7 @@
<div class="sidebar right-sidebar">
{#if phase === 'ended'}
<button class="end-turn-btn" onclick={() => viewingBoard = false}>Go Back</button>
<button class="end-turn-btn" onclick={() => { viewingBoard = false; history.replaceState({}, '', '/play'); }}>Go Back</button>
{:else if isMyTurn && !combatAnimating}
<button class="end-turn-btn" onclick={endTurn}>End Turn</button>
{/if}
@@ -472,7 +530,7 @@
<p class="lobby-hint">{gameState.result.reason}</p>
<div class="lobby-buttons">
<button class="play-btn" onclick={() => viewingBoard = true}>View Board</button>
<button class="play-btn" onclick={() => { gameState = null; phase = 'idle'; }}>Go Back</button>
<button class="play-btn" onclick={() => { gameState = null; phase = 'idle'; history.replaceState({}, '', '/play'); }}>Go Back</button>
</div>
</div>
{/if}
@@ -506,12 +564,10 @@
</main>
<style>
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;700;900&family=Crimson+Text:ital,wght@0,400;0,600;1,400&display=swap');
main {
height: 100vh;
height: 100dvh;
overflow: hidden;
background: #0d0a04;
background: var(--color-bg);
display: flex;
flex-direction: column;
}
@@ -529,21 +585,21 @@
.lobby-title {
font-family: 'Cinzel', serif;
font-size: 32px;
font-size: var(--text-3xl);
font-weight: 900;
color: #f0d080;
color: var(--color-gold);
margin: 0;
letter-spacing: 0.1em;
}
.lobby-title.win { color: #6aaa6a; }
.lobby-title.lose { color: #c85050; }
.lobby-title.win { color: var(--color-success); }
.lobby-title.lose { color: var(--color-error); }
.how-to-play-link {
font-family: 'Crimson Text', serif;
font-size: 14px;
font-size: var(--text-base);
font-style: italic;
color: rgba(240, 180, 80, 0.4);
color: var(--color-gold-faint);
text-decoration: underline;
transition: color 0.15s;
margin-top: -1rem;
@@ -553,24 +609,24 @@
.lobby-hint {
font-family: 'Crimson Text', serif;
font-size: 16px;
font-size: var(--text-md);
font-style: italic;
color: rgba(240, 180, 80, 0.6);
color: var(--color-gold-dim);
margin: 0;
text-align: center;
}
.lobby-hint a { color: #f0d080; }
.lobby-hint a { color: var(--color-gold); }
.final-life {
font-family: 'Cinzel', serif;
font-size: 13px;
font-size: var(--text-base);
color: rgba(240, 180, 80, 0.5);
}
.deck-label {
font-family: 'Cinzel', serif;
font-size: 11px;
font-size: var(--text-sm);
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
@@ -583,11 +639,11 @@
select {
font-family: 'Crimson Text', serif;
font-size: 15px;
color: #f0d080;
background: #1a1008;
border: 1.5px solid #6b4c1e;
border-radius: 6px;
font-size: var(--text-md);
color: var(--color-gold);
background: var(--color-surface);
border: 1.5px solid var(--color-border);
border-radius: var(--radius-md);
padding: 8px 12px;
outline: none;
cursor: pointer;
@@ -596,27 +652,28 @@
.play-btn, .cancel-btn {
font-family: 'Cinzel', serif;
font-size: 13px;
font-size: var(--btn-font-lg);
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
padding: 10px 32px;
border-radius: 6px;
padding: var(--btn-padding-lg);
border-radius: var(--radius-md);
cursor: pointer;
transition: background 0.15s;
}
.play-btn {
background: #c8861a;
color: #fff8e0;
border: none;
background: var(--color-surface-raised);
color: var(--color-gold);
border: 1.5px solid var(--color-bronze);
}
.play-btn:hover:not(:disabled) { background: #e09820; }
.play-btn:hover:not(:disabled) { background: #5a3510; }
.play-btn:disabled {
background: #6b4c1e;
color: rgba(255, 248, 224, 0.4);
background: var(--color-surface-raised);
color: rgba(240, 180, 80, 0.3);
border-color: rgba(200, 134, 26, 0.3);
cursor: not-allowed;
}
@@ -626,34 +683,35 @@
}
.solo-btn {
background: #2a3d20;
border: 1px solid #5a8a40;
color: #a8d880;
background: none;
border: 1px solid rgba(107, 76, 30, 0.5);
color: rgba(240, 180, 80, 0.7);
}
.solo-btn:hover:not(:disabled) {
background: #3a5a2a;
border-color: var(--color-bronze);
color: var(--color-gold);
}
.solo-btn:disabled {
background: #1a2510;
color: rgba(168, 216, 128, 0.3);
background: none;
color: rgba(240, 180, 80, 0.25);
cursor: not-allowed;
border-color: #3a5a2a;
border-color: rgba(107, 76, 30, 0.2);
}
.cancel-btn {
background: none;
color: rgba(240, 180, 80, 0.6);
border: 1px solid rgba(107, 76, 30, 0.4);
color: var(--color-gold-dim);
border: 1px solid var(--color-border-subtle);
}
.cancel-btn:hover { border-color: #c8861a; color: #f0d080; }
.cancel-btn:hover { border-color: var(--color-bronze); color: var(--color-gold); }
.error {
font-family: 'Crimson Text', serif;
font-size: 14px;
color: #c85050;
font-size: var(--text-base);
color: var(--color-error);
margin: 0;
height: 1.4em;
margin-top: -1rem;
@@ -664,8 +722,8 @@
width: 40px;
height: 40px;
border: 3px solid rgba(200, 134, 26, 0.2);
border-top-color: #c8861a;
border-radius: 50%;
border-top-color: var(--color-bronze);
border-radius: var(--radius-full);
animation: spin 0.8s linear infinite;
}
@@ -673,6 +731,7 @@
/* ── Game layout ── */
.game {
position: relative;
flex: 1;
display: flex;
flex-direction: row;
@@ -717,7 +776,7 @@
.sidebar-name {
font-family: 'Cinzel', serif;
font-size: 14px;
font-size: var(--text-base);
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
@@ -727,18 +786,20 @@
}
.opp-name { color: rgba(200, 80, 80, 0.8); }
.you-name { color: #c8861a; }
.opp-profile-link { text-decoration: none; transition: color 0.15s; }
.opp-profile-link:hover { color: #e05050; }
.you-name { color: var(--color-bronze); }
.sidebar-life {
font-family: 'Cinzel', serif;
font-size: 30px;
font-size: var(--text-2xl);
font-weight: 700;
color: #f0d080;
color: var(--color-gold);
}
.sidebar-deck, .sidebar-hand {
font-family: 'Crimson Text', serif;
font-size: 16px;
font-size: var(--text-md);
font-style: italic;
color: rgba(240, 180, 80, 0.45);
}
@@ -752,14 +813,14 @@
.cost-bubble-display {
width: 22px;
height: 22px;
border-radius: 50%;
background: #6ea0ec;
border-radius: var(--radius-full);
background: var(--color-energy);
border: 2.5px solid #000;
display: flex;
align-items: center;
justify-content: center;
color: #08152c;
font-size: 13px;
font-size: var(--text-base);
font-weight: 700;
font-family: 'Cinzel', serif;
flex-shrink: 0;
@@ -767,9 +828,9 @@
.energy-count {
font-family: 'Cinzel', serif;
font-size: 18px;
font-size: var(--text-lg);
font-weight: 700;
color: #f0d080;
color: var(--color-gold);
}
.sacrifice-mode-btn {
@@ -777,7 +838,7 @@
color: rgba(107, 76, 30, 1);
border: 2px solid rgba(107, 76, 30, 1);
border-radius: 15px;
font-size: 16px;
font-size: var(--text-md);
cursor: pointer;
padding: 6px 5.5px;
line-height: 1;
@@ -786,32 +847,46 @@
}
.sacrifice-mode-btn:hover {
border-color: #c8861a;
border-color: var(--color-bronze);
background: rgba(200, 134, 26, 0.1);
}
.sacrifice-mode-btn.active {
background: rgba(180, 40, 40, 0.3);
border-color: #c84040;
border-color: var(--color-error);
}
.end-turn-btn {
font-family: 'Cinzel', serif;
font-size: 13px;
font-size: var(--btn-font-lg);
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
background: #c8861a;
background: var(--color-bronze);
border: none;
border-radius: 4px;
color: #fff8e0;
border-radius: var(--radius-md);
color: var(--color-btn-text);
padding: 10px 8px;
width: 100%;
cursor: pointer;
transition: background 0.15s;
margin-top: auto;
touch-action: manipulation;
}
.end-turn-btn:hover { background: #e09820; }
.end-turn-btn:hover { background: var(--color-bronze-hover); }
@media (max-width: 768px) {
.hand {
height: calc(400px * 0.55 + 1rem); /* ~221px instead of ~370px on mobile */
}
.end-turn-btn {
min-height: 44px;
font-size: var(--text-sm);
padding: 8px 6px;
}
}
.sacrifice-overlay {
position: absolute;
@@ -821,9 +896,9 @@
justify-content: center;
font-size: 48px;
background: rgba(180, 40, 40, 0.35);
border-radius: 10px;
border-radius: var(--radius-lg);
cursor: pointer;
z-index: 10;
z-index: var(--z-card);
transition: background 0.15s;
}
@@ -937,23 +1012,23 @@
width: calc(300px * 0.55);
height: calc(400px * 0.55);
border: 1.5px dashed rgba(107, 76, 30, 0.25);
border-radius: 10px;
border-radius: var(--radius-lg);
display: flex;
align-items: center;
justify-content: center;
transition: border-color 0.15s, background 0.15s;
z-index: 1;
z-index: var(--z-base);
}
.empty-slot.highlight {
border-color: #c8861a;
border-color: var(--color-bronze);
background: rgba(200, 134, 26, 0.08);
cursor: pointer;
}
.slot-hint {
font-family: 'Cinzel', serif;
font-size: 9px;
font-size: var(--text-xs);
color: rgba(200, 134, 26, 0.7);
text-transform: uppercase;
letter-spacing: 0.05em;
@@ -1010,14 +1085,14 @@
.turn-timer {
font-family: 'Cinzel', serif;
font-size: 11px;
font-size: var(--text-sm);
font-weight: 700;
color: rgba(240, 180, 80, 0.6);
color: var(--color-gold-dim);
transition: color 0.3s;
}
.turn-timer.urgent {
color: #c85050;
color: var(--color-error);
animation: pulse 0.8s ease-in-out infinite;
}
@@ -1028,7 +1103,7 @@
.turn-indicator {
font-family: 'Cinzel', serif;
font-size: 13px;
font-size: var(--text-base);
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
@@ -1036,7 +1111,7 @@
transition: color 0.3s;
}
.turn-indicator.my-turn { color: #c8861a; }
.turn-indicator.my-turn { color: var(--color-bronze); }
/* ── Hand ── */
.hand {
@@ -1048,7 +1123,7 @@
padding: 0.5rem;
overflow-x: auto;
overflow-y: hidden;
border-top: 1px solid rgba(107, 76, 30, 0.3);
border-top: 1px solid var(--color-border-dim);
background: rgba(0,0,0,0.3);
height: calc(400px * 0.9 + 1rem);
}
@@ -1081,19 +1156,19 @@
.hand-card:hover:not(:disabled) :global(.card) {
transform: scale(1.1) translate(-50px, calc(var(--peek-y) - 80px)) !important;
transform-origin: top left !important;
z-index: 50 !important;
z-index: 50;
}
.hand-card.selected {
/* transform: translateY(-16px); */
filter: drop-shadow(0 0 8px rgba(200, 134, 26, 0.9));
z-index: 25 !important;
z-index: 25;
}
.hand-card.selected :global(.card) {
/* transform: scale(1.) translate(-30px, calc(var(--peek-y) - 80px)) !important; */
/* transform-origin: top left !important; */
z-index: 50 !important;
z-index: 50;
filter: drop-shadow(0 0 8px rgba(200, 134, 26, 0.9));
}
@@ -1108,13 +1183,13 @@
display: flex;
align-items: center;
justify-content: center;
z-index: 200;
z-index: var(--z-modal);
}
.modal {
background: #110d04;
border: 1.5px solid #6b4c1e;
border-radius: 10px;
border: 1.5px solid var(--color-border);
border-radius: var(--radius-lg);
padding: 2rem 2.5rem;
display: flex;
flex-direction: column;
@@ -1125,9 +1200,9 @@
.modal-title {
font-family: 'Cinzel', serif;
font-size: 20px;
font-size: var(--text-xl);
font-weight: 700;
color: #f0d080;
color: var(--color-gold);
margin: 0;
letter-spacing: 0.08em;
}
@@ -1143,20 +1218,20 @@
font-family: 'Cinzel', serif;
font-size: 48px;
font-weight: 900;
color: #f0d080;
color: var(--color-gold);
line-height: 1;
}
.difficulty-label {
font-family: 'Crimson Text', serif;
font-size: 16px;
font-size: var(--text-md);
font-style: italic;
color: rgba(240, 180, 80, 0.6);
color: var(--color-gold-dim);
}
.difficulty-slider {
width: 100%;
accent-color: #c8861a;
accent-color: var(--color-bronze);
cursor: pointer;
}
@@ -1165,8 +1240,8 @@
display: flex;
justify-content: space-between;
font-family: 'Cinzel', serif;
font-size: 10px;
color: rgba(240, 180, 80, 0.4);
font-size: var(--text-xs);
color: var(--color-gold-faint);
margin-top: -0.75rem;
}
@@ -1185,10 +1260,34 @@
background: rgba(180, 40, 40, 0.9);
color: #fff;
font-family: 'Cinzel', serif;
font-size: 12px;
font-size: var(--text-sm);
padding: 8px 20px;
border-radius: 6px;
border-radius: var(--radius-md);
pointer-events: none;
z-index: 100;
z-index: var(--z-toast);
}
.reconnecting-banner {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: rgba(13, 10, 4, 0.85);
border: 1px solid rgba(200, 134, 26, 0.5);
border-radius: var(--radius-md);
padding: 10px 24px;
font-family: 'Cinzel', serif;
font-size: var(--text-base);
font-weight: 700;
letter-spacing: 0.08em;
color: var(--color-gold-muted);
z-index: 50;
pointer-events: none;
animation: fade-pulse 1.4s ease-in-out infinite;
}
@keyframes fade-pulse {
0%, 100% { opacity: 0.5; }
50% { opacity: 1; }
}
</style>
+591 -56
View File
@@ -1,11 +1,25 @@
<script>
<script lang="ts">
import { API_URL, WS_URL } from '$lib/api.js';
import { apiFetch } from '$lib/api.js';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
let profile = $state(null);
let profile: any = $state(null);
let loading = $state(true);
let wishlistText = $state('');
let wishlistSaving = $state(false);
let wishlistSaved = $state(false);
let wishlistError = $state('');
let friends: any[] = $state([]);
let proposals: any[] = $state([]);
let challenges: any[] = $state([]);
let confirmingRemove: Set<string> = $state(new Set());
let confirmingWithdraw: Set<string> = $state(new Set());
// Increments every second — passed to formatChallengeExpiry to force re-evaluation
let tick = $state(0);
const token = () => localStorage.getItem('token');
@@ -14,9 +28,81 @@
const res = await apiFetch(`${API_URL}/profile`);
if (res.status === 401) { goto('/auth'); return; }
profile = await res.json();
wishlistText = profile.trade_wishlist || '';
const friendsRes = await apiFetch(`${API_URL}/friends`);
if (friendsRes.ok) friends = await friendsRes.json();
const proposalsRes = await apiFetch(`${API_URL}/trade-proposals`);
if (proposalsRes.ok) proposals = await proposalsRes.json();
const challengesRes = await apiFetch(`${API_URL}/challenges`);
if (challengesRes.ok) challenges = await challengesRes.json();
loading = false;
const tickInterval = setInterval(() => { tick++; }, 1000);
const pollInterval = setInterval(async () => {
const [pRes, cRes] = await Promise.all([
apiFetch(`${API_URL}/trade-proposals`),
apiFetch(`${API_URL}/challenges`),
]);
if (pRes.ok) proposals = await pRes.json();
if (cRes.ok) challenges = await cRes.json();
}, 30_000);
return () => {
clearInterval(tickInterval);
clearInterval(pollInterval);
};
});
function formatExpiry(isoString: string) {
const d = new Date(isoString);
const diff = d.getTime() - Date.now();
if (diff < 0) return 'expired';
const hrs = Math.floor(diff / 3600000);
if (hrs < 1) return 'in < 1h';
if (hrs < 24) return `in ${hrs}h`;
return `in ${Math.floor(hrs / 24)}d`;
}
async function saveWishlist() {
wishlistSaving = true;
wishlistError = '';
wishlistSaved = false;
const res = await apiFetch(`${API_URL}/profile`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ trade_wishlist: wishlistText }),
});
wishlistSaving = false;
if (res.ok) {
wishlistSaved = true;
setTimeout(() => { wishlistSaved = false; }, 2500);
} else {
wishlistError = 'Failed to save.';
}
}
function formatChallengeExpiry(isoString: string, _tick?: number) {
const secs = Math.max(0, Math.floor((new Date(isoString).getTime() - Date.now()) / 1000));
if (secs <= 0) return 'expired';
if (secs < 60) return `${secs}s remaining`;
return `${Math.floor(secs / 60)}m ${secs % 60}s remaining`;
}
async function withdrawChallenge(challengeId: string) {
const res = await apiFetch(`${API_URL}/challenges/${challengeId}/decline`, { method: 'POST' });
if (res.ok) challenges = challenges.filter((c: any) => c.id !== challengeId);
}
async function removeFriend(friendshipId: string) {
await apiFetch(`${API_URL}/friendships/${friendshipId}`, { method: 'DELETE' });
friends = friends.filter((f: any) => f.friendship_id !== friendshipId);
}
function logout() {
localStorage.removeItem('token');
localStorage.removeItem('refresh_token');
@@ -69,7 +155,7 @@
<span class="shards-icon"></span>
<span class="shards-value">{profile.shards}</span>
<span class="shards-label">Shards</span>
<a href="/shards" class="shards-link">shatter cards</a>
<a href="/shatter" class="shards-link">shatter cards</a>
</div>
<div class="section-divider"></div>
@@ -98,6 +184,28 @@
<div class="section-divider"></div>
<div class="wishlist-group">
<h2 class="section-title">Trade Wishlist</h2>
<div class="wishlist-section">
<p class="wishlist-hint">Cards or types you're looking to trade for. Visible on your public profile.</p>
<textarea
class="wishlist-textarea"
bind:value={wishlistText}
placeholder="e.g. Looking for legendary locations, rare scientists..."
rows="3"
></textarea>
<div class="wishlist-actions">
<button class="save-btn" onclick={saveWishlist} disabled={wishlistSaving}>
{wishlistSaving ? 'Saving...' : 'Save'}
</button>
{#if wishlistSaved}<span class="wishlist-ok">Saved ✓</span>{/if}
<p class="wishlist-error" style="min-height: 1.2em">{wishlistError}</p>
</div>
</div>
</div>
<div class="section-divider"></div>
<h2 class="section-title">Highlights</h2>
<div class="highlights">
<div class="highlight-card">
@@ -129,17 +237,161 @@
</div>
</div>
<div class="section-divider"></div>
<h2 class="section-title">Friends</h2>
{#if friends.length === 0}
<p class="no-friends">No friends yet.</p>
{:else}
<ul class="friends-list">
{#each friends as f (f.friendship_id)}
<li class="friend-item">
<a href="/profile/{f.username}" class="friend-name">{f.username}</a>
{#if confirmingRemove.has(f.friendship_id)}
<span class="confirm-label">Remove friend?</span>
<button class="confirm-yes-btn" onclick={() => removeFriend(f.friendship_id)}>Confirm</button>
<button class="confirm-no-btn" onclick={() => { confirmingRemove.delete(f.friendship_id); confirmingRemove = confirmingRemove; }}>Cancel</button>
{:else}
<button class="unfriend-btn" onclick={() => { confirmingRemove.add(f.friendship_id); confirmingRemove = confirmingRemove; }}>Remove</button>
{/if}
</li>
{/each}
</ul>
{/if}
<div class="section-divider"></div>
<h2 class="section-title">Trade Proposals</h2>
{#if proposals.length === 0}
<p class="no-friends">No trade proposals.</p>
{:else}
{@const incoming = proposals.filter((p: any) => p.direction === 'incoming' && p.status === 'pending')}
{@const outgoing = proposals.filter((p: any) => p.direction === 'outgoing' && p.status === 'pending')}
{@const resolved = proposals.filter((p: any) => p.status !== 'pending')}
{#if incoming.length > 0}
<p class="proposal-subhead">Incoming</p>
{#each incoming as p (p.id)}
<div class="proposal-card">
<div class="proposal-meta">
<a href="/profile/{p.proposer_username}" target="_blank" class="friend-name">{p.proposer_username}</a>
<span class="proposal-status pending">Pending</span>
<span class="proposal-expires">{formatExpiry(p.expires_at)}</span>
</div>
<p class="proposal-desc">
{#if p.requested_cards.length > 0}Wants: <strong>{p.requested_cards.map((c: any) => c.name).join(', ')}</strong><br/>{/if}
{#if p.offered_cards.length > 0}Offering: {p.offered_cards.map((c: any) => c.name).join(', ')}{/if}
</p>
<a href="/trade/proposal/{p.id}" class="see-proposal-btn">See Proposal</a>
</div>
{/each}
{/if}
{#if outgoing.length > 0}
<p class="proposal-subhead">Outgoing</p>
{#each outgoing as p (p.id)}
<div class="proposal-card">
<div class="proposal-meta">
<span class="proposal-to">To: <a href="/profile/{p.recipient_username}" target="_blank" class="friend-name">{p.recipient_username}</a></span>
<span class="proposal-status pending">Pending</span>
<span class="proposal-expires">{formatExpiry(p.expires_at)}</span>
</div>
<p class="proposal-desc">
{#if p.requested_cards.length > 0}Requesting: <strong>{p.requested_cards.map((c: any) => c.name).join(', ')}</strong><br/>{/if}
{#if p.offered_cards.length > 0}Offering: {p.offered_cards.map((c: any) => c.name).join(', ')}{/if}
</p>
<a href="/trade/proposal/{p.id}" class="see-proposal-btn">See Proposal</a>
</div>
{/each}
{/if}
{#if resolved.length > 0}
<p class="proposal-subhead">History</p>
{#each resolved as p (p.id)}
<div class="proposal-card resolved">
<div class="proposal-meta">
<span class="proposal-to">{p.direction === 'incoming' ? `From: ${p.proposer_username}` : `To: ${p.recipient_username}`}</span>
<span class="proposal-status {p.status}">{p.status}</span>
</div>
<p class="proposal-desc">{p.requested_cards.length} requested · {p.offered_cards.length} offered</p>
<a href="/trade/proposal/{p.id}" class="see-proposal-btn">See Proposal</a>
</div>
{/each}
{/if}
{/if}
{#if challenges.length > 0}
<div class="section-divider"></div>
<h2 class="section-title">Game Challenges</h2>
{@const pendingOut = challenges.filter((c: any) => c.direction === 'outgoing' && c.status === 'pending')}
{@const pendingIn = challenges.filter((c: any) => c.direction === 'incoming' && c.status === 'pending')}
{@const resolvedC = challenges.filter((c: any) => c.status !== 'pending')}
{#if pendingOut.length > 0}
<p class="proposal-subhead">Sent</p>
{#each pendingOut as c (c.id)}
<div class="proposal-card">
<div class="proposal-meta">
<span class="proposal-to">To: <a href="/profile/{c.challenged_username}" target="_blank" class="friend-name">{c.challenged_username}</a></span>
<span class="proposal-status pending">Awaiting response</span>
<span class="proposal-expires">{formatChallengeExpiry(c.expires_at, tick)}</span>
</div>
<p class="proposal-desc">Deck: <strong>{c.deck_name}</strong></p>
{#if confirmingWithdraw.has(c.id)}
<span class="confirm-label">Withdraw challenge?</span>
<button class="confirm-yes-btn" onclick={() => withdrawChallenge(c.id)}>Confirm</button>
<button class="confirm-no-btn" onclick={() => { confirmingWithdraw.delete(c.id); confirmingWithdraw = confirmingWithdraw; }}>Cancel</button>
{:else}
<button class="withdraw-btn" onclick={() => { confirmingWithdraw.add(c.id); confirmingWithdraw = confirmingWithdraw; }}>Withdraw</button>
{/if}
</div>
{/each}
{/if}
{#if pendingIn.length > 0}
<p class="proposal-subhead">Incoming</p>
{#each pendingIn as c (c.id)}
<div class="proposal-card">
<div class="proposal-meta">
<a href="/profile/{c.challenger_username}" target="_blank" class="friend-name">{c.challenger_username}</a>
<span class="proposal-status pending">Pending</span>
<span class="proposal-expires">{formatChallengeExpiry(c.expires_at, tick)}</span>
</div>
<p class="proposal-desc">Their deck: <strong>{c.deck_name}</strong></p>
<p class="proposal-desc">Check your notification bell to accept.</p>
</div>
{/each}
{/if}
{#if resolvedC.length > 0}
<p class="proposal-subhead">History</p>
{#each resolvedC as c (c.id)}
<div class="proposal-card resolved">
<div class="proposal-meta">
<span class="proposal-to">
{c.direction === 'outgoing' ? `To: ${c.challenged_username}` : `From: ${c.challenger_username}`}
</span>
<span class="proposal-status {c.status}">{c.status}</span>
</div>
</div>
{/each}
{/if}
{/if}
</div>
{/if}
</main>
<style>
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;700&family=Crimson+Text:ital,wght@0,400;0,600;1,400&display=swap');
main {
height: 100vh;
overflow-y: auto;
background: #0d0a04;
background: var(--color-bg);
padding: 2rem;
}
@@ -149,6 +401,7 @@
display: flex;
flex-direction: column;
gap: 1.5rem;
padding-bottom: 5rem;
}
.profile-header {
@@ -160,16 +413,16 @@
.avatar {
width: 64px;
height: 64px;
border-radius: 50%;
background: #3d2507;
border: 2px solid #c8861a;
border-radius: var(--radius-full);
background: var(--color-surface-raised);
border: 2px solid var(--color-bronze);
display: flex;
align-items: center;
justify-content: center;
font-family: 'Cinzel', serif;
font-size: 28px;
font-size: var(--text-2xl);
font-weight: 700;
color: #f0d080;
color: var(--color-gold);
flex-shrink: 0;
}
@@ -182,22 +435,22 @@
.username {
font-family: 'Cinzel', serif;
font-size: 24px;
font-size: var(--text-xl);
font-weight: 700;
color: #f0d080;
color: var(--color-gold);
margin: 0;
}
.email {
font-family: 'Crimson Text', serif;
font-size: 15px;
font-size: var(--text-md);
color: rgba(240, 180, 80, 0.5);
margin: 0;
}
.unverified-badge {
font-family: 'Cinzel', serif;
font-size: 9px;
font-size: var(--text-xs);
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
@@ -211,7 +464,7 @@
.resend-btn {
font-family: 'Crimson Text', serif;
font-size: 13px;
font-size: var(--text-base);
font-style: italic;
color: rgba(240, 180, 80, 0.5);
background: none;
@@ -228,7 +481,7 @@
.joined {
font-family: 'Crimson Text', serif;
font-size: 13px;
font-size: var(--text-base);
font-style: italic;
color: rgba(240, 180, 80, 0.35);
margin: 0;
@@ -236,29 +489,29 @@
.logout-btn {
font-family: 'Cinzel', serif;
font-size: 11px;
font-size: var(--btn-font-md);
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
background: none;
border: 1px solid rgba(180, 60, 60, 0.4);
border-radius: 4px;
border-radius: var(--radius-sm);
color: rgba(200, 80, 80, 0.7);
padding: 8px 16px;
padding: var(--btn-padding-md);
cursor: pointer;
transition: all 0.15s;
align-self: flex-start;
}
.logout-btn:hover {
border-color: #c84040;
border-color: var(--color-error);
color: #e05050;
background: rgba(180, 40, 40, 0.1);
}
.reset-link {
font-family: 'Crimson Text', serif;
font-size: 14px;
font-size: var(--text-base);
font-style: italic;
color: rgba(240, 180, 80, 0.4);
text-decoration: underline;
@@ -276,38 +529,39 @@
.shards-link {
font-family: 'Cinzel', serif;
font-size: 10px;
font-size: var(--text-xs);
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: rgba(126, 207, 207, 0.6);
border: 1px solid rgba(126, 207, 207, 0.3);
border-radius: 4px;
border-radius: var(--radius-sm);
padding: 3px 8px;
text-decoration: none;
margin-top: 4px;
margin-left: 0.5rem;
transition: color 0.15s, border-color 0.15s;
}
.shards-link:hover { color: #7ecfcf; border-color: rgba(126, 207, 207, 0.7); }
.shards-link:hover { color: var(--color-cyan); border-color: rgba(126, 207, 207, 0.7); }
.shards-icon {
font-size: 22px;
color: #7ecfcf;
font-size: var(--text-xl);
color: var(--color-cyan);
position: relative;
top: -0.1em;
animation: shard-pulse 3s ease-in-out infinite;
}
.shards-value {
font-family: 'Cinzel', serif;
font-size: 28px;
font-size: var(--text-2xl);
font-weight: 700;
color: #7ecfcf;
color: var(--color-cyan);
}
.shards-label {
font-family: 'Cinzel', serif;
font-size: 11px;
font-size: var(--text-sm);
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
@@ -317,16 +571,16 @@
.section-divider {
height: 1px;
background: rgba(107, 76, 30, 0.3);
background: var(--color-border-dim);
}
.section-title {
font-family: 'Cinzel', serif;
font-size: 13px;
font-size: var(--text-base);
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: rgba(240, 180, 80, 0.4);
color: var(--color-gold-faint);
margin: 0;
}
@@ -337,9 +591,9 @@
}
.stat-card {
background: #1a1008;
border: 1px solid rgba(107, 76, 30, 0.3);
border-radius: 8px;
background: var(--color-surface);
border: 1px solid var(--color-border-dim);
border-radius: var(--radius-lg);
padding: 1rem;
display: flex;
flex-direction: column;
@@ -348,24 +602,24 @@
.stat-label {
font-family: 'Cinzel', serif;
font-size: 9px;
font-size: var(--text-xs);
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: rgba(240, 180, 80, 0.4);
color: var(--color-gold-faint);
}
.stat-value {
font-family: 'Cinzel', serif;
font-size: 28px;
font-size: var(--text-2xl);
font-weight: 700;
color: #f0d080;
color: var(--color-gold);
}
.wins { color: #6aaa6a; }
.losses { color: #c85050; }
.good-wr { color: #6aaa6a; }
.bad-wr { color: #c85050; }
.wins { color: var(--color-success); }
.losses { color: var(--color-error); }
.good-wr { color: var(--color-success); }
.bad-wr { color: var(--color-error); }
.highlights {
display: grid;
@@ -374,9 +628,9 @@
}
.highlight-card {
background: #1a1008;
border: 1px solid rgba(107, 76, 30, 0.3);
border-radius: 8px;
background: var(--color-surface);
border: 1px solid var(--color-border-dim);
border-radius: var(--radius-lg);
padding: 1rem;
display: flex;
flex-direction: column;
@@ -385,23 +639,23 @@
.highlight-label {
font-family: 'Cinzel', serif;
font-size: 9px;
font-size: var(--text-xs);
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: rgba(240, 180, 80, 0.4);
color: var(--color-gold-faint);
}
.highlight-value {
font-family: 'Cinzel', serif;
font-size: 16px;
font-size: var(--text-md);
font-weight: 700;
color: #f0d080;
color: var(--color-gold);
}
.highlight-sub {
font-family: 'Crimson Text', serif;
font-size: 13px;
font-size: var(--text-base);
font-style: italic;
color: rgba(240, 180, 80, 0.45);
}
@@ -418,8 +672,8 @@
height: 48px;
object-fit: cover;
object-position: top;
border-radius: 4px;
border: 1px solid rgba(107, 76, 30, 0.4);
border-radius: var(--radius-sm);
border: 1px solid var(--color-border-subtle);
flex-shrink: 0;
}
@@ -429,22 +683,303 @@
gap: 0.2rem;
}
.wishlist-group {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.wishlist-section {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.wishlist-hint {
font-family: 'Crimson Text', serif;
font-size: var(--text-base);
font-style: italic;
color: rgba(240, 180, 80, 0.35);
margin: 0;
}
.wishlist-textarea {
width: 100%;
background: var(--color-surface);
border: 1px solid var(--color-border-subtle);
border-radius: var(--radius-md);
color: var(--color-gold);
font-family: 'Crimson Text', serif;
font-size: var(--text-md);
padding: 0.6rem 0.75rem;
resize: vertical;
box-sizing: border-box;
transition: border-color 0.15s;
outline: none;
}
.wishlist-textarea:focus { border-color: var(--color-bronze); }
.wishlist-textarea::placeholder { color: rgba(240, 180, 80, 0.25); }
.wishlist-actions {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.save-btn {
font-family: 'Cinzel', serif;
font-size: var(--btn-font-md);
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
background: var(--color-surface-raised);
border: 1px solid var(--color-bronze);
border-radius: var(--radius-sm);
color: var(--color-btn-text);
padding: var(--btn-padding-md);
cursor: pointer;
transition: all 0.15s;
}
.save-btn:hover:not(:disabled) { background: #4d3010; }
.save-btn:disabled { opacity: 0.5; cursor: default; }
.wishlist-ok {
font-family: 'Crimson Text', serif;
font-size: var(--text-base);
color: var(--color-success);
}
.wishlist-error {
font-family: 'Crimson Text', serif;
font-size: var(--text-base);
color: var(--color-error);
margin: 0;
}
.no-friends {
font-family: 'Crimson Text', serif;
font-size: var(--text-base);
font-style: italic;
color: rgba(240, 180, 80, 0.25);
margin: 0;
}
.friends-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.friend-item {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.5rem 0.75rem;
background: var(--color-surface);
border: 1px solid var(--color-border-dim);
border-radius: var(--radius-md);
}
.friend-name {
font-family: 'Cinzel', serif;
font-size: var(--text-base);
font-weight: 700;
color: var(--color-gold);
text-decoration: none;
flex: 1;
transition: color 0.15s;
}
.friend-name:hover { color: var(--color-bronze); }
.unfriend-btn {
font-family: 'Cinzel', serif;
font-size: var(--btn-font-md);
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
background: rgba(180, 40, 40, 0.8);
border: none;
border-radius: var(--radius-md);
color: #fff;
padding: var(--btn-padding-md);
cursor: pointer;
transition: background 0.15s;
}
.unfriend-btn:hover { background: rgba(210, 50, 50, 0.9); }
.no-data {
font-family: 'Crimson Text', serif;
font-size: 14px;
font-size: var(--text-base);
font-style: italic;
color: rgba(240, 180, 80, 0.25);
}
/* ── Trade Proposals ── */
.proposal-subhead {
font-family: 'Cinzel', serif;
font-size: var(--text-xs);
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: rgba(240, 180, 80, 0.3);
margin: 0.5rem 0 0;
}
.proposal-card {
background: var(--color-surface);
border: 1px solid var(--color-border-dim);
border-radius: var(--radius-lg);
padding: 0.85rem 1rem;
display: flex;
flex-direction: column;
gap: 0.45rem;
}
.proposal-card.resolved { opacity: 0.5; }
.proposal-meta {
display: flex;
align-items: center;
gap: 0.6rem;
flex-wrap: wrap;
}
.proposal-to {
font-family: 'Crimson Text', serif;
font-size: var(--text-base);
color: rgba(240, 180, 80, 0.5);
}
.proposal-status {
font-family: 'Cinzel', serif;
font-size: var(--text-xs);
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
border-radius: 3px;
padding: 1px 5px;
border: 1px solid;
}
.proposal-status.pending { color: var(--color-bronze); border-color: rgba(200, 134, 26, 0.4); }
.proposal-status.accepted { color: var(--color-success); border-color: rgba(106, 170, 106, 0.4); }
.proposal-status.declined { color: var(--color-error); border-color: rgba(200, 64, 64, 0.4); }
.proposal-status.expired { color: rgba(240, 180, 80, 0.3); border-color: var(--color-border-dim); }
.proposal-status.withdrawn { color: rgba(240, 180, 80, 0.3); border-color: var(--color-border-dim); }
.proposal-expires {
font-family: 'Crimson Text', serif;
font-size: var(--text-sm);
font-style: italic;
color: rgba(240, 180, 80, 0.3);
margin-left: auto;
}
.proposal-desc {
font-family: 'Crimson Text', serif;
font-size: var(--text-base);
color: rgba(240, 180, 80, 0.65);
margin: 0;
line-height: 1.5;
}
.proposal-desc strong { color: var(--color-gold); font-style: normal; font-weight: 600; }
.see-proposal-btn {
font-family: 'Cinzel', serif;
font-size: var(--btn-font-md);
font-weight: 700;
letter-spacing: 0.07em;
text-transform: uppercase;
background: var(--color-surface-raised);
border: 1px solid var(--color-border-subtle);
border-radius: var(--radius-md);
color: var(--color-gold);
padding: var(--btn-padding-md);
text-decoration: none;
display: inline-block;
transition: all 0.15s;
margin-top: 0.25rem;
align-self: flex-start;
}
.see-proposal-btn:hover { border-color: var(--color-bronze); background: #4d3010; }
.withdraw-btn {
font-family: 'Cinzel', serif;
font-size: var(--btn-font-sm);
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
background: none;
border: 1px solid rgba(180, 60, 60, 0.4);
border-radius: var(--radius-sm);
color: rgba(200, 80, 80, 0.6);
padding: var(--btn-padding-sm);
cursor: pointer;
transition: all 0.15s;
align-self: flex-start;
}
.withdraw-btn:hover { border-color: var(--color-error); color: #e05050; }
.status {
font-family: 'Crimson Text', serif;
font-size: 16px;
font-size: var(--text-md);
font-style: italic;
color: rgba(240, 180, 80, 0.5);
text-align: center;
margin-top: 4rem;
}
.confirm-label {
font-family: 'Crimson Text', serif;
font-size: var(--text-base);
font-style: italic;
color: rgba(200, 80, 80, 0.8);
}
.confirm-yes-btn {
font-family: 'Cinzel', serif;
font-size: var(--btn-font-sm);
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
background: rgba(180, 40, 40, 0.8);
border: none;
border-radius: var(--radius-sm);
color: #fff;
padding: var(--btn-padding-sm);
cursor: pointer;
transition: background 0.15s;
}
.confirm-yes-btn:hover { background: rgba(210, 50, 50, 0.9); }
.confirm-no-btn {
font-family: 'Cinzel', serif;
font-size: var(--btn-font-sm);
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
background: none;
border: 1px solid var(--color-border-subtle);
border-radius: var(--radius-sm);
color: var(--color-gold-faint);
padding: var(--btn-padding-sm);
cursor: pointer;
transition: all 0.15s;
}
.confirm-no-btn:hover { border-color: rgba(107, 76, 30, 0.7); color: rgba(240, 180, 80, 0.7); }
@media (max-width: 640px) {
.stats-grid { grid-template-columns: repeat(2, 1fr); }
.highlights { grid-template-columns: 1fr; }
@@ -0,0 +1,832 @@
<script lang="ts">
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import { get } from 'svelte/store';
import { API_URL, apiFetch } from '$lib/api.js';
import Card from '$lib/Card.svelte';
// Cards shown when collapsed (1 visual row at scale 0.62)
const ROW_SIZE = 4;
function formatLastActive(iso: string | null): string {
if (!iso) return '';
const diff = Math.floor((Date.now() - new Date(iso + 'Z').getTime()) / 1000);
if (diff < 60) return 'Active just now';
if (diff < 3600) return `Active ${Math.floor(diff / 60)}m ago`;
if (diff < 86400) return `Active ${Math.floor(diff / 3600)}h ago`;
const days = Math.floor(diff / 86400);
if (days < 30) return `Active ${days}d ago`;
if (days < 365) return `Active ${Math.floor(days / 30)}mo ago`;
return `Active ${Math.floor(days / 365)}y ago`;
}
let profile: any = $state(null);
let loading = $state(true);
let notFound = $state(false);
let favExpanded = $state(false);
let wttExpanded = $state(false);
// 'idle' | 'pending' | 'pending_received' | 'friends' — friendship status with this user
let friendStatus: 'idle' | 'pending' | 'pending_received' | 'friends' = $state('idle');
let friendshipId: string | null = $state(null);
let isLoggedIn = $state(false);
let sendingFriendRequest = $state(false);
// Challenge state
let showChallengeModal = $state(false);
let challengeDecks: any[] = $state([]);
let selectedDeckId = $state('');
let challengeStatus: 'idle' | 'sending' | 'sent' | 'error' = $state('idle');
let challengeError = $state('');
let favSectionEl: HTMLElement | null = $state(null);
let wttSectionEl: HTMLElement | null = $state(null);
const visibleFav = $derived(
profile ? (favExpanded ? profile.favorite_cards : profile.favorite_cards.slice(0, ROW_SIZE)) : []
);
const visibleWtt = $derived(
profile ? (wttExpanded ? profile.wtt_cards : profile.wtt_cards.slice(0, ROW_SIZE)) : []
);
// Collapse without the page jumping: lock the section's viewport position before and after
function collapseSection(sectionEl: HTMLElement | null, setter: () => void) {
if (!sectionEl) { setter(); return; }
const beforeTop = sectionEl.getBoundingClientRect().top;
setter();
requestAnimationFrame(() => {
const afterTop = sectionEl.getBoundingClientRect().top;
window.scrollBy(0, afterTop - beforeTop);
});
}
async function sendFriendRequest() {
sendingFriendRequest = true;
try {
const res = await apiFetch(`${API_URL}/users/${profile.username}/friend-request`, { method: 'POST' });
if (res.ok) friendStatus = 'pending';
} finally {
sendingFriendRequest = false;
}
}
async function openChallengeModal() {
if (!challengeDecks.length) {
const res = await apiFetch(`${API_URL}/decks`);
if (res.ok) {
const data = await res.json();
challengeDecks = data.filter((d: any) => !d.deleted);
if (challengeDecks.length) selectedDeckId = challengeDecks[0].id;
}
}
challengeStatus = 'idle';
challengeError = '';
showChallengeModal = true;
}
async function sendChallenge() {
if (!selectedDeckId) return;
challengeStatus = 'sending';
challengeError = '';
const res = await apiFetch(`${API_URL}/users/${profile.username}/challenge`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ deck_id: selectedDeckId }),
});
if (res.ok) {
challengeStatus = 'sent';
} else {
const data = await res.json().catch(() => ({}));
challengeError = data.detail || 'Failed to send challenge';
challengeStatus = 'error';
}
}
async function removeFriendFromProfile() {
if (!friendshipId) return;
await apiFetch(`${API_URL}/friendships/${friendshipId}`, { method: 'DELETE' });
friendStatus = 'idle';
friendshipId = null;
}
onMount(async () => {
const username = get(page).params.username;
const token = localStorage.getItem('token');
const res = await fetch(`${API_URL}/users/${username}`);
if (res.status === 404) { notFound = true; loading = false; return; }
const data = await res.json();
// Redirect to own profile if logged-in user visits their own public page
if (token) {
try {
const meRes = await fetch(`${API_URL}/profile`, {
headers: { Authorization: `Bearer ${token}` }
});
if (meRes.ok) {
const me = await meRes.json();
if (me.username === username) { goto('/profile'); return; }
isLoggedIn = true;
// Check existing friendship status
const statusRes = await apiFetch(`${API_URL}/friendship-status/${username}`);
if (statusRes.ok) {
const s = await statusRes.json();
if (s.status === 'friends') { friendStatus = 'friends'; friendshipId = s.friendship_id; }
else if (s.status === 'pending_sent') { friendStatus = 'pending'; }
else if (s.status === 'pending_received') { friendStatus = 'pending_received'; friendshipId = s.friendship_id; }
}
}
} catch { /* non-critical */ }
}
profile = data;
loading = false;
});
</script>
{#if loading}
<main class="page">
<p class="status-text">Loading...</p>
</main>
{:else if notFound}
<main class="page">
<div class="not-found">
<div class="not-found-sigil"></div>
<h1 class="not-found-title">Unknown Adventurer</h1>
<p class="not-found-sub">No record found for this username.</p>
<a href="/" class="btn-secondary">Return Home</a>
</div>
</main>
{:else if profile}
<main class="page">
<div class="profile-wrap">
<!-- ═══ HEADER ═══ -->
<header class="profile-header">
<div class="avatar-col">
<div class="avatar">{profile.username[0].toUpperCase()}</div>
</div>
<div class="header-body">
<div class="ornament-line"></div>
<h1 class="username">{profile.username}</h1>
<div class="ornament-line"></div>
{#if profile.last_active_at}
<p class="last-active">{formatLastActive(profile.last_active_at)}</p>
{/if}
<div class="stats-row">
<div class="stat">
<span class="stat-num wins">{profile.wins}</span>
<span class="stat-label">Wins</span>
</div>
<div class="stat-sep">·</div>
<div class="stat">
<span class="stat-num losses">{profile.losses}</span>
<span class="stat-label">Losses</span>
</div>
<div class="stat-sep">·</div>
<div class="stat">
<span class="stat-num" class:good-wr={profile.win_rate !== null && profile.win_rate >= 50} class:bad-wr={profile.win_rate !== null && profile.win_rate < 50}>
{profile.win_rate !== null ? `${profile.win_rate}%` : '—'}
</span>
<span class="stat-label">Win Rate</span>
</div>
</div>
{#if isLoggedIn}
<div class="friend-actions">
{#if friendStatus === 'idle'}
<button class="btn-friend" onclick={sendFriendRequest} disabled={sendingFriendRequest}>{sendingFriendRequest ? 'Adding...' : '+ Add Friend'}</button>
{:else if friendStatus === 'pending'}
<span class="friend-pending">Request Sent</span>
{:else if friendStatus === 'pending_received'}
<span class="friend-pending">Sent you a request</span>
{:else if friendStatus === 'friends'}
<span class="friend-status">Friends</span>
<button class="btn-unfriend" onclick={removeFriendFromProfile}>Remove</button>
{/if}
</div>
{/if}
</div>
</header>
<!-- ═══ TRADE WISHLIST ═══ -->
{#if profile.trade_wishlist}
<section class="section">
<h2 class="section-title">Looking For</h2>
<blockquote class="wishlist-block">
{profile.trade_wishlist}
</blockquote>
</section>
{/if}
<!-- ═══ FAVORITES ═══ -->
{#if profile.favorite_cards?.length > 0}
{@const hasFavMore = profile.favorite_cards.length > ROW_SIZE}
<section class="section" bind:this={favSectionEl}>
<!-- Header always same height: collapse button invisible when not needed -->
<div class="section-header">
<h2 class="section-title">Favorites</h2>
<button
class="expand-btn"
style:visibility={hasFavMore && favExpanded ? 'visible' : 'hidden'}
onclick={() => collapseSection(favSectionEl, () => { favExpanded = false; })}
>Collapse ▲</button>
</div>
<div class="card-grid">
{#each visibleFav as card (card.id)}
<div class="card-wrap">
<div class="card-inner"><Card {card} /></div>
</div>
{/each}
</div>
{#if hasFavMore}
{#if !favExpanded}
<button class="expand-btn" onclick={() => { favExpanded = true; }}>
Show all {profile.favorite_cards.length} cards ▼
</button>
{:else}
<button class="expand-btn" onclick={() => collapseSection(favSectionEl, () => { favExpanded = false; })}>
Collapse ▲
</button>
{/if}
{/if}
</section>
{/if}
<!-- ═══ WILLING TO TRADE ═══ -->
{#if profile.wtt_cards?.length > 0}
{@const hasWttMore = profile.wtt_cards.length > ROW_SIZE}
<section class="section" bind:this={wttSectionEl}>
<div class="section-header">
<h2 class="section-title">Willing to Trade</h2>
<button
class="expand-btn"
style:visibility={hasWttMore && wttExpanded ? 'visible' : 'hidden'}
onclick={() => collapseSection(wttSectionEl, () => { wttExpanded = false; })}
>Collapse ▲</button>
</div>
<div class="card-grid">
{#each visibleWtt as card (card.id)}
<div class="card-wrap">
<div class="card-inner"><Card {card} /></div>
</div>
{/each}
</div>
{#if hasWttMore}
{#if !wttExpanded}
<button class="expand-btn" onclick={() => { wttExpanded = true; }}>
Show all {profile.wtt_cards.length} cards ▼
</button>
{:else}
<button class="expand-btn" onclick={() => collapseSection(wttSectionEl, () => { wttExpanded = false; })}>
Collapse ▲
</button>
{/if}
{/if}
</section>
{/if}
{#if isLoggedIn}
<div class="offer-cta">
<a href="/trade/offer/{profile.username}" class="btn-primary">⇄ Offer a Trade</a>
{#if challengeStatus === 'sent'}
<span class="challenge-sent">Challenge Sent ✦</span>
{:else}
<button class="btn-secondary" onclick={openChallengeModal}> Challenge to Play</button>
{/if}
</div>
{/if}
{#if showChallengeModal}
<div class="modal-backdrop" onclick={() => { showChallengeModal = false; }}>
<div class="modal" onclick={(e) => e.stopPropagation()}>
<div class="modal-icon"></div>
<h2 class="modal-title">Challenge {profile.username}</h2>
{#if challengeDecks.length === 0}
<p class="modal-body">You have no decks. Build a deck first.</p>
<button class="btn-secondary" onclick={() => { showChallengeModal = false; }}>Close</button>
{:else if challengeStatus === 'sent'}
<p class="modal-body">Your challenge has been sent. {profile.username} has 5 minutes to accept.</p>
<button class="btn-primary" onclick={() => { showChallengeModal = false; }}>Done</button>
{:else}
<p class="modal-body">Select a deck to battle with:</p>
<select class="deck-select" bind:value={selectedDeckId}>
{#each challengeDecks as deck}
<option value={deck.id}>{deck.name}</option>
{/each}
</select>
{#if challengeError}
<p class="challenge-error">{challengeError}</p>
{/if}
<div class="modal-actions">
<button class="btn-primary" onclick={sendChallenge} disabled={challengeStatus === 'sending'}>
{challengeStatus === 'sending' ? 'Sending...' : 'Send Challenge'}
</button>
<button class="btn-secondary" onclick={() => { showChallengeModal = false; }}>Cancel</button>
</div>
{/if}
</div>
</div>
{/if}
</div>
</main>
{/if}
<style>
.page {
height: 100vh;
background: var(--color-bg);
padding: 2rem;
overflow-y: auto;
box-sizing: border-box;
}
.profile-wrap {
max-width: 900px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 2.5rem;
padding-top: 1rem;
padding-bottom: 3rem;
}
/* ── Header ── */
.profile-header {
display: flex;
align-items: flex-start;
gap: 2rem;
}
.avatar-col { flex-shrink: 0; }
.avatar {
width: 80px;
height: 80px;
border-radius: var(--radius-full);
background: var(--color-surface-raised);
border: 2px solid var(--color-bronze);
display: flex;
align-items: center;
justify-content: center;
font-family: 'Cinzel', serif;
font-size: var(--text-3xl);
font-weight: 700;
color: var(--color-gold);
box-shadow: 0 0 20px rgba(200, 134, 26, 0.2), var(--shadow-card);
}
.header-body {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.ornament-line {
height: 1px;
background: var(--color-border-subtle);
}
.username {
font-family: 'Cinzel', serif;
font-size: var(--text-2xl);
font-weight: 700;
color: var(--color-gold);
margin: 0;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.last-active {
font-family: 'Crimson Text', serif;
font-size: var(--text-base);
font-style: italic;
color: rgba(240, 180, 80, 0.35);
margin: 0;
}
.stats-row {
display: flex;
align-items: center;
gap: 1rem;
padding-top: 0.25rem;
}
.stat {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.15rem;
}
.stat-num {
font-family: 'Cinzel', serif;
font-size: var(--text-xl);
font-weight: 700;
color: var(--color-gold);
line-height: 1;
}
.stat-label {
font-family: 'Cinzel', serif;
font-size: var(--text-xs);
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--color-gold-faint);
}
.stat-sep {
font-size: var(--text-lg);
color: var(--color-border-subtle);
align-self: center;
margin-top: -0.5rem;
}
.wins { color: var(--color-success); }
.losses { color: var(--color-error); }
.good-wr { color: var(--color-success); }
.bad-wr { color: var(--color-error); }
/* ── Friend actions ── */
.friend-actions {
display: flex;
align-items: center;
gap: 0.75rem;
margin-top: 0.5rem;
}
.btn-friend {
font-family: 'Cinzel', serif;
font-size: var(--btn-font-md);
font-weight: 700;
letter-spacing: 0.07em;
text-transform: uppercase;
background: var(--color-surface-raised);
border: 1px solid var(--color-bronze);
border-radius: var(--radius-sm);
color: var(--color-gold);
padding: var(--btn-padding-md);
cursor: pointer;
transition: background 0.15s;
}
.btn-friend:hover { background: #4d3010; }
.friend-pending {
font-family: 'Cinzel', serif;
font-size: var(--text-sm);
font-weight: 700;
letter-spacing: 0.07em;
text-transform: uppercase;
color: var(--color-gold-faint);
}
.friend-status {
font-family: 'Cinzel', serif;
font-size: var(--text-sm);
font-weight: 700;
letter-spacing: 0.07em;
text-transform: uppercase;
color: var(--color-success);
}
.btn-unfriend {
font-family: 'Cinzel', serif;
font-size: var(--btn-font-sm);
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
background: none;
border: 1px solid rgba(180, 60, 60, 0.4);
border-radius: var(--radius-sm);
color: rgba(200, 80, 80, 0.6);
padding: var(--btn-padding-sm);
cursor: pointer;
transition: all 0.15s;
}
.btn-unfriend:hover { border-color: #c84040; color: #e05050; }
/* ── Sections ── */
.section {
display: flex;
flex-direction: column;
gap: 1rem;
}
/* Header row: always the same height regardless of which buttons are visible */
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.section-title {
font-family: 'Cinzel', serif;
font-size: var(--text-base);
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--color-gold-dim);
margin: 0;
}
/* ── Wishlist ── */
.wishlist-block {
margin: 0;
padding: 1rem 1.5rem;
background: rgba(61, 37, 7, 0.25);
border-left: 3px solid rgba(200, 134, 26, 0.4);
border-radius: 0 var(--radius-lg) var(--radius-lg) 0;
font-family: 'Crimson Text', serif;
font-size: var(--text-lg);
font-style: italic;
color: var(--color-gold-muted);
line-height: 1.6;
}
/* ── Card grid ── */
.card-grid {
display: flex;
flex-wrap: wrap;
gap: 10px;
justify-content: flex-start;
align-items: flex-start;
}
/*
* Two-level card sizing:
* .card-wrap — controls layout footprint (width/height + margin compensation)
* .card-inner — controls visual scale from center (so hover grows symmetrically)
*
* card dimensions assumed: 300×420px at natural scale
* at scale 0.62: visual 186×260px
* margin-right = -(300 - 186) = -114px → next card starts 186+gap from here
* margin-bottom = -(420 - 260) = -160px → row height becomes 260px
*/
.card-wrap {
width: 300px;
height: 420px;
margin-right: -114px;
margin-bottom: -160px;
flex-shrink: 0;
position: relative;
z-index: 1;
/* Pointer events on the wrapper would cover the full 300×420px layout box,
most of which is empty space at scale 0.62. Delegate to card-inner only. */
pointer-events: none;
}
/* Elevate wrapper when the visible card inside is hovered */
.card-wrap:has(.card-inner:hover) {
z-index: 10;
}
.card-inner {
width: 100%;
height: 100%;
transform: scale(0.62);
transform-origin: top left;
transition: transform 0.2s ease;
pointer-events: auto;
}
/*
* Hover grows from the visual center, not the top-left corner.
* At scale 0.62, visual center = (300*0.62/2, 420*0.62/2) = (93px, 130px).
* At scale 0.78 from top-left, center would shift to (117px, 163px) — delta (+24, +33).
* Pre-translate by (-24px, -34px) to cancel that shift, keeping center fixed.
*/
.card-inner:hover {
transform: translate(-24px, -34px) scale(0.78);
}
/* ── Expand / Collapse buttons ── */
.expand-btn {
font-family: 'Cinzel', serif;
font-size: var(--btn-font-sm);
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: rgba(240, 180, 80, 0.5);
background: none;
border: 1px solid var(--color-border-subtle);
border-radius: var(--radius-sm);
padding: var(--btn-padding-sm);
cursor: pointer;
transition: color 0.15s, border-color 0.15s;
align-self: flex-start;
white-space: nowrap;
}
.expand-btn:hover {
color: var(--color-gold);
border-color: rgba(200, 134, 26, 0.6);
}
/* ── Offer CTA ── */
.offer-cta {
display: flex;
align-items: center;
gap: 1rem;
flex-wrap: wrap;
padding-top: 0.25rem;
}
.challenge-sent {
font-family: 'Cinzel', serif;
font-size: var(--text-sm);
font-weight: 700;
letter-spacing: 0.07em;
text-transform: uppercase;
color: var(--color-success);
}
.deck-select {
width: 100%;
background: var(--color-bg);
border: 1px solid var(--color-border-subtle);
border-radius: var(--radius-md);
color: var(--color-gold);
font-family: 'Crimson Text', serif;
font-size: var(--text-md);
padding: 8px 12px;
cursor: pointer;
}
.deck-select:focus { outline: none; border-color: var(--color-bronze); }
.challenge-error {
font-family: 'Crimson Text', serif;
font-size: var(--text-base);
color: var(--color-error);
margin: 0;
min-height: 1.2em;
}
.modal-actions {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
justify-content: center;
}
/* ── Buttons ── */
.btn-primary {
font-family: 'Cinzel', serif;
font-size: var(--btn-font-lg);
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
background: var(--color-bronze);
border: none;
border-radius: var(--radius-md);
color: var(--color-btn-text);
padding: var(--btn-padding-lg);
cursor: pointer;
transition: background 0.15s, transform 0.1s;
}
.btn-primary:hover { background: var(--color-bronze-hover); transform: translateY(-1px); }
.btn-primary:active { transform: translateY(0); }
a.btn-primary { text-decoration: none; }
.btn-secondary {
font-family: 'Cinzel', serif;
font-size: var(--btn-font-md);
font-weight: 700;
letter-spacing: 0.07em;
text-transform: uppercase;
background: var(--color-surface-raised);
border: 1px solid var(--color-border-subtle);
border-radius: var(--radius-md);
color: var(--color-gold);
padding: var(--btn-padding-md);
cursor: pointer;
text-decoration: none;
display: inline-block;
transition: all 0.15s;
}
.btn-secondary:hover { border-color: var(--color-bronze); color: var(--color-btn-text); }
/* ── States ── */
.status-text {
font-family: 'Crimson Text', serif;
font-size: var(--text-md);
font-style: italic;
color: var(--color-gold-faint);
text-align: center;
margin-top: 4rem;
}
.not-found {
max-width: 400px;
margin: 6rem auto 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
text-align: center;
}
.not-found-sigil { font-size: var(--text-3xl); color: rgba(107, 76, 30, 0.4); }
.not-found-title {
font-family: 'Cinzel', serif;
font-size: var(--text-xl);
font-weight: 700;
color: rgba(240, 180, 80, 0.5);
margin: 0;
letter-spacing: 0.08em;
}
.not-found-sub {
font-family: 'Crimson Text', serif;
font-size: var(--text-md);
font-style: italic;
color: rgba(240, 180, 80, 0.3);
margin: 0;
}
.empty-profile { padding: 2rem 0; text-align: center; }
.empty-text {
font-family: 'Crimson Text', serif;
font-size: var(--text-md);
font-style: italic;
color: rgba(240, 180, 80, 0.25);
margin: 0;
}
/* ── Modal ── */
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(5, 3, 0, 0.8);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
padding: 1rem;
}
.modal {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-xl);
padding: 2rem 2.5rem;
max-width: 400px;
width: 100%;
box-shadow: var(--shadow-elevated);
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
text-align: center;
}
.modal-icon { font-size: var(--text-3xl); color: rgba(200, 134, 26, 0.6); }
.modal-title {
font-family: 'Cinzel', serif;
font-size: var(--text-lg);
font-weight: 700;
letter-spacing: 0.08em;
color: var(--color-gold);
margin: 0;
}
.modal-body {
font-family: 'Crimson Text', serif;
font-size: var(--text-md);
font-style: italic;
color: var(--color-gold-dim);
line-height: 1.6;
margin: 0;
}
/* ── Responsive ── */
@media (max-width: 600px) {
.profile-header { flex-direction: column; align-items: flex-start; gap: 1rem; }
.username { font-size: var(--text-xl); }
/* Smaller scale on narrow screens (same translate trick: delta = 300*(0.68-0.55)/2 = 19.5px, 420*(0.68-0.55)/2 = 27.3px) */
.card-inner { transform: scale(0.55); }
.card-inner:hover { transform: translate(-20px, -27px) scale(0.68); }
.card-wrap {
margin-right: calc(-(300px - 300px * 0.55));
margin-bottom: calc(-(420px - 420px * 0.55));
}
}
</style>
+30 -30
View File
@@ -80,11 +80,9 @@
</main>
<style>
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;700&family=Crimson+Text:ital,wght@0,400;0,600;1,400&display=swap');
main {
min-height: 100vh;
background: #0d0a04;
background: var(--color-bg);
display: flex;
align-items: center;
justify-content: center;
@@ -93,9 +91,9 @@
.card {
width: 380px;
background: #3d2507;
border: 2px solid #c8861a;
border-radius: 12px;
background: var(--color-surface);
border: 2px solid var(--color-bronze);
border-radius: var(--radius-xl);
padding: 2rem;
display: flex;
flex-direction: column;
@@ -104,17 +102,17 @@
.title {
font-family: 'Cinzel', serif;
font-size: 20px;
font-size: var(--text-xl);
font-weight: 700;
color: #f5d060;
color: var(--color-gold);
margin: 0;
text-align: center;
}
.hint {
font-family: 'Crimson Text', serif;
font-size: 15px;
color: rgba(245, 208, 96, 0.7);
font-size: var(--text-md);
color: var(--color-gold-dim);
margin: 0;
text-align: center;
}
@@ -127,63 +125,65 @@
.field-label {
font-family: 'Cinzel', serif;
font-size: 10px;
font-size: var(--text-xs);
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: rgba(245, 208, 96, 0.5);
color: var(--color-gold-faint);
}
input {
width: 100%;
padding: 9px 12px;
background: #221508;
border: 1.5px solid #c8861a;
border-radius: 6px;
color: #f5d060;
background: var(--color-surface);
border: 1.5px solid var(--color-border);
border-radius: var(--radius-md);
color: var(--color-gold);
font-family: 'Crimson Text', serif;
font-size: 15px;
font-size: var(--text-md);
box-sizing: border-box;
outline: none;
margin-bottom: 0.4rem;
}
input:focus { border-color: #f5d060; }
input::placeholder { color: rgba(245, 208, 96, 0.35); }
input:focus { border-color: var(--color-bronze); }
input::placeholder { color: var(--color-gold-faint); }
.btn {
width: 100%;
padding: 10px;
background: #c8861a;
color: #fff8e0;
padding: var(--btn-padding-lg);
background: var(--color-bronze);
color: var(--color-btn-text);
border: none;
border-radius: 6px;
border-radius: var(--radius-md);
font-family: 'Cinzel', serif;
font-size: 13px;
font-size: var(--btn-font-lg);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
cursor: pointer;
transition: background 0.15s;
}
.btn:hover:not(:disabled) { background: #e09820; }
.btn:hover:not(:disabled) { background: var(--color-bronze-hover); }
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.back-link {
all: unset;
font-family: 'Crimson Text', serif;
font-size: 14px;
color: rgba(245, 208, 96, 0.5);
font-size: var(--text-base);
color: var(--color-gold-faint);
cursor: pointer;
text-align: center;
transition: color 0.15s;
}
.back-link:hover { color: #f5d060; }
.back-link:hover { color: var(--color-gold); }
.error {
font-family: 'Crimson Text', serif;
font-size: 14px;
color: #f06060;
font-size: var(--text-base);
color: var(--color-error);
margin: 0;
min-height: 1.4em;
text-align: center;
@@ -1,30 +1,27 @@
<script>
<script lang="ts">
import { API_URL } from '$lib/api.js';
import { apiFetch } from '$lib/api.js';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import CardSelector from '$lib/CardSelector.svelte';
let allCards = $state([]);
let shards = $state(null);
let selectedIds = $state(new Set());
let selectedCards: any[] = $state([]); // bound from CardSelector
let selectorOpen = $state(false);
let shattering = $state(false);
let result = $state(null); // { gained, shards }
let result: { gained: number } | null = $state(null);
let inDeckIds = $state(new Set());
let selectorRef: any;
const selectedCards = $derived(allCards.filter(c => selectedIds.has(c.id)));
const totalYield = $derived(selectedCards.reduce((sum, c) => sum + c.cost, 0));
const totalYield = $derived(selectedCards.reduce((sum: number, c: any) => sum + c.cost, 0));
onMount(async () => {
if (!localStorage.getItem('token')) { goto('/auth'); return; }
const [cardsRes, profileRes, inDecksRes] = await Promise.all([
apiFetch(`${API_URL}/cards`),
const [profileRes, inDecksRes] = await Promise.all([
apiFetch(`${API_URL}/profile`),
apiFetch(`${API_URL}/cards/in-decks`),
]);
if (cardsRes.status === 401) { goto('/auth'); return; }
allCards = await cardsRes.json();
const profile = await profileRes.json();
shards = profile.shards;
if (inDecksRes.ok) inDeckIds = new Set(await inDecksRes.json());
@@ -42,8 +39,8 @@
const data = await res.json();
shards = data.shards;
result = { gained: data.gained };
allCards = allCards.filter(c => !selectedIds.has(c.id));
selectedIds = new Set();
selectorRef?.refresh(); // refetch so shattered cards disappear
}
shattering = false;
}
@@ -53,7 +50,7 @@
<main>
<div class="top">
<h1 class="page-title">Shards</h1>
<h1 class="page-title">Shatter</h1>
{#if shards !== null}
<div class="shards-display">
<span class="shards-icon"></span>
@@ -96,8 +93,9 @@
{#if selectorOpen}
<div class="selector-overlay">
<CardSelector
allCards={allCards}
bind:this={selectorRef}
bind:selectedIds={selectedIds}
bind:selectedCards={selectedCards}
{inDeckIds}
onclose={() => { selectorOpen = false; }}
/>
@@ -105,11 +103,9 @@
{/if}
<style>
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;700;900&family=Crimson+Text:ital,wght@0,400;0,600;1,400&display=swap');
main {
min-height: 100vh;
background: #0d0a04;
background: var(--color-bg);
padding: 2.5rem 2rem;
display: flex;
flex-direction: column;
@@ -130,7 +126,7 @@
font-family: 'Cinzel', serif;
font-size: clamp(22px, 4vw, 32px);
font-weight: 900;
color: #f0d080;
color: var(--color-gold);
letter-spacing: 0.12em;
text-transform: uppercase;
margin: 0;
@@ -143,22 +139,23 @@
}
.shards-icon {
font-size: 20px;
color: #7ecfcf;
font-size: var(--text-xl);
color: var(--color-cyan);
position: relative;
top: -0.1em;
animation: shard-pulse 3s ease-in-out infinite;
}
.shards-amount {
font-family: 'Cinzel', serif;
font-size: 24px;
font-size: var(--text-xl);
font-weight: 700;
color: #7ecfcf;
color: var(--color-cyan);
}
.shards-label {
font-family: 'Cinzel', serif;
font-size: 11px;
font-size: var(--text-sm);
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
@@ -168,23 +165,23 @@
.explainer {
font-family: 'Crimson Text', serif;
font-size: 16px;
font-size: var(--text-md);
font-style: italic;
color: rgba(240, 180, 80, 0.5);
color: var(--color-gold-faint);
margin: 0;
line-height: 1.6;
}
.store-hint {
font-family: 'Crimson Text', serif;
font-size: 15px;
font-size: var(--text-md);
font-style: italic;
color: rgba(240, 180, 80, 0.35);
color: var(--color-gold-faint);
margin: 0;
}
.store-link {
color: #7ecfcf;
color: var(--color-cyan);
text-decoration: underline;
text-underline-offset: 2px;
transition: color 0.15s;
@@ -197,16 +194,16 @@
align-items: center;
gap: 0.5rem;
background: #0d2a0d;
border: 1.5px solid #6aaa6a;
border-radius: 8px;
border: 1.5px solid var(--color-success);
border-radius: var(--radius-lg);
padding: 0.75rem 1.25rem;
font-family: 'Cinzel', serif;
font-size: 15px;
font-size: var(--text-md);
font-weight: 700;
color: #6aaa6a;
color: var(--color-success);
}
.result-icon { color: #7ecfcf; position: relative; top: -0.1em; }
.result-icon { color: var(--color-cyan); position: relative; top: -0.1em; animation: shard-pulse 3s ease-in-out infinite; }
.dismiss {
margin-left: auto;
@@ -214,11 +211,11 @@
border: none;
color: rgba(106, 170, 106, 0.5);
cursor: pointer;
font-size: 13px;
font-size: var(--text-base);
padding: 0 0 0 0.75rem;
transition: color 0.15s;
}
.dismiss:hover { color: #6aaa6a; }
.dismiss:hover { color: var(--color-success); }
/* ── Action area ── */
.action-area {
@@ -232,15 +229,15 @@
.select-btn {
font-family: 'Cinzel', serif;
font-size: 13px;
font-size: var(--btn-font-lg);
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
background: #3d2507;
border: 1.5px solid #c8861a;
border-radius: 6px;
color: #f0d080;
padding: 10px 24px;
background: var(--color-surface-raised);
border: 1.5px solid var(--color-bronze);
border-radius: var(--radius-md);
color: var(--color-gold);
padding: var(--btn-padding-lg);
cursor: pointer;
transition: background 0.15s;
width: 100%;
@@ -254,26 +251,26 @@
justify-content: center;
gap: 0.75rem;
font-family: 'Cinzel', serif;
font-size: 15px;
font-size: var(--text-md);
font-weight: 700;
}
.summary-count { color: #f0d080; }
.summary-arrow { color: rgba(240, 180, 80, 0.35); }
.summary-yield { color: #7ecfcf; display: flex; align-items: center; gap: 0.3rem; }
.shards-icon-sm { font-size: 14px; color: #7ecfcf; position: relative; top: -0.1em; }
.summary-count { color: var(--color-gold); }
.summary-arrow { color: var(--color-gold-faint); }
.summary-yield { color: var(--color-cyan); display: flex; align-items: center; gap: 0.3rem; }
.shards-icon-sm { font-size: var(--text-base); color: var(--color-cyan); position: relative; top: -0.1em; }
.shatter-btn {
font-family: 'Cinzel', serif;
font-size: 13px;
font-size: var(--btn-font-lg);
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
background: #1a1008;
border: 1.5px solid #7ecfcf;
border-radius: 6px;
color: #7ecfcf;
padding: 10px 24px;
background: var(--color-surface);
border: 1.5px solid var(--color-cyan);
border-radius: var(--radius-md);
color: var(--color-cyan);
padding: var(--btn-padding-lg);
cursor: pointer;
width: 100%;
transition: background 0.15s;
@@ -286,7 +283,7 @@
.selector-overlay {
position: fixed;
inset: 0;
z-index: 200;
z-index: var(--z-dropdown);
background: rgba(0, 0, 0, 0.85);
}
</style>
+255 -113
View File
@@ -1,4 +1,4 @@
<script>
<script lang="ts">
import { API_URL } from '$lib/api.js';
import { apiFetch } from '$lib/api.js';
import { goto } from '$app/navigation';
@@ -7,11 +7,11 @@
import { page } from '$app/stores';
import Card from '$lib/Card.svelte';
let shards = $state(null);
let buying = $state(null); // which quantity is being bought
let flash = $state(null); // { quantity, ok }
let shardPackages = $state([]);
let buyingShards = $state(null);
let shards: number | null = $state(null);
let buying: number | null = $state(null);
let flash: { quantity: number; ok: boolean } | null = $state(null);
let shardPackages: any[] = $state([]);
let buyingShards: string | null = $state(null);
let paymentSuccess = $state(false);
const packages = [
@@ -31,7 +31,7 @@
const profile = await profileRes.json();
shards = profile.shards;
const config = await configRes.json();
shardPackages = Object.entries(config.shard_packages).map(([id, pkg]) => ({ id, ...pkg }));
shardPackages = Object.entries(config.shard_packages).map(([id, pkg]) => ({ id, ...(pkg as object) }));
if ($page.url.searchParams.get('payment') === 'success') {
paymentSuccess = true;
@@ -41,7 +41,7 @@
}
});
async function buyWithStripe(packageId) {
async function buyWithStripe(packageId: string) {
if (buyingShards) return;
buyingShards = packageId;
const res = await apiFetch(`${API_URL}/store/stripe/checkout`, {
@@ -56,8 +56,8 @@
buyingShards = null;
}
async function buy(quantity, cost) {
if (buying !== null || shards < cost) return;
async function buy(quantity: number, cost: number) {
if (buying !== null || shards === null || shards < cost) return;
buying = quantity;
const res = await apiFetch(`${API_URL}/store/buy`, {
method: 'POST',
@@ -79,9 +79,10 @@
const SPECIFIC_CARD_COST = 1000;
let specificPhase = $state('idle'); // idle | input | generating | revealing | done
let wikiTitle = $state('');
let specificCard = $state(null);
let specificCard: any = $state(null);
let specificFlipped = $state(false);
let specificError = $state('');
let specificAction = $state({ favorited: false, tradeListed: false, shattered: false, shardGain: 0 });
function openSpecificModal() {
wikiTitle = '';
@@ -93,6 +94,36 @@
specificPhase = 'idle';
specificCard = null;
specificFlipped = false;
specificAction = { favorited: false, tradeListed: false, shattered: false, shardGain: 0 };
}
async function specificToggleFavorite() {
const res = await apiFetch(`${API_URL}/cards/${specificCard.id}/favorite`, { method: 'POST' });
if (res.ok) {
const data = await res.json();
specificAction = { ...specificAction, favorited: data.is_favorite };
}
}
async function specificToggleTrade() {
const res = await apiFetch(`${API_URL}/cards/${specificCard.id}/willing-to-trade`, { method: 'POST' });
if (res.ok) {
const data = await res.json();
specificAction = { ...specificAction, tradeListed: data.willing_to_trade };
}
}
async function specificShatter() {
const res = await apiFetch(`${API_URL}/shards/shatter`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ card_ids: [specificCard.id] }),
});
if (res.ok) {
const data = await res.json();
shards = data.shards;
specificAction = { ...specificAction, shattered: true, shardGain: data.gained };
}
}
async function buySpecificCard() {
@@ -122,7 +153,7 @@
}
// How many mini-packs to fan out per package
function fanCount(quantity) {
function fanCount(quantity: number) {
if (quantity === 1) return 1;
if (quantity === 5) return 2;
if (quantity === 10) return 3;
@@ -130,7 +161,7 @@
}
// Rotation and offset for each pack in the fan
function packTransform(index, total) {
function packTransform(index: number, total: number) {
if (total === 1) return 'rotate(0deg) translateY(0px)';
const spread = 10; // degrees between each pack
const mid = (total - 1) / 2;
@@ -148,7 +179,7 @@
<span class="shards-icon"></span>
<span class="shards-amount">{shards}</span>
<span class="shards-label">Shards</span>
<a href="/shards" class="shards-link">shatter cards</a>
<a href="/shatter" class="shards-link">shatter cards</a>
</div>
{/if}
</div>
@@ -179,12 +210,12 @@
<button
class="buy-btn"
class:flash-ok={isFlashing && flash.ok}
class:flash-err={isFlashing && !flash.ok}
class:flash-ok={isFlashing && flash?.ok}
class:flash-err={isFlashing && !flash?.ok}
onclick={() => buy(pkg.quantity, pkg.cost)}
disabled={!canAfford || buying !== null}
>
{#if isFlashing && flash.ok}
{#if isFlashing && flash?.ok}
Purchased!
{:else if buying === pkg.quantity}
...
@@ -236,7 +267,7 @@
{#each shardPackages as pkg}
<div class="shard-card">
{#if pkg.bonus > 0}
<div class="shard-sticker"><span class="sticker-icon"></span>{pkg.bonus}<br/>BONUS</div>
<div class="shard-sticker"><span class="sticker-icon"></span>{pkg.bonus}<br/>BONUS*</div>
{/if}
<span class="shard-amount"><span class="shard-icon"></span> {pkg.shards}</span>
<span class="shard-price">{pkg.price_label}</span>
@@ -250,6 +281,9 @@
</div>
{/each}
</div>
{#if shardPackages.some(p => p.bonus > 0)}
<p class="bonus-footnote">* compared to the per-shard price of the 100 shards pack</p>
{/if}
</div>
</main>
@@ -300,6 +334,30 @@
Your card is being generated…
</p>
<div class="pack-card-actions" class:actions-visible={specificPhase === 'done'}>
{#if specificAction.shattered}
<span class="shard-gained">+{specificAction.shardGain}</span>
{:else}
<button
class="pack-action-btn fav"
class:active={specificAction.favorited}
onclick={specificToggleFavorite}
title="Favorite"
>{specificAction.favorited ? '★' : '☆'}</button>
<button
class="pack-action-btn trade"
class:active={specificAction.tradeListed}
onclick={specificToggleTrade}
title="Mark for Trade"
>⇄</button>
<button
class="pack-action-btn shatter"
onclick={specificShatter}
title="Shatter for shards"
>◈</button>
{/if}
</div>
<button
class="close-reveal-btn"
class:hidden={specificPhase !== 'done'}
@@ -312,12 +370,10 @@
{/if}
<style>
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;700;900&family=Crimson+Text:ital,wght@0,400;0,600;1,400&display=swap');
main {
height: 100vh;
overflow-y: auto;
background: #0d0a04;
background: var(--color-bg);
padding: 2.5rem 2rem 5rem;
display: flex;
flex-direction: column;
@@ -336,7 +392,7 @@
font-family: 'Cinzel', serif;
font-size: clamp(22px, 4vw, 32px);
font-weight: 900;
color: #f0d080;
color: var(--color-gold);
letter-spacing: 0.12em;
text-transform: uppercase;
margin: 0;
@@ -350,38 +406,39 @@
.shards-link {
font-family: 'Cinzel', serif;
font-size: 10px;
font-size: var(--text-xs);
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: rgba(126, 207, 207, 0.6);
border: 1px solid rgba(126, 207, 207, 0.3);
border-radius: 4px;
border-radius: var(--radius-sm);
padding: 3px 8px;
text-decoration: none;
margin-top: 4px;
margin-left: 0.5rem;
transition: color 0.15s, border-color 0.15s;
}
.shards-link:hover { color: #7ecfcf; border-color: rgba(126, 207, 207, 0.7); }
.shards-link:hover { color: var(--color-cyan); border-color: rgba(126, 207, 207, 0.7); }
.shards-icon {
font-size: 20px;
color: #7ecfcf;
font-size: var(--text-xl);
color: var(--color-cyan);
position: relative;
top: -0.1em;
animation: shard-pulse 3s ease-in-out infinite;
}
.shards-amount {
font-family: 'Cinzel', serif;
font-size: 24px;
font-size: var(--text-xl);
font-weight: 700;
color: #7ecfcf;
color: var(--color-cyan);
}
.shards-label {
font-family: 'Cinzel', serif;
font-size: 11px;
font-size: var(--text-sm);
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
@@ -399,8 +456,8 @@
.pkg-card {
position: relative;
background: #1a1008;
border: 1.5px solid rgba(107, 76, 30, 0.5);
background: var(--color-surface);
border: 1.5px solid var(--color-border-subtle);
border-radius: 14px;
padding: 2rem 1.5rem 1.5rem;
display: flex;
@@ -412,7 +469,7 @@
}
.pkg-card:not(.cannot-afford):hover {
border-color: #c8861a;
border-color: var(--color-bronze);
background: #211408;
}
@@ -431,7 +488,7 @@
background: #f5d800;
color: #c0000a;
font-family: 'Cinzel', serif;
font-size: 11px;
font-size: var(--text-sm);
font-weight: 900;
display: flex;
align-items: center;
@@ -528,9 +585,9 @@
/* ── Labels ── */
.qty-label {
font-family: 'Cinzel', serif;
font-size: 15px;
font-size: var(--text-md);
font-weight: 700;
color: #f0d080;
color: var(--color-gold);
margin: 0;
letter-spacing: 0.05em;
}
@@ -539,12 +596,12 @@
.buy-btn {
width: 100%;
padding: 8px 0;
background: #3d2507;
border: 1.5px solid #c8861a;
border-radius: 6px;
color: #f0d080;
background: var(--color-surface-raised);
border: 1.5px solid var(--color-bronze);
border-radius: var(--radius-md);
color: var(--color-gold);
font-family: 'Cinzel', serif;
font-size: 13px;
font-size: var(--btn-font-lg);
font-weight: 700;
letter-spacing: 0.05em;
cursor: pointer;
@@ -572,13 +629,13 @@
.buy-btn.flash-err {
background: #4a1a1a;
border-color: #c85050;
color: #c85050;
border-color: #c84040;
color: #c84040;
}
.cost-icon {
color: #7ecfcf;
font-size: 12px;
color: var(--color-cyan);
font-size: var(--text-sm);
position: relative;
top: -0.1em;
}
@@ -587,7 +644,7 @@
width: 100%;
max-width: 800px;
height: 1px;
background: rgba(107, 76, 30, 0.3);
background: var(--color-border-dim);
}
.shard-section {
@@ -597,33 +654,44 @@
gap: 1rem;
width: 100%;
max-width: 800px;
margin-top: 0;
}
.section-title {
font-family: 'Cinzel', serif;
font-size: 16px;
font-size: var(--text-base);
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: rgba(240, 180, 80, 0.5);
color: var(--color-gold-faint);
margin: 0;
}
.section-hint {
font-family: 'Crimson Text', serif;
font-size: 15px;
font-size: var(--text-md);
font-style: italic;
color: rgba(240, 180, 80, 0.35);
color: var(--color-gold-faint);
margin: -0.5rem 0 0;
}
.bonus-footnote {
font-family: 'Crimson Text', serif;
font-size: var(--text-md);
font-style: italic;
color: var(--color-gold-faint);
opacity: 0.6;
margin: 0;
text-align: center;
}
.payment-success {
font-family: 'Crimson Text', serif;
font-size: 15px;
color: #6aaa6a;
font-size: var(--text-md);
color: var(--color-success);
background: #0d2a0d;
border: 1px solid #6aaa6a;
border-radius: 6px;
border: 1px solid var(--color-success);
border-radius: var(--radius-md);
padding: 0.6rem 1.2rem;
}
@@ -631,12 +699,13 @@
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1.25rem;
margin-top: 0.75rem;
}
.shard-card {
position: relative;
background: #1a1008;
border: 1.5px solid rgba(107, 76, 30, 0.4);
background: var(--color-surface);
border: 1.5px solid var(--color-border-subtle);
border-radius: 14px;
padding: 2rem 2rem 1.75rem;
display: flex;
@@ -651,14 +720,14 @@
position: absolute;
top: -14px;
right: -14px;
background: #7ecfcf;
color: #0d0a04;
background: var(--color-cyan);
color: var(--color-bg);
font-family: 'Cinzel', serif;
font-size: 12px;
font-size: var(--text-sm);
font-weight: 900;
line-height: 1.3;
padding: 8px 11px;
border-radius: 5px;
border-radius: var(--radius-md);
text-align: center;
transform: rotate(8deg);
box-shadow: 0 2px 10px rgba(0,0,0,0.6);
@@ -670,13 +739,13 @@
top: -0.1em;
}
.shard-card:hover { border-color: #7ecfcf; }
.shard-card:hover { border-color: var(--color-cyan); }
.shard-amount {
font-family: 'Cinzel', serif;
font-size: 26px;
font-size: var(--text-2xl);
font-weight: 700;
color: #7ecfcf;
color: var(--color-cyan);
display: flex;
align-items: center;
gap: 0.3rem;
@@ -685,12 +754,13 @@
.shard-icon {
position: relative;
top: -0.1em;
animation: shard-pulse 3s ease-in-out infinite;
}
.shard-price {
font-family: 'Crimson Text', serif;
font-size: 19px;
color: rgba(240, 180, 80, 0.6);
font-size: var(--text-lg);
color: var(--color-gold-dim);
}
.stripe-btn {
@@ -698,10 +768,10 @@
padding: 10px 0;
background: #1a3a4a;
border: 1.5px solid #4a9aba;
border-radius: 6px;
border-radius: var(--radius-md);
color: #a0d8ef;
font-family: 'Cinzel', serif;
font-size: 13px;
font-size: var(--btn-font-lg);
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
@@ -723,8 +793,8 @@
}
.specific-card-preview {
background: #1a1008;
border: 1.5px solid rgba(107, 76, 30, 0.4);
background: var(--color-surface);
border: 1.5px solid var(--color-border-subtle);
border-radius: 14px;
padding: 2rem 2.5rem;
width: 100%;
@@ -732,11 +802,11 @@
.specific-card-title {
font-family: 'Cinzel', serif;
font-size: 14px;
font-size: var(--text-base);
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: rgba(240, 180, 80, 0.6);
color: var(--color-gold-dim);
margin: 0;
}
@@ -775,7 +845,7 @@
.specific-desc {
font-family: 'Crimson Text', serif;
font-size: 16px;
font-size: var(--text-md);
font-style: italic;
color: rgba(240, 180, 80, 0.55);
line-height: 1.5;
@@ -784,15 +854,15 @@
.specific-buy-btn {
font-family: 'Cinzel', serif;
font-size: 13px;
font-size: var(--btn-font-lg);
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
background: #3d2507;
border: 1.5px solid #c8861a;
border-radius: 6px;
color: #f0d080;
padding: 10px 20px;
background: var(--color-surface-raised);
border: 1.5px solid var(--color-bronze);
border-radius: var(--radius-md);
color: var(--color-gold);
padding: var(--btn-padding-lg);
cursor: pointer;
display: flex;
align-items: center;
@@ -805,7 +875,7 @@
.specific-cant-afford {
font-family: 'Crimson Text', serif;
font-size: 13px;
font-size: var(--text-base);
font-style: italic;
color: rgba(200, 100, 80, 0.7);
margin: 0;
@@ -815,7 +885,7 @@
.specific-overlay {
position: fixed;
inset: 0;
z-index: 300;
z-index: var(--z-modal);
background: rgba(0,0,0,0.92);
display: flex;
align-items: center;
@@ -824,8 +894,8 @@
/* Input modal */
.specific-modal {
background: #1a1008;
border: 1.5px solid rgba(107, 76, 30, 0.6);
background: var(--color-surface);
border: 1.5px solid var(--color-border-subtle);
border-radius: 14px;
padding: 2.5rem 2.5rem 2rem;
display: flex;
@@ -836,40 +906,40 @@
.modal-title {
font-family: 'Cinzel', serif;
font-size: 18px;
font-size: var(--text-lg);
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #f0d080;
color: var(--color-gold);
margin: 0;
}
.modal-hint {
font-family: 'Crimson Text', serif;
font-size: 15px;
font-size: var(--text-md);
font-style: italic;
color: rgba(240, 180, 80, 0.5);
color: var(--color-gold-faint);
margin: 0;
}
.wiki-input {
font-family: 'Crimson Text', serif;
font-size: 17px;
background: #0d0a04;
border: 1.5px solid rgba(107, 76, 30, 0.6);
border-radius: 6px;
color: #f0d080;
font-size: var(--text-lg);
background: var(--color-bg);
border: 1.5px solid var(--color-border-subtle);
border-radius: var(--radius-md);
color: var(--color-gold);
padding: 10px 14px;
outline: none;
transition: border-color 0.15s;
}
.wiki-input:focus { border-color: #c8861a; }
.wiki-input::placeholder { color: rgba(240, 180, 80, 0.25); }
.wiki-input:focus { border-color: var(--color-bronze); }
.wiki-input::placeholder { color: var(--color-gold-faint); }
.modal-error {
font-family: 'Crimson Text', serif;
font-size: 14px;
color: #c85050;
font-size: var(--text-base);
color: var(--color-error);
margin: 0;
}
@@ -882,30 +952,30 @@
.modal-cancel {
font-family: 'Cinzel', serif;
font-size: 11px;
font-size: var(--text-sm);
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
background: none;
border: 1px solid rgba(107, 76, 30, 0.5);
border-radius: 5px;
color: rgba(240, 180, 80, 0.5);
border: 1px solid var(--color-border-subtle);
border-radius: var(--radius-md);
color: var(--color-gold-faint);
padding: 8px 18px;
cursor: pointer;
transition: border-color 0.15s, color 0.15s;
}
.modal-cancel:hover { border-color: #c8861a; color: #f0d080; }
.modal-cancel:hover { border-color: var(--color-bronze); color: var(--color-gold); }
.modal-confirm {
font-family: 'Cinzel', serif;
font-size: 11px;
font-size: var(--text-sm);
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
background: #3d2507;
border: 1.5px solid #c8861a;
border-radius: 5px;
color: #f0d080;
background: var(--color-surface-raised);
border: 1.5px solid var(--color-bronze);
border-radius: var(--radius-md);
color: var(--color-gold);
padding: 8px 18px;
cursor: pointer;
display: flex;
@@ -936,7 +1006,7 @@
inset: 0;
background: white;
opacity: 0;
z-index: 9999;
z-index: 300;
pointer-events: none;
border-radius: 16px;
}
@@ -1000,26 +1070,98 @@
.reveal-label {
font-family: 'Crimson Text', serif;
font-size: 17px;
font-size: var(--text-lg);
font-style: italic;
color: rgba(240, 180, 80, 0.6);
color: var(--color-gold-dim);
margin: 0;
transition: opacity 0.6s ease;
}
.reveal-label.hidden { opacity: 0; }
.close-reveal-btn.hidden { opacity: 0; pointer-events: none; }
.pack-card-actions {
display: flex;
gap: 6px;
justify-content: center;
height: 34px;
visibility: hidden;
}
.pack-card-actions.actions-visible {
visibility: visible;
}
.pack-action-btn {
font-family: 'Cinzel', serif;
font-size: var(--text-base);
font-weight: 700;
width: 34px;
height: 34px;
border-radius: var(--radius-full);
border: 1.5px solid;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s;
}
.pack-action-btn.fav {
background: rgba(30, 20, 0, 0.7);
border-color: rgba(200, 160, 0, 0.5);
color: rgba(240, 200, 0, 0.6);
}
.pack-action-btn.fav:hover, .pack-action-btn.fav.active {
background: rgba(60, 45, 0, 0.9);
border-color: #c8a000;
color: #f0c800;
}
.pack-action-btn.trade {
background: rgba(0, 25, 25, 0.7);
border-color: rgba(0, 150, 150, 0.5);
color: rgba(0, 190, 190, 0.6);
}
.pack-action-btn.trade:hover, .pack-action-btn.trade.active {
background: rgba(0, 50, 50, 0.9);
border-color: #00a0a0;
color: var(--color-cyan);
}
.pack-action-btn.shatter {
background: rgba(0, 20, 30, 0.7);
border-color: rgba(100, 200, 200, 0.4);
color: rgba(126, 207, 207, 0.6);
}
.pack-action-btn.shatter:hover {
background: rgba(0, 40, 50, 0.9);
border-color: var(--color-cyan);
color: var(--color-cyan);
}
.shard-gained {
font-family: 'Cinzel', serif;
font-size: var(--text-base);
font-weight: 700;
color: var(--color-cyan);
padding: 6px 12px;
background: rgba(0, 40, 50, 0.8);
border: 1.5px solid rgba(126, 207, 207, 0.5);
border-radius: 16px;
animation: shard-pulse 3s ease-in-out infinite;
}
.close-reveal-btn {
font-family: 'Cinzel', serif;
font-size: 13px;
font-size: var(--btn-font-lg);
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
background: rgba(60,30,5,0.85);
border: 1.5px solid #c8861a;
border-radius: 6px;
color: #f0d080;
padding: 10px 32px;
border: 1.5px solid var(--color-bronze);
border-radius: var(--radius-md);
color: var(--color-gold);
padding: var(--btn-padding-lg);
cursor: pointer;
transition: background 0.15s;
}
+121 -71
View File
@@ -1,4 +1,4 @@
<script>
<script lang="ts">
import { API_URL, WS_URL, apiFetch } from '$lib/api.js';
import { goto } from '$app/navigation';
import { onMount, onDestroy } from 'svelte';
@@ -9,13 +9,17 @@
let phase = $state('idle'); // idle | queuing | trading | complete
let error = $state('');
let reconnecting = $state(false);
let queueReconnectDelay = 1000;
let queueReconnectTimer: ReturnType<typeof setTimeout> | null = null;
let tradeReconnectDelay = 1000;
let tradeReconnectTimer: ReturnType<typeof setTimeout> | null = null;
let queueWs = null;
let tradeWs = null;
let queueWs: WebSocket | null = null;
let tradeWs: WebSocket | null = null;
let tradeId = $state('');
let allCards = $state([]); // user's full card collection (for selector)
let tradeState = $state(null); // latest trade state from server
let tradeState: any = $state(null); // latest trade state from server
let selectorOpen = $state(false);
let selectorIds = $state(new Set());
@@ -35,12 +39,11 @@
onMount(async () => {
if (!token()) { goto('/auth'); return; }
const res = await apiFetch(`${API_URL}/cards`);
if (!res.ok) { goto('/auth'); return; }
allCards = await res.json();
});
onDestroy(() => {
clearTimeout(queueReconnectTimer);
clearTimeout(tradeReconnectTimer);
queueWs?.close();
tradeWs?.close();
});
@@ -49,12 +52,18 @@
error = '';
phase = 'queuing';
queueWs = new WebSocket(`${WS_URL}/ws/trade/queue`);
queueWs.onopen = () => queueWs.send(token());
queueWs.onopen = () => {
queueWs!.send(token()!);
reconnecting = false;
queueReconnectDelay = 1000;
};
queueWs.onmessage = (e) => {
const msg = JSON.parse(e.data);
if (msg.type === 'trade_start') {
tradeId = msg.trade_id;
queueWs.close();
// Set phase before close so onclose doesn't trigger a reconnect
phase = 'trading';
queueWs!.close();
connectToTrade();
} else if (msg.type === 'error') {
error = msg.message;
@@ -62,22 +71,37 @@
}
};
queueWs.onerror = () => { error = 'Connection failed'; phase = 'idle'; };
queueWs.onclose = () => {
if (phase === 'queuing') {
reconnecting = true;
queueReconnectTimer = setTimeout(() => {
queueReconnectDelay = Math.min(queueReconnectDelay * 2, 30000);
joinQueue();
}, queueReconnectDelay);
}
};
}
function cancelQueue() {
queueWs?.close();
clearTimeout(queueReconnectTimer);
phase = 'idle';
reconnecting = false;
queueWs?.close();
}
function connectToTrade() {
phase = 'trading';
tradeWs = new WebSocket(`${WS_URL}/ws/trade/${tradeId}`);
tradeWs.onopen = () => tradeWs.send(token());
tradeWs.onopen = () => {
tradeWs!.send(token()!);
reconnecting = false;
tradeReconnectDelay = 1000;
};
tradeWs.onmessage = (e) => {
const msg = JSON.parse(e.data);
if (msg.type === 'state') {
tradeState = msg.state;
} else if (msg.type === 'trade_complete') {
// Set phase before close so onclose doesn't trigger a reconnect
phase = 'complete';
tradeWs?.close();
} else if (msg.type === 'error') {
@@ -90,9 +114,15 @@
}
}
};
tradeWs.onerror = () => { error = 'Connection lost'; phase = 'idle'; };
tradeWs.onclose = (e) => {
if (phase === 'trading') { error = 'Connection lost'; phase = 'idle'; }
tradeWs.onerror = () => { error = 'Connection lost'; reconnecting = false; phase = 'idle'; };
tradeWs.onclose = () => {
if (phase === 'trading') {
reconnecting = true;
tradeReconnectTimer = setTimeout(() => {
tradeReconnectDelay = Math.min(tradeReconnectDelay * 2, 30000);
connectToTrade();
}, tradeReconnectDelay);
}
};
}
@@ -100,7 +130,7 @@
if (myOffer.accepted) {
tradeWs?.send(JSON.stringify({ type: 'unaccept' }));
}
selectorIds = new Set(myOffer.cards.map(c => c.id));
selectorIds = new Set(myOffer.cards.map((c: any) => c.id));
selectorOpen = true;
}
@@ -118,7 +148,9 @@
}
function reset() {
clearTimeout(tradeReconnectTimer);
phase = 'idle';
reconnecting = false;
tradeState = null;
tradeId = '';
error = '';
@@ -140,7 +172,7 @@
{:else if phase === 'queuing'}
<div class="center-screen">
<div class="spinner"></div>
<p class="searching-text">Searching for a trade partner...</p>
<p class="searching-text">{reconnecting ? 'Reconnecting...' : 'Searching for a trade partner...'}</p>
<button class="cancel-btn" onclick={cancelQueue}>Cancel</button>
</div>
@@ -149,7 +181,6 @@
{#if selectorOpen}
<div class="selector-overlay">
<CardSelector
allCards={allCards}
bind:selectedIds={selectorIds}
onclose={closeSelector}
/>
@@ -185,7 +216,7 @@
<div class="panel their-panel">
<div class="panel-header">
<span class="panel-title">{partnerUsername || 'Partner'}'s Offer</span>
<span class="panel-title">{#if partnerUsername}<a href="/profile/{partnerUsername}" target="_blank" class="partner-link">{partnerUsername}</a>{:else}Partner{/if}'s Offer</span>
{#if theirOffer.accepted}
<span class="accepted-badge">Accepted ✓</span>
{/if}
@@ -211,7 +242,9 @@
<div class="action-bar">
<button class="choose-btn" onclick={openSelector}>Choose Cards</button>
{#if error}
{#if reconnecting}
<span class="reconnecting-text">Reconnecting...</span>
{:else if error}
<span class="error-inline">{error}</span>
{/if}
@@ -246,11 +279,10 @@
</main>
<style>
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;700&family=Crimson+Text:ital,wght@0,400;0,600;1,400&display=swap');
main {
height: calc(100vh - 56px);
background: #0d0a04;
background: var(--color-bg);
display: flex;
flex-direction: column;
overflow: hidden;
@@ -270,40 +302,40 @@
.title {
font-family: 'Cinzel', serif;
font-size: 36px;
font-size: var(--text-3xl);
font-weight: 700;
color: #f0d080;
color: var(--color-gold);
margin: 0;
letter-spacing: 0.04em;
}
.subtitle {
font-family: 'Crimson Text', serif;
font-size: 18px;
font-size: var(--text-lg);
font-style: italic;
color: rgba(240, 180, 80, 0.6);
color: var(--color-gold-dim);
margin: 0;
}
.error {
font-family: 'Crimson Text', serif;
font-size: 15px;
color: #c85050;
font-size: var(--text-md);
color: var(--color-error);
margin: 0;
text-align: center;
}
.primary-btn {
font-family: 'Cinzel', serif;
font-size: 13px;
font-size: var(--btn-font-lg);
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
background: #3d2507;
border: 1px solid #c8861a;
border-radius: 4px;
color: #f0d080;
padding: 10px 28px;
background: var(--color-surface-raised);
border: 1px solid var(--color-bronze);
border-radius: var(--radius-md);
color: var(--color-gold);
padding: var(--btn-padding-lg);
cursor: pointer;
transition: background 0.15s;
margin-top: 0.5rem;
@@ -313,24 +345,24 @@
.cancel-btn {
font-family: 'Cinzel', serif;
font-size: 11px;
font-size: var(--btn-font-md);
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
background: none;
border: 1px solid rgba(107, 76, 30, 0.5);
border-radius: 4px;
border: 1px solid var(--color-border-subtle);
border-radius: var(--radius-sm);
color: rgba(240, 180, 80, 0.5);
padding: 6px 18px;
padding: var(--btn-padding-md);
cursor: pointer;
transition: all 0.15s;
}
.cancel-btn:hover { border-color: #c8861a; color: #f0d080; }
.cancel-btn:hover { border-color: var(--color-bronze); color: var(--color-gold); }
.searching-text {
font-family: 'Crimson Text', serif;
font-size: 18px;
font-size: var(--text-lg);
font-style: italic;
color: rgba(240, 180, 80, 0.7);
margin: 0;
@@ -340,8 +372,8 @@
width: 40px;
height: 40px;
border: 3px solid rgba(200, 134, 26, 0.2);
border-top-color: #c8861a;
border-radius: 50%;
border-top-color: var(--color-bronze);
border-radius: var(--radius-full);
animation: spin 0.9s linear infinite;
}
@@ -349,20 +381,20 @@
.complete-icon {
font-size: 56px;
color: #6aaa6a;
color: var(--color-success);
line-height: 1;
}
.secondary-link {
font-family: 'Crimson Text', serif;
font-size: 15px;
font-size: var(--text-md);
font-style: italic;
color: rgba(240, 180, 80, 0.5);
text-decoration: underline;
transition: color 0.15s;
}
.secondary-link:hover { color: #f0d080; }
.secondary-link:hover { color: var(--color-gold); }
/* ── Trade layout ── */
@@ -394,27 +426,30 @@
align-items: center;
gap: 1rem;
padding: 1rem 1.5rem 0.75rem;
border-bottom: 1px solid rgba(107, 76, 30, 0.3);
border-bottom: 1px solid var(--color-border-dim);
}
.panel-title {
font-family: 'Cinzel', serif;
font-size: 14px;
font-size: var(--text-base);
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: rgba(240, 180, 80, 0.7);
}
.partner-link { text-decoration: none; transition: color 0.15s; }
.partner-link:hover { color: var(--color-gold); text-decoration: underline; }
.accepted-badge {
font-family: 'Cinzel', serif;
font-size: 11px;
font-size: var(--text-sm);
font-weight: 700;
letter-spacing: 0.04em;
color: #6aaa6a;
color: var(--color-success);
background: rgba(106, 170, 106, 0.12);
border: 1px solid rgba(106, 170, 106, 0.4);
border-radius: 3px;
border-radius: var(--radius-sm);
padding: 2px 7px;
}
@@ -433,7 +468,7 @@
.empty-offer p {
font-family: 'Crimson Text', serif;
font-size: 15px;
font-size: var(--text-md);
font-style: italic;
color: rgba(240, 180, 80, 0.25);
margin: 0;
@@ -456,7 +491,7 @@
.divider {
flex-shrink: 0;
width: 1px;
background: rgba(107, 76, 30, 0.35);
background: var(--color-border-dim);
margin: 0;
}
@@ -468,43 +503,58 @@
align-items: center;
gap: 1rem;
padding: 1rem 1.5rem;
border-top: 1px solid rgba(107, 76, 30, 0.35);
background: #0d0a04;
border-top: 1px solid var(--color-border-dim);
background: var(--color-bg);
}
.choose-btn {
font-family: 'Cinzel', serif;
font-size: 11px;
font-size: var(--btn-font-md);
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
background: #1e1208;
border: 1px solid rgba(107, 76, 30, 0.6);
border-radius: 4px;
border-radius: var(--radius-sm);
color: rgba(240, 180, 80, 0.8);
padding: 8px 18px;
padding: var(--btn-padding-md);
cursor: pointer;
transition: all 0.15s;
}
.choose-btn:hover { background: #2e1c0c; border-color: #c8861a; color: #f0d080; }
.choose-btn:hover { background: #2e1c0c; border-color: var(--color-bronze); color: var(--color-gold); }
.error-inline {
font-family: 'Crimson Text', serif;
font-size: 14px;
color: #c85050;
font-size: var(--text-base);
color: var(--color-error);
flex: 1;
text-align: center;
}
.reconnecting-text {
font-family: 'Crimson Text', serif;
font-size: var(--text-base);
font-style: italic;
color: rgba(240, 180, 80, 0.5);
flex: 1;
text-align: center;
animation: fade-pulse 1.4s ease-in-out infinite;
}
@keyframes fade-pulse {
0%, 100% { opacity: 0.5; }
50% { opacity: 1; }
}
.accept-btn {
font-family: 'Cinzel', serif;
font-size: 13px;
font-size: var(--btn-font-lg);
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
border-radius: 4px;
padding: 10px 28px;
border-radius: var(--radius-md);
padding: var(--btn-padding-lg);
cursor: pointer;
transition: all 0.2s;
margin-left: auto;
@@ -520,22 +570,22 @@
/* Ready: gold, inviting click */
.accept-btn.accept-ready {
background: #3d2507;
border: 2px solid #c8861a;
color: #f0d080;
background: var(--color-surface-raised);
border: 2px solid var(--color-bronze);
color: var(--color-gold);
box-shadow: 0 0 12px rgba(200, 134, 26, 0.2);
}
.accept-btn.accept-ready:hover {
background: #5a3510;
box-shadow: 0 0 20px rgba(200, 134, 26, 0.4);
box-shadow: var(--shadow-glow);
}
/* Accepted: bright green, pulsing, waiting */
.accept-btn.accept-accepted {
background: rgba(40, 90, 40, 0.4);
border: 2px solid #6aaa6a;
color: #6aaa6a;
border: 2px solid var(--color-success);
color: var(--color-success);
cursor: default;
animation: pulse-green 1.8s ease-in-out infinite;
}
@@ -548,7 +598,7 @@
.selector-overlay {
position: fixed;
inset: 0;
z-index: 200;
z-index: var(--z-dropdown);
background: rgba(0, 0, 0, 0.9);
}
@@ -0,0 +1,450 @@
<script lang="ts">
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import { get } from 'svelte/store';
import { API_URL, apiFetch } from '$lib/api.js';
import Card from '$lib/Card.svelte';
import CardSelector from '$lib/CardSelector.svelte';
const token = () => localStorage.getItem('token');
let recipientUsername = $derived(get(page).params.username);
let theirCards: any[] = $state([]); // recipient's WTT cards (static list from profile)
let loading = $state(true);
let notFound = $state(false);
// Which panel is the selector picking for: 'mine' | 'theirs' | null
let selectorMode: 'mine' | 'theirs' | null = $state(null);
let mySelectedIds: Set<string> = $state(new Set());
let mySelectedCards: any[] = $state([]); // bound from CardSelector
let theirSelectedIds: Set<string> = $state(new Set());
let theirSelectedCards = $derived(theirCards.filter((c: any) => theirSelectedIds.has(c.id)));
let submitting = $state(false);
let error = $state('');
let done = $state(false);
onMount(async () => {
if (!token()) { goto('/auth'); return; }
const username = get(page).params.username;
const profileRes = await fetch(`${API_URL}/users/${username}`);
if (profileRes.status === 404) { notFound = true; loading = false; return; }
const profile = await profileRes.json();
theirCards = profile.wtt_cards ?? [];
loading = false;
});
function openSelector(mode: 'mine' | 'theirs') {
selectorMode = mode;
}
function closeSelector() {
selectorMode = null;
}
async function sendOffer() {
if (mySelectedIds.size === 0 && theirSelectedIds.size === 0) {
error = 'At least one side must include cards.';
return;
}
submitting = true;
error = '';
const res = await apiFetch(`${API_URL}/trade-proposals`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
recipient_username: get(page).params.username,
offered_card_ids: [...mySelectedIds],
requested_card_ids: [...theirSelectedIds],
}),
});
submitting = false;
if (res.ok) {
done = true;
} else {
const data = await res.json().catch(() => ({}));
error = data.detail ?? 'Failed to send offer.';
}
}
</script>
<main>
{#if loading}
<div class="center-screen">
<p class="status-text">Loading...</p>
</div>
{:else if notFound}
<div class="center-screen">
<p class="status-text">User not found.</p>
<a href="/" class="back-link">← Home</a>
</div>
{:else if done}
<div class="center-screen">
<div class="done-icon"></div>
<h2 class="done-title">Offer Sent</h2>
<p class="done-body">Your trade offer has been sent to <strong>{recipientUsername}</strong>. They have 72 hours to respond.</p>
<div class="done-actions">
<a href="/profile/{recipientUsername}" class="choose-btn">View their profile</a>
<a href="/profile" class="choose-btn">Your proposals</a>
</div>
</div>
{:else}
<!-- Full-screen card selector overlay — rendered conditionally per mode so bind works cleanly -->
{#if selectorMode === 'mine'}
<div class="selector-overlay">
<CardSelector bind:selectedIds={mySelectedIds} bind:selectedCards={mySelectedCards} onclose={closeSelector} />
</div>
{:else if selectorMode === 'theirs'}
<div class="selector-overlay">
<CardSelector staticCards={theirCards} bind:selectedIds={theirSelectedIds} onclose={closeSelector} />
</div>
{/if}
<div class="trade-layout">
<div class="trade-header">
<h1 class="trade-title">Propose a Trade <span class="trade-with">with</span> <strong class="trade-username">{recipientUsername}</strong></h1>
<p class="action-error" style="min-height: 1.2em">{error}</p>
<button
class="send-btn"
class:disabled={mySelectedIds.size === 0 && theirSelectedIds.size === 0}
disabled={submitting || (mySelectedIds.size === 0 && theirSelectedIds.size === 0)}
onclick={sendOffer}
>
{submitting ? 'Sending...' : 'Send Offer'}
</button>
</div>
<div class="trade-panels">
<!-- YOUR OFFER panel -->
<div class="panel your-panel">
<div class="panel-header">
<span class="panel-title">Your Offer</span>
<span class="panel-count">{mySelectedIds.size} card{mySelectedIds.size === 1 ? '' : 's'}</span>
<button class="choose-btn" onclick={() => openSelector('mine')}>
{mySelectedIds.size > 0 ? 'Change' : 'Choose Cards'}
</button>
</div>
<div class="panel-cards">
{#if mySelectedCards.length === 0}
<div class="empty-offer">
<p>No cards offered</p>
</div>
{:else}
<div class="card-scroll">
{#each mySelectedCards as card (card.id)}
<div class="card-wrap">
<Card {card} noHover={true} />
</div>
{/each}
</div>
{/if}
</div>
</div>
<!-- THEIR WTT panel -->
<div class="panel their-panel">
<div class="panel-header">
<span class="panel-title">You Want</span>
<span class="panel-count">{theirSelectedIds.size} card{theirSelectedIds.size === 1 ? '' : 's'}</span>
{#if theirCards.length > 0}
<button class="choose-btn" onclick={() => openSelector('theirs')}>
{theirSelectedIds.size > 0 ? 'Change' : 'Choose Cards'}
</button>
{/if}
</div>
<div class="panel-cards">
{#if theirSelectedCards.length === 0}
<div class="empty-offer">
{#if theirCards.length === 0}
<p>{recipientUsername} has no cards marked as willing to trade.</p>
{:else}
<p>No cards requested</p>
{/if}
</div>
{:else}
<div class="card-scroll">
{#each theirSelectedCards as card (card.id)}
<div class="card-wrap">
<Card {card} noHover={true} />
</div>
{/each}
</div>
{/if}
</div>
</div>
</div>
</div>
{/if}
</main>
<style>
main {
height: 100vh;
background: var(--color-bg);
display: flex;
flex-direction: column;
overflow: hidden;
}
/* ── Overlay ── */
.selector-overlay {
position: fixed;
inset: 0;
z-index: var(--z-dropdown);
background: var(--color-bg);
}
/* ── Trade layout ── */
.trade-layout {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow: hidden;
}
.trade-header {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.75rem 1.5rem;
border-bottom: 1px solid var(--color-border-dim);
flex-shrink: 0;
}
.trade-title {
font-family: 'Cinzel', serif;
font-size: var(--text-lg);
font-weight: 700;
letter-spacing: 0.08em;
color: var(--color-gold);
margin: 0;
flex: 1;
}
.trade-with {
font-family: 'Crimson Text', serif;
font-size: var(--text-lg);
font-style: italic;
font-weight: 400;
color: rgba(240, 180, 80, 0.5);
letter-spacing: 0;
}
.trade-username {
font-family: 'Cinzel', serif;
font-size: var(--text-lg);
font-weight: 700;
color: var(--color-bronze);
letter-spacing: 0.08em;
}
/* ── Panels ── */
.trade-panels {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0;
flex: 1;
min-height: 0;
}
.panel {
display: flex;
flex-direction: column;
padding: 1.25rem 1.5rem;
gap: 1rem;
min-height: 0;
}
.your-panel { border-right: 1px solid var(--color-border-dim); }
.panel-header {
display: flex;
align-items: center;
gap: 0.75rem;
flex-shrink: 0;
}
.panel-title {
font-family: 'Cinzel', serif;
font-size: var(--text-base);
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: rgba(240, 180, 80, 0.5);
}
.panel-count {
font-family: 'Crimson Text', serif;
font-size: var(--text-base);
font-style: italic;
color: rgba(240, 180, 80, 0.3);
}
.panel-cards {
flex: 1;
min-height: 0;
overflow: hidden;
}
.empty-offer {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.empty-offer p {
font-family: 'Crimson Text', serif;
font-size: var(--text-md);
font-style: italic;
color: rgba(240, 180, 80, 0.2);
margin: 0;
}
.card-scroll {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-content: flex-start;
height: 100%;
overflow-y: auto;
padding-bottom: 0.5rem;
}
/*
* Cards displayed at scale 0.62 inside a 300×420 layout box.
* Negative margins compensate so the visual footprint is 186×260px.
*/
.card-wrap {
width: 300px;
height: 420px;
margin-right: -114px;
margin-bottom: -160px;
flex-shrink: 0;
pointer-events: none;
}
.card-wrap :global(.card) {
transform: scale(0.62);
transform-origin: top left;
}
/* ── Choose button ── */
.choose-btn {
font-family: 'Cinzel', serif;
font-size: var(--btn-font-md);
font-weight: 700;
letter-spacing: 0.07em;
text-transform: uppercase;
background: var(--color-surface-raised);
border: 1px solid var(--color-border-subtle);
border-radius: var(--radius-md);
color: var(--color-gold);
padding: var(--btn-padding-md);
cursor: pointer;
transition: all 0.15s;
align-self: flex-start;
flex-shrink: 0;
}
.choose-btn:hover { border-color: var(--color-bronze); background: #4d3010; }
a.choose-btn { text-decoration: none; }
.action-error {
font-family: 'Crimson Text', serif;
font-size: var(--text-base);
color: var(--color-error);
margin: 0;
flex-shrink: 0;
}
.send-btn {
font-family: 'Cinzel', serif;
font-size: var(--btn-font-lg);
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
background: var(--color-bronze);
border: none;
border-radius: var(--radius-md);
color: var(--color-btn-text);
padding: var(--btn-padding-lg);
cursor: pointer;
transition: background 0.15s, transform 0.1s, box-shadow 0.15s;
box-shadow: var(--shadow-glow);
}
.send-btn:hover:not(:disabled) { background: var(--color-bronze-hover); transform: translateY(-1px); box-shadow: 0 0 30px rgba(200, 134, 26, 0.5); }
.send-btn:active:not(:disabled) { transform: translateY(0); }
.send-btn:disabled, .send-btn.disabled { background: #2a1a05; color: rgba(240, 180, 80, 0.2); cursor: default; box-shadow: none; }
/* ── Center screen states ── */
.center-screen {
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1.5rem;
padding: 2rem;
}
.status-text {
font-family: 'Crimson Text', serif;
font-size: var(--text-md);
font-style: italic;
color: var(--color-gold-faint);
margin: 0;
}
.done-icon { font-size: var(--text-3xl); color: rgba(200, 134, 26, 0.5); }
.done-title {
font-family: 'Cinzel', serif;
font-size: var(--text-xl);
font-weight: 700;
letter-spacing: 0.08em;
color: var(--color-gold);
margin: 0;
}
.done-body {
font-family: 'Crimson Text', serif;
font-size: var(--text-lg);
font-style: italic;
color: var(--color-gold-dim);
text-align: center;
margin: 0;
max-width: 400px;
}
.done-body strong { color: var(--color-gold-muted); font-style: normal; }
.done-actions {
display: flex;
gap: 1.5rem;
}
/* ── Responsive ── */
@media (max-width: 640px) {
.trade-panels {
grid-template-columns: 1fr;
grid-template-rows: 1fr 1fr;
overflow-y: auto;
}
.your-panel { border-right: none; border-bottom: 1px solid var(--color-border-dim); }
}
</style>
@@ -0,0 +1,439 @@
<script lang="ts">
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import { get } from 'svelte/store';
import { API_URL, apiFetch } from '$lib/api.js';
import Card from '$lib/Card.svelte';
const token = () => localStorage.getItem('token');
let proposal: any = $state(null);
let loading = $state(true);
let notFound = $state(false);
let submitting = $state(false);
let error = $state('');
let done = $state(false);
let doneStatus: 'accepted' | 'declined' | 'withdrawn' = $state('accepted');
let confirmingAccept = $state(false);
onMount(async () => {
if (!token()) { goto('/auth'); return; }
const id = get(page).params.id;
const res = await apiFetch(`${API_URL}/trade-proposals/${id}`);
if (res.status === 404 || res.status === 403) { notFound = true; loading = false; return; }
if (res.status === 401) { goto('/auth'); return; }
proposal = await res.json();
loading = false;
});
async function accept() {
submitting = true;
error = '';
const res = await apiFetch(`${API_URL}/trade-proposals/${proposal.id}/accept`, { method: 'POST' });
submitting = false;
if (res.ok) {
doneStatus = 'accepted';
done = true;
} else {
const data = await res.json().catch(() => ({}));
error = data.detail ?? 'Failed to accept proposal.';
}
}
async function decline() {
submitting = true;
error = '';
const res = await apiFetch(`${API_URL}/trade-proposals/${proposal.id}/decline`, { method: 'POST' });
submitting = false;
if (res.ok) {
doneStatus = proposal.direction === 'outgoing' ? 'withdrawn' : 'declined';
done = true;
} else {
const data = await res.json().catch(() => ({}));
error = data.detail ?? 'Failed to decline proposal.';
}
}
</script>
<main>
{#if loading}
<div class="center-screen">
<p class="status-text">Loading...</p>
</div>
{:else if notFound}
<div class="center-screen">
<p class="status-text">Proposal not found.</p>
<a href="/profile" class="action-btn secondary">← Back to Profile</a>
</div>
{:else if done}
<div class="center-screen">
<div class="done-icon"></div>
{#if doneStatus === 'accepted'}
<h2 class="done-title">Trade Accepted</h2>
<p class="done-body">The cards have been exchanged. Check your collection.</p>
{:else if doneStatus === 'declined'}
<h2 class="done-title">Proposal Declined</h2>
<p class="done-body">The trade offer from <strong>{proposal.proposer_username}</strong> has been declined.</p>
{:else}
<h2 class="done-title">Proposal Withdrawn</h2>
<p class="done-body">Your trade offer to <strong>{proposal.recipient_username}</strong> has been withdrawn.</p>
{/if}
<a href="/profile" class="action-btn secondary">← Back to Profile</a>
</div>
{:else}
<div class="trade-layout">
<div class="trade-header">
<h1 class="trade-title">
{#if proposal.direction === 'incoming'}
Trade offer <span class="trade-with">from</span> <strong class="trade-username">{proposal.proposer_username}</strong>
{:else}
Your offer <span class="trade-with">to</span> <strong class="trade-username">{proposal.recipient_username}</strong>
{/if}
</h1>
<p class="action-error" style="min-height: 1.2em">{error}</p>
{#if proposal.status !== 'pending'}
<span class="status-badge {proposal.status}">{proposal.status}</span>
{:else if proposal.direction === 'incoming'}
{#if confirmingAccept}
<span class="confirm-label">Accept and exchange cards?</span>
<button class="action-btn primary" disabled={submitting} onclick={accept}>
{submitting ? 'Accepting...' : 'Confirm'}
</button>
<button class="action-btn secondary" disabled={submitting} onclick={() => confirmingAccept = false}>
Cancel
</button>
{:else}
<button class="action-btn destructive" disabled={submitting} onclick={decline}>
{submitting ? '...' : 'Decline'}
</button>
<button class="action-btn primary" disabled={submitting} onclick={() => confirmingAccept = true}>
Accept
</button>
{/if}
{:else}
<button class="action-btn destructive" disabled={submitting} onclick={decline}>
{submitting ? '...' : 'Withdraw'}
</button>
{/if}
</div>
<div class="trade-panels">
<!-- THEIR OFFER panel (what the proposer is giving) -->
<div class="panel your-panel">
<div class="panel-header">
<span class="panel-title">
{proposal.direction === 'incoming' ? `${proposal.proposer_username}'s Offer` : 'Your Offer'}
</span>
<span class="panel-count">{proposal.offered_cards.length} card{proposal.offered_cards.length === 1 ? '' : 's'}</span>
</div>
<div class="panel-cards">
{#if proposal.offered_cards.length === 0}
<div class="empty-offer"><p>No cards offered</p></div>
{:else}
<div class="card-scroll">
{#each proposal.offered_cards as card (card.id)}
<div class="card-wrap">
<Card {card} noHover={true} />
</div>
{/each}
</div>
{/if}
</div>
</div>
<!-- REQUESTED panel (what the proposer wants) -->
<div class="panel their-panel">
<div class="panel-header">
<span class="panel-title">
{proposal.direction === 'incoming' ? 'They Want' : `${proposal.recipient_username}'s Cards`}
</span>
<span class="panel-count">{proposal.requested_cards.length} card{proposal.requested_cards.length === 1 ? '' : 's'}</span>
</div>
<div class="panel-cards">
{#if proposal.requested_cards.length === 0}
<div class="empty-offer"><p>No cards requested</p></div>
{:else}
<div class="card-scroll">
{#each proposal.requested_cards as card (card.id)}
<div class="card-wrap">
<Card {card} noHover={true} />
</div>
{/each}
</div>
{/if}
</div>
</div>
</div>
</div>
{/if}
</main>
<style>
main {
height: 100vh;
background: var(--color-bg);
display: flex;
flex-direction: column;
overflow: hidden;
}
/* ── Trade layout ── */
.trade-layout {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow: hidden;
}
.trade-header {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.75rem 1.5rem;
border-bottom: 1px solid var(--color-border-dim);
flex-shrink: 0;
}
.trade-title {
font-family: 'Cinzel', serif;
font-size: var(--text-lg);
font-weight: 700;
letter-spacing: 0.08em;
color: var(--color-gold);
margin: 0;
flex: 1;
}
.trade-with {
font-family: 'Crimson Text', serif;
font-size: var(--text-lg);
font-style: italic;
font-weight: 400;
color: rgba(240, 180, 80, 0.5);
letter-spacing: 0;
}
.trade-username {
font-family: 'Cinzel', serif;
font-size: var(--text-lg);
font-weight: 700;
color: var(--color-bronze);
letter-spacing: 0.08em;
}
/* ── Panels ── */
.trade-panels {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0;
flex: 1;
min-height: 0;
}
.panel {
display: flex;
flex-direction: column;
padding: 1.25rem 1.5rem;
gap: 1rem;
min-height: 0;
}
.your-panel { border-right: 1px solid var(--color-border-dim); }
.panel-header {
display: flex;
align-items: center;
gap: 0.75rem;
flex-shrink: 0;
}
.panel-title {
font-family: 'Cinzel', serif;
font-size: var(--text-base);
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: rgba(240, 180, 80, 0.5);
}
.panel-count {
font-family: 'Crimson Text', serif;
font-size: var(--text-base);
font-style: italic;
color: rgba(240, 180, 80, 0.3);
}
.panel-cards {
flex: 1;
min-height: 0;
overflow: hidden;
}
.empty-offer {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.empty-offer p {
font-family: 'Crimson Text', serif;
font-size: var(--text-md);
font-style: italic;
color: rgba(240, 180, 80, 0.2);
margin: 0;
}
.card-scroll {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-content: flex-start;
height: 100%;
overflow-y: auto;
padding-bottom: 0.5rem;
}
.card-wrap {
width: 300px;
height: 420px;
margin-right: -114px;
margin-bottom: -160px;
flex-shrink: 0;
pointer-events: none;
}
.card-wrap :global(.card) {
transform: scale(0.62);
transform-origin: top left;
}
/* ── Buttons ── */
.action-btn {
font-family: 'Cinzel', serif;
font-size: var(--btn-font-lg);
font-weight: 700;
letter-spacing: 0.07em;
text-transform: uppercase;
border-radius: var(--radius-md);
padding: var(--btn-padding-lg);
cursor: pointer;
transition: background 0.15s, transform 0.1s;
border: none;
flex-shrink: 0;
text-decoration: none;
display: inline-block;
}
.action-btn.primary {
background: var(--color-bronze);
color: var(--color-btn-text);
box-shadow: var(--shadow-glow);
}
.action-btn.primary:hover:not(:disabled) { background: var(--color-bronze-hover); transform: translateY(-1px); }
.action-btn.destructive {
background: rgba(180, 40, 40, 0.8);
color: #fff;
}
.action-btn.destructive:hover:not(:disabled) { background: rgba(210, 50, 50, 0.9); }
.action-btn.secondary {
background: var(--color-surface-raised);
border: 1px solid var(--color-border-subtle);
color: var(--color-gold);
}
.action-btn.secondary:hover { border-color: var(--color-bronze); background: #4d3010; }
.action-btn:disabled { opacity: 0.5; cursor: default; transform: none; }
.confirm-label {
font-family: 'Crimson Text', serif;
font-size: var(--text-md);
font-style: italic;
color: rgba(240, 180, 80, 0.7);
}
.action-error {
font-family: 'Crimson Text', serif;
font-size: var(--text-base);
color: var(--color-error);
margin: 0;
flex-shrink: 0;
}
.status-badge {
font-family: 'Cinzel', serif;
font-size: var(--text-sm);
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
padding: 5px 12px;
border-radius: var(--radius-sm);
border: 1px solid;
flex-shrink: 0;
}
.status-badge.accepted { color: var(--color-success); border-color: rgba(106, 170, 106, 0.4); }
.status-badge.declined { color: var(--color-error); border-color: rgba(200, 64, 64, 0.4); }
.status-badge.expired { color: rgba(240, 180, 80, 0.3); border-color: var(--color-border-dim); }
.status-badge.withdrawn { color: rgba(240, 180, 80, 0.3); border-color: var(--color-border-dim); }
/* ── Center screen states ── */
.center-screen {
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1.5rem;
padding: 2rem;
}
.status-text {
font-family: 'Crimson Text', serif;
font-size: var(--text-md);
font-style: italic;
color: var(--color-gold-faint);
margin: 0;
}
.done-icon { font-size: var(--text-3xl); color: rgba(200, 134, 26, 0.5); }
.done-title {
font-family: 'Cinzel', serif;
font-size: var(--text-xl);
font-weight: 700;
letter-spacing: 0.08em;
color: var(--color-gold);
margin: 0;
}
.done-body {
font-family: 'Crimson Text', serif;
font-size: var(--text-lg);
font-style: italic;
color: var(--color-gold-dim);
text-align: center;
margin: 0;
max-width: 400px;
}
.done-body strong { color: var(--color-gold-muted); font-style: normal; }
/* ── Responsive ── */
@media (max-width: 640px) {
.trade-panels {
grid-template-columns: 1fr;
grid-template-rows: 1fr 1fr;
overflow-y: auto;
}
.your-panel { border-right: none; border-bottom: 1px solid var(--color-border-dim); }
}
</style>
+290
View File
@@ -0,0 +1,290 @@
<script lang="ts">
import { API_URL, apiFetch } from '$lib/api.js';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
let query = $state('');
let results: any[] = $state([]);
let loading = $state(false);
let searched = $state(false);
onMount(() => {
if (!localStorage.getItem('token')) goto('/auth');
});
$effect(() => {
const q = query;
if (q.trim().length === 0) {
results = [];
searched = false;
return;
}
if (q.trim().length < 2) {
results = [];
searched = false;
return;
}
const timer = setTimeout(async () => {
loading = true;
const res = await apiFetch(`${API_URL}/users?q=${encodeURIComponent(q.trim())}`);
if (res.ok) {
results = await res.json();
}
loading = false;
searched = true;
}, 300);
return () => clearTimeout(timer);
});
</script>
<div class="page">
<header class="page-header">
<h1>Players</h1>
<p class="subtitle">Search for other players by username</p>
</header>
<div class="search-wrap">
<div class="search-field">
<svg class="search-icon" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="8.5" cy="8.5" r="5.5" stroke="currentColor" stroke-width="1.5"/>
<path d="M13 13l3.5 3.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
<input
type="text"
bind:value={query}
placeholder="Search by username..."
autocomplete="off"
spellcheck="false"
/>
</div>
</div>
<div class="results-area">
{#if query.trim() === ''}
<p class="hint">Type a username to search</p>
{:else if loading}
<p class="hint">Searching...</p>
{:else if searched && results.length === 0}
<p class="hint">No players found</p>
{:else if results.length > 0}
<ul class="results">
{#each results as user}
<li class="result-row">
<a
class="username"
href="/profile/{user.username}"
>{user.username}</a>
<span class="stats">
<span class="stat win">W: {user.wins}</span>
<span class="sep">·</span>
<span class="stat loss">L: {user.losses}</span>
<span class="sep">·</span>
<span class="stat rate">{user.win_rate}% win rate</span>
</span>
<div class="actions">
<a class="btn-primary" href="/trade/offer/{user.username}">Propose Trade</a>
<button class="btn-disabled" disabled title="Coming soon">Challenge</button>
</div>
</li>
{/each}
</ul>
{/if}
</div>
</div>
<style>
.page {
max-width: 760px;
margin: 0 auto;
padding: 3rem 2rem 4rem;
}
.page-header {
margin-bottom: 2.5rem;
}
h1 {
font-family: 'Cinzel', serif;
font-size: var(--text-3xl);
font-weight: 900;
color: var(--color-gold);
letter-spacing: 0.1em;
text-transform: uppercase;
margin: 0 0 0.4rem;
}
.subtitle {
font-family: 'Crimson Text', serif;
font-size: var(--text-lg);
color: var(--color-gold-dim);
margin: 0;
}
/* Search */
.search-wrap {
margin-bottom: 2rem;
}
.search-field {
position: relative;
}
.search-icon {
position: absolute;
left: 14px;
top: 50%;
transform: translateY(-50%);
width: 18px;
height: 18px;
color: var(--color-gold-faint);
pointer-events: none;
}
input {
width: 100%;
box-sizing: border-box;
background: var(--color-surface);
border: 1.5px solid var(--color-bronze);
border-radius: var(--radius-lg);
padding: 13px 16px 13px 42px;
font-family: 'Crimson Text', serif;
font-size: var(--text-lg);
color: var(--color-gold);
outline: none;
transition: border-color 0.15s;
}
input::placeholder {
color: var(--color-gold-faint);
}
input:focus {
border-color: var(--color-gold);
}
/* States */
.hint {
font-family: 'Crimson Text', serif;
font-style: italic;
font-size: var(--text-lg);
color: var(--color-gold-faint);
text-align: center;
margin: 3rem 0;
}
/* Results */
.results {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.result-row {
display: flex;
align-items: center;
gap: 1.5rem;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: 0.9rem 1.2rem;
transition: border-color 0.15s, box-shadow 0.15s;
}
.result-row:hover {
border-color: var(--color-bronze);
box-shadow: var(--shadow-subtle);
}
.username {
font-family: 'Cinzel', serif;
font-size: var(--text-md);
font-weight: 700;
letter-spacing: 0.06em;
color: var(--color-gold);
text-decoration: none;
min-width: 120px;
transition: color 0.15s;
}
.username:hover {
color: var(--color-bronze-hover);
text-decoration: underline;
}
.stats {
flex: 1;
font-family: 'Crimson Text', serif;
font-size: var(--text-md);
color: var(--color-gold-dim);
display: flex;
align-items: center;
gap: 0.4rem;
flex-wrap: wrap;
}
.stat.win { color: var(--color-success); }
.stat.loss { color: var(--color-error); }
.stat.rate { color: var(--color-gold-dim); }
.sep {
color: var(--color-border);
font-size: var(--text-base);
}
.actions {
display: flex;
gap: 0.5rem;
flex-shrink: 0;
}
.btn-primary {
font-family: 'Cinzel', serif;
font-size: var(--btn-font-md);
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
background: var(--color-bronze);
color: var(--color-btn-text);
border: none;
border-radius: var(--radius-md);
padding: var(--btn-padding-md);
cursor: pointer;
text-decoration: none;
display: inline-block;
transition: background 0.15s;
}
.btn-primary:hover {
background: var(--color-bronze-hover);
}
.btn-disabled {
font-family: 'Cinzel', serif;
font-size: var(--btn-font-md);
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
background: rgba(200, 134, 26, 0.15);
color: rgba(240, 208, 128, 0.3);
border: 1px solid rgba(107, 76, 30, 0.25);
border-radius: var(--radius-md);
padding: var(--btn-padding-md);
cursor: not-allowed;
opacity: 1;
}
@media (max-width: 560px) {
.result-row {
flex-wrap: wrap;
gap: 0.75rem;
}
.username { min-width: unset; }
.stats { width: 100%; }
.actions { width: 100%; }
}
</style>
+14 -16
View File
@@ -50,11 +50,9 @@
</main>
<style>
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;700&family=Crimson+Text:ital,wght@0,400;0,600;1,400&display=swap');
main {
min-height: 100vh;
background: #0d0a04;
background: var(--color-bg);
display: flex;
align-items: center;
justify-content: center;
@@ -63,9 +61,9 @@
.card {
width: 380px;
background: #3d2507;
border: 2px solid #c8861a;
border-radius: 12px;
background: var(--color-surface-raised);
border: 2px solid var(--color-bronze);
border-radius: var(--radius-xl);
padding: 2rem;
display: flex;
flex-direction: column;
@@ -74,17 +72,17 @@
.title {
font-family: 'Cinzel', serif;
font-size: 20px;
font-size: var(--text-xl);
font-weight: 700;
color: #f5d060;
color: var(--color-gold);
margin: 0;
text-align: center;
}
.hint {
font-family: 'Crimson Text', serif;
font-size: 15px;
color: rgba(245, 208, 96, 0.7);
font-size: var(--text-md);
color: var(--color-gold-dim);
margin: 0;
text-align: center;
line-height: 1.6;
@@ -92,17 +90,17 @@
.btn {
width: 100%;
padding: 10px;
background: #c8861a;
color: #fff8e0;
padding: var(--btn-padding-lg);
background: var(--color-bronze);
color: var(--color-btn-text);
border: none;
border-radius: 6px;
border-radius: var(--radius-md);
font-family: 'Cinzel', serif;
font-size: 13px;
font-size: var(--btn-font-lg);
font-weight: 700;
cursor: pointer;
transition: background 0.15s;
}
.btn:hover { background: #e09820; }
.btn:hover { background: var(--color-bronze-hover); }
</style>
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.