This commit is contained in:
2026-03-26 00:51:25 +01:00
parent 99db0b3c67
commit ef4496aa5d
31 changed files with 4185 additions and 452 deletions

View File

@@ -0,0 +1,454 @@
<script>
import Card from '$lib/Card.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 = [],
selectedIds = $bindable(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) {
return str.charAt(0).toUpperCase() + str.slice(1).replace(/_/g, ' ');
}
let sortBy = $state('name');
let sortAsc = $state(true);
let selectedRarities = $state(new Set(RARITIES));
let selectedTypes = $state(new Set(TYPES));
let costMin = $state(1);
let costMax = $state(10);
let filtersOpen = $state(false);
let searchQuery = $state('');
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))
);
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;
});
return result;
});
function toggleSort(val) {
if (sortBy === val) sortAsc = !sortAsc;
else { sortBy = val; sortAsc = true; }
}
function toggleRarity(r) {
const s = new Set(selectedRarities);
s.has(r) ? s.delete(r) : s.add(r);
selectedRarities = s;
}
function toggleType(t) {
const s = new Set(selectedTypes);
s.has(t) ? s.delete(t) : s.add(t);
selectedTypes = s;
}
function allRaritiesSelected() { return selectedRarities.size === RARITIES.length; }
function allTypesSelected() { return selectedTypes.size === TYPES.length; }
function toggleAllRarities() { selectedRarities = allRaritiesSelected() ? new Set() : new Set(RARITIES); }
function toggleAllTypes() { selectedTypes = allTypesSelected() ? new Set() : new Set(TYPES); }
function toggleCard(id) {
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;
}
s.add(id);
}
selectedIds = s;
}
</script>
<div class="selector">
{#if showFooter && onclose}
<div class="top-bar">
<span class="counter">{selectedIds.size} card{selectedIds.size === 1 ? '' : 's'} selected</span>
<button class="done-btn" onclick={onclose}>Done</button>
</div>
{/if}
<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]}
<button class="sort-btn" class:active={sortBy === val} onclick={() => toggleSort(val)}>
{lbl}
{#if sortBy === val}<span class="sort-arrow">{sortAsc ? '↑' : '↓'}</span>{/if}
</button>
{/each}
<input
class="search-input"
type="search"
placeholder="Search by name…"
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>
{#if filtersOpen}
<div class="filters">
<div class="filter-group">
<div class="filter-group-header">
<span class="filter-group-label">Rarity</span>
<button class="select-all" onclick={toggleAllRarities}>{allRaritiesSelected() ? 'Deselect all' : 'Select all'}</button>
</div>
<div class="checkboxes">
{#each RARITIES as r}
<label class="checkbox-label">
<input type="checkbox" checked={selectedRarities.has(r)} onchange={() => toggleRarity(r)} />
{label(r)}
</label>
{/each}
</div>
</div>
<div class="filter-group">
<div class="filter-group-header">
<span class="filter-group-label">Type</span>
<button class="select-all" onclick={toggleAllTypes}>{allTypesSelected() ? 'Deselect all' : 'Select all'}</button>
</div>
<div class="checkboxes">
{#each TYPES as t}
<label class="checkbox-label">
<input type="checkbox" checked={selectedTypes.has(t)} onchange={() => toggleType(t)} />
{label(t)}
</label>
{/each}
</div>
</div>
<div class="filter-group">
<div class="filter-group-header">
<span class="filter-group-label">Cost</span>
<button class="select-all" onclick={() => { costMin = 1; costMax = 10; }}>Reset</button>
</div>
<div class="cost-range">
<span class="range-label">Min: {costMin}</span>
<input type="range" min="1" max="10" bind:value={costMin} oninput={() => { if (costMin > costMax) costMax = costMin; }} />
<span class="range-label">Max: {costMax}</span>
<input type="range" min="1" max="10" bind:value={costMax} oninput={() => { if (costMax < costMin) costMin = costMax; }} />
</div>
</div>
</div>
{/if}
</div>
{#if filtered.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>
{/if}
</button>
{/each}
</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;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
}
.toolbar {
flex-shrink: 0;
padding: 1.5rem 2rem 1rem;
border-bottom: 1px solid rgba(107, 76, 30, 0.3);
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.sort-row {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.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;
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); }
.toolbar-label {
font-family: 'Cinzel', serif;
font-size: 11px;
color: rgba(240, 180, 80, 0.5);
letter-spacing: 0.08em;
text-transform: uppercase;
margin-right: 0.25rem;
}
.sort-btn {
font-family: 'Cinzel', serif;
font-size: 11px;
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;
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; }
.filter-toggle {
font-family: 'Cinzel', serif;
font-size: 11px;
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;
cursor: pointer;
margin-left: 0.5rem;
position: relative;
transition: all 0.15s;
}
.filter-toggle:hover { border-color: #c8861a; color: #f0d080; }
.filter-dot {
position: absolute;
top: -3px;
right: -3px;
width: 7px;
height: 7px;
border-radius: 50%;
background: #c8861a;
}
.filters {
display: flex;
gap: 3rem;
padding-top: 0.5rem;
flex-wrap: wrap;
}
.filter-group { display: flex; flex-direction: column; gap: 0.5rem; }
.filter-group-header { display: flex; align-items: baseline; gap: 1rem; }
.filter-group-label {
font-family: 'Cinzel', serif;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: rgba(240, 180, 80, 0.5);
}
.select-all {
font-family: 'Crimson Text', serif;
font-size: 12px;
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;
}
.select-all:hover { color: #f0d080; }
.checkboxes { display: flex; flex-wrap: wrap; gap: 0.4rem 1rem; }
.checkbox-label {
display: flex;
align-items: center;
gap: 0.4rem;
font-family: 'Crimson Text', serif;
font-size: 14px;
color: rgba(240, 180, 80, 0.8);
cursor: pointer;
}
.checkbox-label input {
accent-color: #c8861a;
width: 14px;
height: 14px;
cursor: pointer;
}
.cost-range { display: flex; flex-direction: column; gap: 0.4rem; }
.range-label {
font-family: 'Cinzel', serif;
font-size: 11px;
color: rgba(240, 180, 80, 0.7);
min-width: 60px;
}
input[type=range] {
accent-color: #c8861a;
width: 160px;
}
.grid {
flex: 1;
overflow-y: auto;
display: flex;
flex-wrap: wrap;
gap: 20px;
justify-content: center;
padding: 2rem 2rem 0;
}
.card-wrap {
all: unset;
position: relative;
cursor: pointer;
display: block;
border-radius: 12px;
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);
}
.card-wrap.selected {
box-shadow: 0 0 0 3px #c8861a, 0 0 20px rgba(200, 134, 26, 0.4);
}
.card-wrap.disabled {
opacity: 0.4;
cursor: not-allowed;
}
.selected-badge {
position: absolute;
top: 80px;
left: 50%;
transform: translateX(-50%);
background: #c8861a;
color: #fff8e0;
font-family: 'Cinzel', serif;
font-size: 23.875px;
font-weight: 1000;
padding: 4px 10px;
border-radius: 23px;
border: black 3px solid;
pointer-events: none;
z-index: 10;
}
.status {
font-family: 'Crimson Text', serif;
font-size: 16px;
font-style: italic;
color: rgba(240, 180, 80, 0.5);
text-align: center;
margin-top: 4rem;
}
.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;
}
.counter {
font-family: 'Cinzel', serif;
font-size: 13px;
color: rgba(240, 180, 80, 0.6);
}
.done-btn {
font-family: 'Cinzel', serif;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
background: #3d2507;
border: 1px solid #c8861a;
border-radius: 4px;
color: #f0d080;
padding: 8px 24px;
cursor: pointer;
transition: background 0.15s;
}
.done-btn:hover { background: #5a3510; }
</style>

View File

@@ -9,7 +9,8 @@
{ href: '/cards', label: 'Cards' },
{ href: '/decks', label: 'Decks' },
{ href: '/play', label: 'Play' },
{ href: '/how-to-play', label: 'How to Play' },
{ href: '/trade', label: 'Trade' },
{ href: '/store', label: 'Store' },
];
function close() { menuOpen = false; }

View File

@@ -5,15 +5,35 @@
import { page } from '$app/state';
let { children } = $props();
</script>
{#if !['auth', 'forgot-password'].some(p => page.url.pathname.startsWith(`/${p}`))}
<Header />
{/if}
const showHeader = $derived(!['auth', 'forgot-password'].some(p => page.url.pathname.startsWith(`/${p}`)));
</script>
<svelte:head>
<title>WikiTCG</title>
<link rel="icon" href={favicon} />
</svelte:head>
{@render children()}
<div class="layout">
{#if showHeader}
<Header />
{/if}
<div class="page-area">
{@render children()}
</div>
</div>
<style>
.layout {
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden;
}
.page-area {
flex: 1;
overflow: hidden;
min-height: 0;
}
</style>

View File

@@ -9,6 +9,7 @@
let loading = $state(false);
let boosters = $state(null);
let countdown = $state(null);
let emailVerified = $state(true);
let countdownDisplay = $state('');
let countdownInterval = null;
@@ -27,9 +28,10 @@
async function fetchBoosters() {
const res = await apiFetch(`${API_URL}/boosters`);
if (res.status === 401) { goto('/auth'); return; }
const [count, countdownTs] = await res.json();
boosters = count;
countdown = countdownTs ? new Date(countdownTs) : null;
const data = await res.json();
boosters = data.count;
countdown = data.countdown ? new Date(data.countdown) : null;
emailVerified = data.email_verified;
startCountdown();
}
@@ -140,7 +142,9 @@
</div>
<!-- Idle pack -->
{#if boosters !== null && boosters > 0 && phase === 'idle'}
{#if !emailVerified}
<p class="verify-notice">Verify your email to begin opening packs</p>
{:else if boosters !== null && boosters > 0 && phase === 'idle'}
<div class="pack-wrap" bind:this={packRef}>
<button class="pack-btn" onclick={openPack}>
<div class="booster-pack">
@@ -234,6 +238,15 @@
margin: 0;
}
.verify-notice {
font-family: 'Crimson Text', serif;
font-size: 18px;
font-style: italic;
color: rgba(240, 180, 80, 0.55);
text-align: center;
margin-top: 4rem;
}
.pack-wrap {
display: flex;
justify-content: center;

View File

@@ -30,14 +30,17 @@
let sortAsc = $state(true);
let costMin = $state(1);
let costMax = $state(11);
let costMax = $state(10);
let searchQuery = $state('');
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
c.cost <= costMax &&
(!q || c.name.toLowerCase().includes(q))
);
result = result.slice().sort((a, b) => {
@@ -189,6 +192,13 @@
</button>
{/each}
<input
class="search-input"
type="search"
placeholder="Search by name…"
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}
@@ -236,14 +246,14 @@
<div class="filter-group">
<div class="filter-group-header">
<span class="filter-group-label">Cost</span>
<button class="select-all" onclick={() => { costMin = 1; costMax = 11; }}>Reset</button>
<button class="select-all" onclick={() => { costMin = 1; costMax = 10; }}>Reset</button>
</div>
<div class="cost-range">
<span class="range-label">Min: {costMin}</span>
<input type="range" min="1" max="11" bind:value={costMin}
<input type="range" min="1" max="10" bind:value={costMin}
oninput={() => { if (costMin > costMax) costMax = costMin; }} />
<span class="range-label">Max: {costMax}</span>
<input type="range" min="1" max="11" bind:value={costMax}
<input type="range" min="1" max="10" bind:value={costMax}
oninput={() => { if (costMax < costMin) costMin = costMax; }} />
</div>
</div>
@@ -327,6 +337,22 @@
flex-wrap: wrap;
}
.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;
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); }
.toolbar-label {
font-family: 'Cinzel', serif;
font-size: 11px;
@@ -374,7 +400,7 @@
color: rgba(240, 180, 80, 0.6);
padding: 4px 10px;
cursor: pointer;
margin-left: auto;
margin-left: 0.5rem;
position: relative;
transition: all 0.15s;
}
@@ -530,6 +556,7 @@
align-items: center;
justify-content: center;
z-index: 100;
backdrop-filter: blur(6px);
}
.card-popup {

View File

@@ -1,10 +1,10 @@
<script>
import { API_URL, WS_URL } from '$lib/api.js';
import { API_URL } from '$lib/api.js';
import { apiFetch } from '$lib/api.js';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import { page } from '$app/stores';
import Card from '$lib/Card.svelte';
import CardSelector from '$lib/CardSelector.svelte';
const deckId = $derived($page.params.id);
const token = () => localStorage.getItem('token');
@@ -17,79 +17,10 @@
let loading = $state(true);
let saving = $state(false);
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 sortBy = $state('name');
let sortAsc = $state(true);
let selectedRarities = $state(new Set(RARITIES));
let selectedTypes = $state(new Set(TYPES));
let costMin = $state(1);
let costMax = $state(11);
let filtersOpen = $state(false);
function label(str) {
return str.charAt(0).toUpperCase() + str.slice(1).replace(/_/g, ' ');
}
let filtered = $derived.by(() => {
let result = allCards.filter(c =>
selectedRarities.has(c.card_rarity) &&
selectedTypes.has(c.card_type) &&
c.cost >= costMin &&
c.cost <= costMax
);
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;
});
return result;
});
function toggleSort(val) {
if (sortBy === val) sortAsc = !sortAsc;
else { sortBy = val; sortAsc = true; }
}
function toggleRarity(r) {
const s = new Set(selectedRarities);
s.has(r) ? s.delete(r) : s.add(r);
selectedRarities = s;
}
function toggleType(t) {
const s = new Set(selectedTypes);
s.has(t) ? s.delete(t) : s.add(t);
selectedTypes = s;
}
function allRaritiesSelected() { return selectedRarities.size === RARITIES.length; }
function allTypesSelected() { return selectedTypes.size === TYPES.length; }
function toggleAllRarities() { selectedRarities = allRaritiesSelected() ? new Set() : new Set(RARITIES); }
function toggleAllTypes() { selectedTypes = allTypesSelected() ? new Set() : new Set(TYPES); }
const selectedCost = $derived(
allCards.filter(c => selectedIds.has(c.id)).reduce((sum, c) => sum + c.cost, 0)
);
function toggleCard(id) {
const s = new Set(selectedIds);
if (s.has(id)) {
s.delete(id);
} else {
const card = allCards.find(c => c.id === id);
if (card && selectedCost + card.cost > 50) return;
s.add(id);
}
selectedIds = s;
}
function startEditName() {
nameInput = deckName;
editingName = true;
@@ -104,9 +35,7 @@
saving = true;
await apiFetch(`${API_URL}/decks/${deckId}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: deckName,
card_ids: [...selectedIds],
@@ -130,7 +59,6 @@
const currentCardIds = await deckCardsRes.json();
selectedIds = new Set(currentCardIds);
// Get deck name
const decksRes = await apiFetch(`${API_URL}/decks`);
const decks = await decksRes.json();
const deck = decks.find(d => d.id === deckId);
@@ -164,92 +92,17 @@
</button>
</div>
</div>
<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]}
<button class="sort-btn" class:active={sortBy === val} onclick={() => toggleSort(val)}>
{lbl}
{#if sortBy === val}<span class="sort-arrow">{sortAsc ? '↑' : '↓'}</span>{/if}
</button>
{/each}
<button class="filter-toggle" onclick={() => filtersOpen = !filtersOpen}>
{filtersOpen ? 'Hide filters' : 'Filter'}
{#if selectedRarities.size < RARITIES.length || selectedTypes.size < TYPES.length || costMin > 1 || costMax < 11}
<span class="filter-dot"></span>
{/if}
</button>
</div>
{#if filtersOpen}
<div class="filters">
<div class="filter-group">
<div class="filter-group-header">
<span class="filter-group-label">Rarity</span>
<button class="select-all" onclick={toggleAllRarities}>{allRaritiesSelected() ? 'Deselect all' : 'Select all'}</button>
</div>
<div class="checkboxes">
{#each RARITIES as r}
<label class="checkbox-label">
<input type="checkbox" checked={selectedRarities.has(r)} onchange={() => toggleRarity(r)} />
{label(r)}
</label>
{/each}
</div>
</div>
<div class="filter-group">
<div class="filter-group-header">
<span class="filter-group-label">Type</span>
<button class="select-all" onclick={toggleAllTypes}>{allTypesSelected() ? 'Deselect all' : 'Select all'}</button>
</div>
<div class="checkboxes">
{#each TYPES as t}
<label class="checkbox-label">
<input type="checkbox" checked={selectedTypes.has(t)} onchange={() => toggleType(t)} />
{label(t)}
</label>
{/each}
</div>
</div>
<div class="filter-group">
<div class="filter-group-header">
<span class="filter-group-label">Cost</span>
<button class="select-all" onclick={() => { costMin = 1; costMax = 11; }}>Reset</button>
</div>
<div class="cost-range">
<span class="range-label">Min: {costMin}</span>
<input type="range" min="1" max="11" bind:value={costMin} oninput={() => { if (costMin > costMax) costMax = costMin; }} />
<span class="range-label">Max: {costMax}</span>
<input type="range" min="1" max="11" bind:value={costMax} oninput={() => { if (costMax < costMin) costMin = costMax; }} />
</div>
</div>
</div>
{/if}
</div>
{#if loading}
<p class="status">Loading...</p>
{:else if filtered.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={!selectedIds.has(card.id) && selectedCost + card.cost > 50}
onclick={() => toggleCard(card.id)}
>
<Card {card} noHover={true} />
{#if selectedIds.has(card.id)}
<div class="selected-badge"></div>
{/if}
</button>
{/each}
</div>
<CardSelector
allCards={allCards}
bind:selectedIds={selectedIds}
costLimit={50}
showFooter={false}
/>
{/if}
</main>
@@ -258,23 +111,17 @@
main {
height: 100vh;
overflow-y: auto;
overflow: hidden;
background: #0d0a04;
padding: 0 2rem 2rem 2rem;
display: flex;
flex-direction: column;
}
.toolbar {
position: sticky;
top: 0;
z-index: 50;
flex-shrink: 0;
background: #0d0a04;
padding-bottom: 1rem;
padding: 1.5rem 2rem 1rem;
border-bottom: 1px solid rgba(107, 76, 30, 0.3);
margin-bottom: 2rem;
padding-top: 1.5rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.deck-header {
@@ -348,187 +195,6 @@
.done-btn:hover:not(:disabled) { background: #5a3510; }
.done-btn:disabled { opacity: 0.5; cursor: default; }
/* Reuse cards page toolbar styles */
.sort-row {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.toolbar-label {
font-family: 'Cinzel', serif;
font-size: 11px;
color: rgba(240, 180, 80, 0.5);
letter-spacing: 0.08em;
text-transform: uppercase;
margin-right: 0.25rem;
}
.sort-btn {
font-family: 'Cinzel', serif;
font-size: 11px;
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;
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; }
.filter-toggle {
font-family: 'Cinzel', serif;
font-size: 11px;
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;
cursor: pointer;
margin-left: auto;
position: relative;
transition: all 0.15s;
}
.filter-toggle:hover { border-color: #c8861a; color: #f0d080; }
.filter-dot {
position: absolute;
top: -3px;
right: -3px;
width: 7px;
height: 7px;
border-radius: 50%;
background: #c8861a;
}
.filters {
display: flex;
gap: 3rem;
padding-top: 1rem;
flex-wrap: wrap;
}
.filter-group { display: flex; flex-direction: column; gap: 0.5rem; }
.filter-group-header { display: flex; align-items: baseline; gap: 1rem; }
.filter-group-label {
font-family: 'Cinzel', serif;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: rgba(240, 180, 80, 0.5);
}
.select-all {
font-family: 'Crimson Text', serif;
font-size: 12px;
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;
}
.select-all:hover { color: #f0d080; }
.checkboxes { display: flex; flex-wrap: wrap; gap: 0.4rem 1rem; }
.checkbox-label {
display: flex;
align-items: center;
gap: 0.4rem;
font-family: 'Crimson Text', serif;
font-size: 14px;
color: rgba(240, 180, 80, 0.8);
cursor: pointer;
}
.checkbox-label input {
accent-color: #c8861a;
width: 14px;
height: 14px;
cursor: pointer;
}
.cost-range { display: flex; flex-direction: column; gap: 0.4rem; }
.range-label {
font-family: 'Cinzel', serif;
font-size: 11px;
color: rgba(240, 180, 80, 0.7);
min-width: 60px;
}
input[type=range] {
accent-color: #c8861a;
width: 160px;
}
/* Grid + selection */
.grid {
display: flex;
flex-wrap: wrap;
gap: 20px;
justify-content: center;
padding-bottom: 50px;
}
.card-wrap {
all: unset;
position: relative;
cursor: pointer;
display: block;
border-radius: 12px;
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);
}
.card-wrap.selected {
box-shadow: 0 0 0 3px #c8861a, 0 0 20px rgba(200, 134, 26, 0.4);
}
.card-wrap.disabled {
opacity: 0.4;
cursor: not-allowed;
}
.selected-badge {
position: absolute;
top: 80px;
left: 50%;
transform: translateX(-50%);
background: #c8861a;
color: #fff8e0;
font-family: 'Cinzel', serif;
font-size: 23.875px;
font-weight: 1000;
padding: 4px 10px;
border-radius: 23px;
border: black 3px solid;
pointer-events: none;
z-index: 10;
}
.status {
font-family: 'Crimson Text', serif;
font-size: 16px;
@@ -537,4 +203,4 @@
text-align: center;
margin-top: 4rem;
}
</style>
</style>

View File

@@ -113,7 +113,7 @@
<div class="rule-card">
<div class="rule-icon">🃏</div>
<h3 class="rule-title">No Card Limit</h3>
<p class="rule-body">There is no minimum or maximum number of cards. On the extreme ends, you can have just 4 11-cost cards, or 50 1-cost cards.</p>
<p class="rule-body">There is no minimum or maximum number of cards. On the extreme ends, you can have just 5 10-cost cards, or 50 1-cost cards.</p>
</div>
</div>
</section>

View File

@@ -304,6 +304,7 @@
{#if phase === 'idle'}
<div class="lobby">
<h1 class="lobby-title">Find a Match</h1>
<a href="/how-to-play" class="how-to-play-link">How to Play</a>
{#if decks.length === 0}
<p class="lobby-hint">You need a deck to play. <a href="/decks">Build one first.</a></p>
{:else}
@@ -538,6 +539,18 @@
.lobby-title.win { color: #6aaa6a; }
.lobby-title.lose { color: #c85050; }
.how-to-play-link {
font-family: 'Crimson Text', serif;
font-size: 14px;
font-style: italic;
color: rgba(240, 180, 80, 0.4);
text-decoration: underline;
transition: color 0.15s;
margin-top: -1rem;
padding-bottom: 20px;
}
.how-to-play-link:hover { color: rgba(240, 180, 80, 0.7); }
.lobby-hint {
font-family: 'Crimson Text', serif;
font-size: 16px;
@@ -643,6 +656,8 @@
color: #c85050;
margin: 0;
height: 1.4em;
margin-top: -1rem;
margin-bottom: -1rem;
}
.spinner {

View File

@@ -22,6 +22,18 @@
localStorage.removeItem('refresh_token');
goto('/auth');
}
let resendStatus = $state('');
async function resendVerification() {
resendStatus = 'sending';
await apiFetch(`${API_URL}/auth/resend-verification`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: profile.email }),
});
resendStatus = 'sent';
}
</script>
<main>
@@ -34,7 +46,17 @@
<div class="avatar">{profile.username[0].toUpperCase()}</div>
<div class="profile-info">
<h1 class="username">{profile.username}</h1>
<p class="email">{profile.email}</p>
<p class="email">
{profile.email}
{#if !profile.email_verified}
<span class="unverified-badge">unverified</span>
{/if}
</p>
{#if !profile.email_verified}
<button class="resend-btn" onclick={resendVerification} disabled={resendStatus === 'sending' || resendStatus === 'sent'}>
{resendStatus === 'sent' ? 'Email sent' : resendStatus === 'sending' ? 'Sending...' : 'Resend verification email'}
</button>
{/if}
<p class="joined">Member since {new Date(profile.created_at).toLocaleDateString('en-GB', { year: 'numeric', month: 'long', day: 'numeric' })}</p>
</div>
<button class="logout-btn" onclick={logout}>Log Out</button>
@@ -43,6 +65,15 @@
<div class="section-divider"></div>
<div class="shards-row">
<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>
</div>
<div class="section-divider"></div>
<h2 class="section-title">Stats</h2>
<div class="stats-grid">
<div class="stat-card">
@@ -164,6 +195,37 @@
margin: 0;
}
.unverified-badge {
font-family: 'Cinzel', serif;
font-size: 9px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: #c87830;
border: 1px solid rgba(200, 120, 48, 0.4);
border-radius: 3px;
padding: 1px 5px;
vertical-align: middle;
margin-left: 6px;
}
.resend-btn {
font-family: 'Crimson Text', serif;
font-size: 13px;
font-style: italic;
color: rgba(240, 180, 80, 0.5);
background: none;
border: none;
padding: 0;
cursor: pointer;
text-decoration: underline;
transition: color 0.15s;
text-align: left;
}
.resend-btn:hover:not(:disabled) { color: rgba(240, 180, 80, 0.8); }
.resend-btn:disabled { cursor: default; opacity: 0.6; }
.joined {
font-family: 'Crimson Text', serif;
font-size: 13px;
@@ -206,6 +268,53 @@
.reset-link:hover { color: rgba(240, 180, 80, 0.7); }
.shards-row {
display: flex;
align-items: center;
gap: 0.5rem;
}
.shards-link {
font-family: 'Cinzel', serif;
font-size: 10px;
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;
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-icon {
font-size: 22px;
color: #7ecfcf;
position: relative;
top: -0.1em;
}
.shards-value {
font-family: 'Cinzel', serif;
font-size: 28px;
font-weight: 700;
color: #7ecfcf;
}
.shards-label {
font-family: 'Cinzel', serif;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: rgba(126, 207, 207, 0.5);
margin-top: 4px;
}
.section-divider {
height: 1px;
background: rgba(107, 76, 30, 0.3);

View File

@@ -0,0 +1,288 @@
<script>
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 selectorOpen = $state(false);
let shattering = $state(false);
let result = $state(null); // { gained, shards }
const selectedCards = $derived(allCards.filter(c => selectedIds.has(c.id)));
const totalYield = $derived(selectedCards.reduce((sum, c) => sum + c.cost, 0));
onMount(async () => {
if (!localStorage.getItem('token')) { goto('/auth'); return; }
const [cardsRes, profileRes] = await Promise.all([
apiFetch(`${API_URL}/cards`),
apiFetch(`${API_URL}/profile`),
]);
if (cardsRes.status === 401) { goto('/auth'); return; }
allCards = await cardsRes.json();
const profile = await profileRes.json();
shards = profile.shards;
});
async function shatter() {
if (selectedIds.size === 0 || shattering) return;
shattering = true;
const res = await apiFetch(`${API_URL}/shards/shatter`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ card_ids: [...selectedIds] }),
});
if (res.ok) {
const data = await res.json();
shards = data.shards;
result = { gained: data.gained };
allCards = allCards.filter(c => !selectedIds.has(c.id));
selectedIds = new Set();
}
shattering = false;
}
function dismissResult() { result = null; }
</script>
<main>
<div class="top">
<h1 class="page-title">Shards</h1>
{#if shards !== null}
<div class="shards-display">
<span class="shards-icon"></span>
<span class="shards-amount">{shards}</span>
<span class="shards-label">Shards</span>
</div>
{/if}
<p class="explainer">
Shatter cards you no longer need to recover shards equal to their cost.
Shattered cards are permanently destroyed.
</p>
<p class="store-hint">You can also <a href="/store#buy-shards" class="store-link">buy shards</a> in the store.</p>
</div>
{#if result}
<div class="result-banner">
<span class="result-icon"></span>
+{result.gained} shards gained
<button class="dismiss" onclick={dismissResult}>✕</button>
</div>
{/if}
<div class="action-area">
<button class="select-btn" onclick={() => { selectorOpen = true; }}>
{selectedIds.size === 0 ? 'Select cards to shatter' : `${selectedIds.size} card${selectedIds.size === 1 ? '' : 's'} selected`}
</button>
{#if selectedIds.size > 0}
<button
class="shatter-btn"
onclick={shatter}
disabled={shattering}
>
{shattering ? 'Shattering...' : `Shatter for ◈ ${totalYield}`}
</button>
{/if}
</div>
</main>
{#if selectorOpen}
<div class="selector-overlay">
<CardSelector
allCards={allCards}
bind:selectedIds={selectedIds}
onclose={() => { selectorOpen = false; }}
/>
</div>
{/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;
padding: 2.5rem 2rem;
display: flex;
flex-direction: column;
align-items: center;
gap: 2rem;
}
.top {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
max-width: 500px;
text-align: center;
}
.page-title {
font-family: 'Cinzel', serif;
font-size: clamp(22px, 4vw, 32px);
font-weight: 900;
color: #f0d080;
letter-spacing: 0.12em;
text-transform: uppercase;
margin: 0;
}
.shards-display {
display: flex;
align-items: center;
gap: 0.4rem;
}
.shards-icon {
font-size: 20px;
color: #7ecfcf;
position: relative;
top: -0.1em;
}
.shards-amount {
font-family: 'Cinzel', serif;
font-size: 24px;
font-weight: 700;
color: #7ecfcf;
}
.shards-label {
font-family: 'Cinzel', serif;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: rgba(126, 207, 207, 0.5);
margin-top: 3px;
}
.explainer {
font-family: 'Crimson Text', serif;
font-size: 16px;
font-style: italic;
color: rgba(240, 180, 80, 0.5);
margin: 0;
line-height: 1.6;
}
.store-hint {
font-family: 'Crimson Text', serif;
font-size: 15px;
font-style: italic;
color: rgba(240, 180, 80, 0.35);
margin: 0;
}
.store-link {
color: #7ecfcf;
text-decoration: underline;
text-underline-offset: 2px;
transition: color 0.15s;
}
.store-link:hover { color: #a8e8e8; }
/* ── Result banner ── */
.result-banner {
display: flex;
align-items: center;
gap: 0.5rem;
background: #0d2a0d;
border: 1.5px solid #6aaa6a;
border-radius: 8px;
padding: 0.75rem 1.25rem;
font-family: 'Cinzel', serif;
font-size: 15px;
font-weight: 700;
color: #6aaa6a;
}
.result-icon { color: #7ecfcf; position: relative; top: -0.1em; }
.dismiss {
margin-left: auto;
background: none;
border: none;
color: rgba(106, 170, 106, 0.5);
cursor: pointer;
font-size: 13px;
padding: 0 0 0 0.75rem;
transition: color 0.15s;
}
.dismiss:hover { color: #6aaa6a; }
/* ── Action area ── */
.action-area {
display: flex;
flex-direction: column;
align-items: center;
gap: 1.25rem;
width: 100%;
max-width: 400px;
}
.select-btn {
font-family: 'Cinzel', serif;
font-size: 13px;
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;
cursor: pointer;
transition: background 0.15s;
width: 100%;
}
.select-btn:hover { background: #5a3510; }
.selection-summary {
display: flex;
align-items: center;
justify-content: center;
gap: 0.75rem;
font-family: 'Cinzel', serif;
font-size: 15px;
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; }
.shatter-btn {
font-family: 'Cinzel', serif;
font-size: 13px;
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;
cursor: pointer;
width: 100%;
transition: background 0.15s;
}
.shatter-btn:hover:not(:disabled) { background: #0d2a2a; }
.shatter-btn:disabled { opacity: 0.5; cursor: not-allowed; }
/* ── Card selector overlay ── */
.selector-overlay {
position: fixed;
inset: 0;
z-index: 200;
background: rgba(0, 0, 0, 0.85);
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,560 @@
<script>
import { API_URL, WS_URL, apiFetch } from '$lib/api.js';
import { goto } from '$app/navigation';
import { onMount, onDestroy } from 'svelte';
import Card from '$lib/Card.svelte';
import CardSelector from '$lib/CardSelector.svelte';
const token = () => localStorage.getItem('token');
let phase = $state('idle'); // idle | queuing | trading | complete
let error = $state('');
let queueWs = null;
let tradeWs = null;
let tradeId = $state('');
let allCards = $state([]); // user's full card collection (for selector)
let tradeState = $state(null); // latest trade state from server
let selectorOpen = $state(false);
let selectorIds = $state(new Set());
let myOffer = $derived(tradeState?.my_offer ?? { cards: [], accepted: false });
let theirOffer = $derived(tradeState?.their_offer ?? { cards: [], accepted: false });
let partnerUsername = $derived(tradeState?.partner_username ?? '');
let eitherHasCards = $derived(myOffer.cards.length > 0 || theirOffer.cards.length > 0);
// disabled | ready | accepted
let acceptState = $derived(
!eitherHasCards ? 'disabled' :
myOffer.accepted ? 'accepted' :
'ready'
);
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(() => {
queueWs?.close();
tradeWs?.close();
});
function joinQueue() {
error = '';
phase = 'queuing';
queueWs = new WebSocket(`${WS_URL}/ws/trade/queue`);
queueWs.onopen = () => queueWs.send(token());
queueWs.onmessage = (e) => {
const msg = JSON.parse(e.data);
if (msg.type === 'trade_start') {
tradeId = msg.trade_id;
queueWs.close();
connectToTrade();
} else if (msg.type === 'error') {
error = msg.message;
phase = 'idle';
}
};
queueWs.onerror = () => { error = 'Connection failed'; phase = 'idle'; };
}
function cancelQueue() {
queueWs?.close();
phase = 'idle';
}
function connectToTrade() {
phase = 'trading';
tradeWs = new WebSocket(`${WS_URL}/ws/trade/${tradeId}`);
tradeWs.onopen = () => tradeWs.send(token());
tradeWs.onmessage = (e) => {
const msg = JSON.parse(e.data);
if (msg.type === 'state') {
tradeState = msg.state;
} else if (msg.type === 'trade_complete') {
phase = 'complete';
tradeWs?.close();
} else if (msg.type === 'error') {
error = msg.message;
setTimeout(() => { if (phase !== 'idle') error = ''; }, 4000);
if (msg.message.includes('disconnected')) {
phase = 'idle';
tradeState = null;
tradeWs?.close();
}
}
};
tradeWs.onerror = () => { error = 'Connection lost'; phase = 'idle'; };
tradeWs.onclose = (e) => {
if (phase === 'trading') { error = 'Connection lost'; phase = 'idle'; }
};
}
function openSelector() {
if (myOffer.accepted) {
tradeWs?.send(JSON.stringify({ type: 'unaccept' }));
}
selectorIds = new Set(myOffer.cards.map(c => c.id));
selectorOpen = true;
}
function closeSelector() {
selectorOpen = false;
tradeWs?.send(JSON.stringify({
type: 'update_offer',
card_ids: [...selectorIds],
}));
}
function handleAccept() {
if (acceptState === 'disabled' || acceptState === 'accepted') return;
tradeWs?.send(JSON.stringify({ type: 'accept' }));
}
function reset() {
phase = 'idle';
tradeState = null;
tradeId = '';
error = '';
selectorOpen = false;
}
</script>
<main>
{#if phase === 'idle'}
<div class="center-screen">
<h1 class="title">Trade</h1>
<p class="subtitle">Exchange cards with another player</p>
{#if error}
<p class="error">{error}</p>
{/if}
<button class="primary-btn" onclick={joinQueue}>Find Trade Partner</button>
</div>
{:else if phase === 'queuing'}
<div class="center-screen">
<div class="spinner"></div>
<p class="searching-text">Searching for a trade partner...</p>
<button class="cancel-btn" onclick={cancelQueue}>Cancel</button>
</div>
{:else if phase === 'trading'}
<div class="trade-layout">
{#if selectorOpen}
<div class="selector-overlay">
<CardSelector
allCards={allCards}
bind:selectedIds={selectorIds}
onclose={closeSelector}
/>
</div>
{/if}
<div class="trade-panels">
<div class="panel your-panel">
<div class="panel-header">
<span class="panel-title">Your Offer</span>
{#if myOffer.accepted}
<span class="accepted-badge">Accepted ✓</span>
{/if}
</div>
<div class="panel-cards">
{#if myOffer.cards.length === 0}
<div class="empty-offer">
<p>No cards offered yet</p>
</div>
{:else}
<div class="card-scroll">
{#each myOffer.cards as card (card.id)}
<div class="card-wrap">
<Card {card} noHover={true} />
</div>
{/each}
</div>
{/if}
</div>
</div>
<div class="divider"></div>
<div class="panel their-panel">
<div class="panel-header">
<span class="panel-title">{partnerUsername || 'Partner'}'s Offer</span>
{#if theirOffer.accepted}
<span class="accepted-badge">Accepted ✓</span>
{/if}
</div>
<div class="panel-cards">
{#if theirOffer.cards.length === 0}
<div class="empty-offer">
<p>No cards offered yet</p>
</div>
{:else}
<div class="card-scroll">
{#each theirOffer.cards as card (card.id)}
<div class="card-wrap">
<Card {card} noHover={true} />
</div>
{/each}
</div>
{/if}
</div>
</div>
</div>
<div class="action-bar">
<button class="choose-btn" onclick={openSelector}>Choose Cards</button>
{#if error}
<span class="error-inline">{error}</span>
{/if}
<button
class="accept-btn"
class:accept-ready={acceptState === 'ready'}
class:accept-accepted={acceptState === 'accepted'}
class:accept-disabled={acceptState === 'disabled'}
disabled={acceptState === 'disabled' || acceptState === 'accepted'}
onclick={handleAccept}
>
{#if acceptState === 'accepted'}
Accepted ✓
{:else if acceptState === 'disabled'}
Accept Trade
{:else}
Accept Trade
{/if}
</button>
</div>
</div>
{:else if phase === 'complete'}
<div class="center-screen">
<div class="complete-icon"></div>
<h1 class="title">Trade Complete!</h1>
<p class="subtitle">Your cards have been exchanged.</p>
<button class="primary-btn" onclick={reset}>Trade Again</button>
<a href="/cards" class="secondary-link">View Your Cards</a>
</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: calc(100vh - 56px);
background: #0d0a04;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* ── Center screens (idle, queuing, complete) ── */
.center-screen {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1.25rem;
padding: 2rem;
}
.title {
font-family: 'Cinzel', serif;
font-size: 36px;
font-weight: 700;
color: #f0d080;
margin: 0;
letter-spacing: 0.04em;
}
.subtitle {
font-family: 'Crimson Text', serif;
font-size: 18px;
font-style: italic;
color: rgba(240, 180, 80, 0.6);
margin: 0;
}
.error {
font-family: 'Crimson Text', serif;
font-size: 15px;
color: #c85050;
margin: 0;
text-align: center;
}
.primary-btn {
font-family: 'Cinzel', serif;
font-size: 13px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
background: #3d2507;
border: 1px solid #c8861a;
border-radius: 4px;
color: #f0d080;
padding: 10px 28px;
cursor: pointer;
transition: background 0.15s;
margin-top: 0.5rem;
}
.primary-btn:hover { background: #5a3510; }
.cancel-btn {
font-family: 'Cinzel', serif;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
background: none;
border: 1px solid rgba(107, 76, 30, 0.5);
border-radius: 4px;
color: rgba(240, 180, 80, 0.5);
padding: 6px 18px;
cursor: pointer;
transition: all 0.15s;
}
.cancel-btn:hover { border-color: #c8861a; color: #f0d080; }
.searching-text {
font-family: 'Crimson Text', serif;
font-size: 18px;
font-style: italic;
color: rgba(240, 180, 80, 0.7);
margin: 0;
}
.spinner {
width: 40px;
height: 40px;
border: 3px solid rgba(200, 134, 26, 0.2);
border-top-color: #c8861a;
border-radius: 50%;
animation: spin 0.9s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.complete-icon {
font-size: 56px;
color: #6aaa6a;
line-height: 1;
}
.secondary-link {
font-family: 'Crimson Text', serif;
font-size: 15px;
font-style: italic;
color: rgba(240, 180, 80, 0.5);
text-decoration: underline;
transition: color 0.15s;
}
.secondary-link:hover { color: #f0d080; }
/* ── Trade layout ── */
.trade-layout {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
.trade-panels {
flex: 1;
display: flex;
min-height: 0;
overflow: hidden;
}
.panel {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
overflow: hidden;
}
.panel-header {
flex-shrink: 0;
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem 1.5rem 0.75rem;
border-bottom: 1px solid rgba(107, 76, 30, 0.3);
}
.panel-title {
font-family: 'Cinzel', serif;
font-size: 14px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: rgba(240, 180, 80, 0.7);
}
.accepted-badge {
font-family: 'Cinzel', serif;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.04em;
color: #6aaa6a;
background: rgba(106, 170, 106, 0.12);
border: 1px solid rgba(106, 170, 106, 0.4);
border-radius: 3px;
padding: 2px 7px;
}
.panel-cards {
flex: 1;
overflow-y: auto;
padding: 1rem 1.5rem;
}
.empty-offer {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.empty-offer p {
font-family: 'Crimson Text', serif;
font-size: 15px;
font-style: italic;
color: rgba(240, 180, 80, 0.25);
margin: 0;
}
.card-scroll {
display: flex;
flex-wrap: wrap;
gap: 16px;
justify-content: center;
}
.card-wrap {
flex-shrink: 0;
transform: scale(0.82);
transform-origin: top center;
margin-bottom: -42px;
}
.divider {
flex-shrink: 0;
width: 1px;
background: rgba(107, 76, 30, 0.35);
margin: 0;
}
/* ── Action bar ── */
.action-bar {
flex-shrink: 0;
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem 1.5rem;
border-top: 1px solid rgba(107, 76, 30, 0.35);
background: #0d0a04;
}
.choose-btn {
font-family: 'Cinzel', serif;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
background: #1e1208;
border: 1px solid rgba(107, 76, 30, 0.6);
border-radius: 4px;
color: rgba(240, 180, 80, 0.8);
padding: 8px 18px;
cursor: pointer;
transition: all 0.15s;
}
.choose-btn:hover { background: #2e1c0c; border-color: #c8861a; color: #f0d080; }
.error-inline {
font-family: 'Crimson Text', serif;
font-size: 14px;
color: #c85050;
flex: 1;
text-align: center;
}
.accept-btn {
font-family: 'Cinzel', serif;
font-size: 13px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
border-radius: 4px;
padding: 10px 28px;
cursor: pointer;
transition: all 0.2s;
margin-left: auto;
}
/* Disabled: grayed out, no interaction */
.accept-btn.accept-disabled {
background: rgba(30, 18, 8, 0.5);
border: 1px solid rgba(107, 76, 30, 0.25);
color: rgba(240, 180, 80, 0.2);
cursor: not-allowed;
}
/* Ready: gold, inviting click */
.accept-btn.accept-ready {
background: #3d2507;
border: 2px solid #c8861a;
color: #f0d080;
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);
}
/* Accepted: bright green, pulsing, waiting */
.accept-btn.accept-accepted {
background: rgba(40, 90, 40, 0.4);
border: 2px solid #6aaa6a;
color: #6aaa6a;
cursor: default;
animation: pulse-green 1.8s ease-in-out infinite;
}
@keyframes pulse-green {
0%, 100% { box-shadow: 0 0 8px rgba(106, 170, 106, 0.3); }
50% { box-shadow: 0 0 20px rgba(106, 170, 106, 0.6); }
}
.selector-overlay {
position: fixed;
inset: 0;
z-index: 200;
background: rgba(0, 0, 0, 0.9);
}
@media (max-width: 640px) {
.trade-panels { flex-direction: column; }
.divider { width: 100%; height: 1px; }
.card-wrap { transform: scale(0.7); margin-bottom: -60px; }
}
</style>

View File

@@ -0,0 +1,108 @@
<script>
import { API_URL } from '$lib/api.js';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { onMount } from 'svelte';
let status = $state('verifying'); // 'verifying' | 'success' | 'error'
let errorMessage = $state('');
const token = $derived($page.url.searchParams.get('token') ?? '');
onMount(async () => {
if (!token) {
status = 'error';
errorMessage = 'No verification token found in this link.';
return;
}
try {
const res = await fetch(`${API_URL}/auth/verify-email?token=${encodeURIComponent(token)}`);
if (res.ok) {
status = 'success';
} else {
const data = await res.json();
status = 'error';
errorMessage = data.detail ?? 'Verification failed.';
}
} catch {
status = 'error';
errorMessage = 'Something went wrong. Please try again.';
}
});
</script>
<main>
<div class="card">
{#if status === 'verifying'}
<h1 class="title">Verifying...</h1>
<p class="hint">Please wait while we verify your email.</p>
{:else if status === 'success'}
<h1 class="title">Email Verified</h1>
<p class="hint">Your email has been confirmed. You can now open packs and trade cards.</p>
<button class="btn" onclick={() => goto('/')}>Start Playing</button>
{:else}
<h1 class="title">Verification Failed</h1>
<p class="hint">{errorMessage}</p>
<p class="hint">Your link may have expired. You can request a new one from your profile.</p>
<button class="btn" onclick={() => goto('/profile')}>Go to Profile</button>
{/if}
</div>
</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;
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
}
.card {
width: 380px;
background: #3d2507;
border: 2px solid #c8861a;
border-radius: 12px;
padding: 2rem;
display: flex;
flex-direction: column;
gap: 1.2rem;
}
.title {
font-family: 'Cinzel', serif;
font-size: 20px;
font-weight: 700;
color: #f5d060;
margin: 0;
text-align: center;
}
.hint {
font-family: 'Crimson Text', serif;
font-size: 15px;
color: rgba(245, 208, 96, 0.7);
margin: 0;
text-align: center;
line-height: 1.6;
}
.btn {
width: 100%;
padding: 10px;
background: #c8861a;
color: #fff8e0;
border: none;
border-radius: 6px;
font-family: 'Cinzel', serif;
font-size: 13px;
font-weight: 700;
cursor: pointer;
transition: background 0.15s;
}
.btn:hover { background: #e09820; }
</style>