🐐
This commit is contained in:
454
frontend/src/lib/CardSelector.svelte
Normal file
454
frontend/src/lib/CardSelector.svelte
Normal 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>
|
||||
@@ -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; }
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
288
frontend/src/routes/shards/+page.svelte
Normal file
288
frontend/src/routes/shards/+page.svelte
Normal 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>
|
||||
1048
frontend/src/routes/store/+page.svelte
Normal file
1048
frontend/src/routes/store/+page.svelte
Normal file
File diff suppressed because it is too large
Load Diff
560
frontend/src/routes/trade/+page.svelte
Normal file
560
frontend/src/routes/trade/+page.svelte
Normal 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>
|
||||
108
frontend/src/routes/verify-email/+page.svelte
Normal file
108
frontend/src/routes/verify-email/+page.svelte
Normal 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>
|
||||
Reference in New Issue
Block a user