This commit is contained in:
2026-03-18 15:33:24 +01:00
parent 5e7a6808ab
commit 867c51062b
39 changed files with 6499 additions and 161 deletions

View File

@@ -1,5 +1,5 @@
<script>
let { card } = $props();
let { card, noHover = false, defenseOverride = null } = $props();
const RARITY_BADGE = {
common: { symbol: "C", label: "Common", bg: "#c8c8c8", color: "#333" },
@@ -15,12 +15,12 @@
location: { bg: "#d8e8d4", header: "#4a7a50" },
artwork: { bg: "#e4d4e8", header: "#7a5090" },
life_form: { bg: "#ccdce8", header: "#3a6878" },
conflict: { bg: "#e8d4d4", header: "#8b2020" },
event: { bg: "#e8d4d4", header: "#8b2020" },
group: { bg: "#e8e4d0", header: "#748c12" },
science_thing: { bg: "#c7c5c1", header: "#060c17" },
vehicle: { bg: "#c7c1c4", header: "#801953" },
business: { bg: "#b7c1c4", header: "#3c5251" },
other: { bg: "#dddad4", header: "#6a6860" },
organization: { bg: "#b7c1c4", header: "#3c5251" },
other: { bg: "#dddad4", header: "#827e6f" },
};
const FOIL_RARITIES = new Set(["super_rare", "epic", "legendary"]);
@@ -37,7 +37,7 @@
let wikiUrl = $derived("https://en.wikipedia.org/wiki/" + encodeURIComponent(card.name.replace(/ /g, "_")));
</script>
<div class="card" class:foil class:super_rare class:epic class:legendary style="--foil-offset: {foilOffset}">
<div class="card" class:foil class:super_rare class:epic class:legendary class:no-hover={noHover} style="--foil-offset: {foilOffset}">
<div class="card-inner" style="--bg: {colors.bg}; --header: {colors.header}">
<div class="card-header">
@@ -77,7 +77,7 @@
<div class="card-footer">
<span class="stat">ATK <strong>{card.attack}</strong></span>
<span class="card-date">{new Date(card.created_at).toLocaleDateString()}</span>
<span class="stat">DEF <strong>{card.defense}</strong></span>
<span class="stat">DEF <strong>{defenseOverride !== null ? defenseOverride : card.defense}</strong></span>
</div>
</div>
@@ -133,11 +133,11 @@
.card.foil.epic::before {
background: repeating-linear-gradient(
105deg,
rgba(255,0,128,0.3) 0%,
rgba(255,200,0,0.3) 10%,
rgba(0,255,128,0.3) 20%,
rgba(0,200,255,0.3) 30%,
rgba(128,0,255,0.3) 40%,
rgba(255,0,128,0.28) 0%,
rgba(255,200,0,0.26) 10%,
rgba(0,255,128,0.24) 20%,
rgba(0,200,255,0.26) 30%,
rgba(128,0,255,0.28) 40%,
rgba(255,0,128,0.3) 50%
);
background-size: 300% 300%;
@@ -147,10 +147,10 @@
background: repeating-linear-gradient(
105deg,
rgba(255,215,0,0.35) 0%,
rgba(255,180,0,0.15) 15%,
rgba(255,255,180,0.40) 30%,
rgba(255,200,0,0.15) 45%,
rgba(255,215,0,0.35) 60%
rgba(255,180,0,0.08) 15%,
rgba(255,255,180,0.35) 30%,
rgba(255,200,0,0.08) 45%,
rgba(255,215,0,0.30) 60%
);
animation-duration: 1.8s;
background-size: 300% 300%;
@@ -348,4 +348,9 @@
font-family: 'Cinzel', serif;
line-height: 1;
}
.card.no-hover:hover {
transform: none;
box-shadow: 0 4px 24px rgba(0,0,0,0.5);
}
</style>

View File

@@ -0,0 +1,31 @@
<script>
let { deckType } = $props();
</script>
{#if deckType}
<span class="type-badge type-{deckType.toLowerCase().replace(' ', '-')}">
{deckType}
</span>
{/if}
<style>
.type-badge {
font-family: 'Cinzel', serif;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.05em;
padding: 3px 8px;
border-radius: 3px;
cursor: default;
display: inline-block;
}
.type-wall { background: rgba(58, 104, 120, 0.3); color: #a0d4e8; border: 1px solid rgba(58, 104, 120, 0.5); }
.type-aggro { background: rgba(139, 32, 32, 0.3); color: #e89090; border: 1px solid rgba(139, 32, 32, 0.5); }
.type-god-card { background: rgba(184, 120, 32, 0.3); color: #f5d880; border: 1px solid rgba(184, 120, 32, 0.5); }
.type-rush { background: rgba(74, 122, 80, 0.3); color: #a8dca8; border: 1px solid rgba(74, 122, 80, 0.5); }
.type-control { background: rgba(122, 80, 144, 0.3); color: #d0a0e8; border: 1px solid rgba(122, 80, 144, 0.5); }
.type-balanced { background: rgba(106, 104, 96, 0.3); color: #c8c6c0; border: 1px solid rgba(106, 104, 96, 0.5); }
.type-unplayable { background: rgba(60, 60, 60, 0.3); color: #909090; border: 1px solid rgba(60, 60, 60, 0.5); }
.type-pantheon { background: rgba(184, 150, 60, 0.3); color: #fce8a0; border: 1px solid rgba(184, 150, 60, 0.5); }
</style>

65
frontend/src/lib/api.js Normal file
View File

@@ -0,0 +1,65 @@
let isRefreshing = false;
let refreshPromise = null;
import { PUBLIC_API_URL } from '$env/static/public';
export const API_URL = PUBLIC_API_URL;
export const WS_URL = PUBLIC_API_URL.replace('http', 'ws');
async function refreshTokens() {
const refreshToken = localStorage.getItem('refresh_token');
if (!refreshToken) throw new Error('No refresh token');
const res = await fetch(`${API_URL}/auth/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refresh_token: refreshToken }),
});
if (!res.ok) {
localStorage.removeItem('token');
localStorage.removeItem('refresh_token');
window.location.href = '/auth';
throw new Error('Refresh failed');
}
const data = await res.json();
localStorage.setItem('token', data.access_token);
localStorage.setItem('refresh_token', data.refresh_token);
return data.access_token;
}
export async function apiFetch(url, options = {}) {
const token = localStorage.getItem('token');
if (!token) {
window.location.href = '/auth';
throw new Error('No token');
}
const headers = {
...options.headers,
Authorization: `Bearer ${token}`,
};
const res = await fetch(url, { ...options, headers });
if (res.status === 401) {
if (!isRefreshing) {
isRefreshing = true;
refreshPromise = refreshTokens().finally(() => { isRefreshing = false; });
}
try {
const newToken = await refreshPromise;
const retryRes = await fetch(url, {
...options,
headers: { ...options.headers, Authorization: `Bearer ${newToken}` },
});
return retryRes;
} catch {
window.location.href = '/auth';
throw new Error('Unauthorized');
}
}
return res;
}

View File

@@ -2,19 +2,14 @@
import { page } from '$app/stores';
import { goto } from '$app/navigation';
function logout() {
localStorage.removeItem('token');
goto('/auth');
close();
}
let menuOpen = $state(false);
const links = [
{ href: '/', label: 'Booster Packs' },
{ href: '/cards', label: 'Cards' },
{ href: '/decks', label: 'Decks' },
{ href: '/play', label: 'Play' },
{ href: '/', label: 'Booster Packs' },
{ href: '/cards', label: 'Cards' },
{ href: '/decks', label: 'Decks' },
{ href: '/play', label: 'Play' },
{ href: '/how-to-play', label: 'How to Play' },
];
function close() { menuOpen = false; }
@@ -27,7 +22,7 @@
{#each links as link}
<a href={link.href} class:active={$page.url.pathname === link.href}>{link.label}</a>
{/each}
<button class="logout" onclick={logout}>Log out</button>
<a href="/profile" class:active={$page.url.pathname === '/profile'}>Profile</a>
</nav>
<button class="hamburger" onclick={() => menuOpen = !menuOpen} aria-label="Toggle menu">
@@ -43,7 +38,6 @@
{#each links as link}
<a href={link.href} class:active={$page.url.pathname === link.href} onclick={close}>{link.label}</a>
{/each}
<button class="logout mobile-logout" onclick={logout}>Log out</button>
</nav>
{/if}
@@ -159,33 +153,6 @@
color: #f0d080;
}
.logout {
font-family: 'Cinzel', serif;
font-size: 11px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: rgba(200, 80, 80, 0.7);
background: none;
border: none;
border-bottom: 1.5px solid transparent;
cursor: pointer;
padding: 4px 0;
width: auto;
transition: color 0.15s, border-color 0.15s;
}
.logout:hover {
color: #c84040;
border-bottom-color: #c84040;
}
.mobile-logout {
font-size: 13px;
padding: 0.75rem 0;
border-bottom: 1px solid rgba(107, 76, 30, 0.3);
text-align: left;
}
@media (max-width: 640px) {
nav.desktop { display: none; }
.hamburger { display: flex; }

View File

@@ -7,7 +7,7 @@
let { children } = $props();
</script>
{#if page.url.pathname !== '/auth'}
{#if !['auth', 'forgot-password'].some(p => page.url.pathname.startsWith(`/${p}`))}
<Header />
{/if}

View File

@@ -1,4 +1,6 @@
<script>
import { API_URL, WS_URL } from '$lib/api.js';
import { apiFetch } from '$lib/api.js';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import Card from '$lib/Card.svelte';
@@ -23,9 +25,7 @@
});
async function fetchBoosters() {
const res = await fetch('http://localhost:8000/boosters', {
headers: { Authorization: `Bearer ${localStorage.getItem('token')}` }
});
const res = await apiFetch(`${API_URL}/boosters`);
if (res.status === 401) { goto('/auth'); return; }
const [count, countdownTs] = await res.json();
boosters = count;
@@ -74,9 +74,8 @@
phase = 'dropping';
// Fetch while pack is sliding away
const fetchPromise = fetch('http://localhost:8000/open_pack', {
method: 'POST',
headers: { Authorization: `Bearer ${localStorage.getItem('token')}` }
const fetchPromise = apiFetch(`${API_URL}/open_pack`, {
method: 'POST'
});
await delay(700);
@@ -320,7 +319,7 @@
/* no-top clips the pack body below the tear line */
.booster-pack.no-top {
clip-path: inset(60px 0 0 0 round 0 0 10px 10px);
clip-path: inset(41px 0 0 0 round 0 0 10px 10px);
}
/* ── Overlay ── */
@@ -359,15 +358,16 @@
top: 0;
left: 0;
width: 300px;
height: 60px;
height: 41px;
background: #dfdbcf;
background-image: linear-gradient(to right, transparent, rgba(255,255,255,0.35) 5%, transparent 8%);
/* background-image: linear-gradient(to right, transparent, rgba(255,255,255,0.35) 5%, transparent 8%); */
background-image: repeating-linear-gradient(
90deg,
rgba(255,255,255,0.18) 0%,
rgba(255,255,255,0.18) 1px,
transparent 1px,
transparent 8px
rgba(255,255,255,0.25),
rgba(0,0,0,0.15) 2%,
rgba(0,0,0,0.08) 3%,
transparent 3%,
transparent 4%
);
border-radius: 10px 10px 0 0;
transform-origin: top left;

View File

@@ -1,4 +1,5 @@
<script>
import { API_URL, WS_URL } from '$lib/api.js';
import { goto } from '$app/navigation';
let mode = $state('login');
@@ -8,16 +9,26 @@
let error = $state('');
let loading = $state(false);
function validate() {
if (!username.trim()) return 'Username is required';
if (username.length > 16) return 'Username must be 16 characters or fewer';
if (mode === 'register') {
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return 'Please enter a valid email';
if (password.length < 8) return 'Password must be at least 8 characters';
if (password.length > 256) return 'Password must be 256 characters or fewer';
}
return null;
}
async function submit() {
error = '';
if (!username.trim()) {
error = 'Username is required';
return;
}
const validationError = validate();
if (validationError) { error = validationError; return;}
loading = true;
try {
if (mode === 'register') {
const res = await fetch('http://localhost:8000/register', {
const res = await fetch(`${API_URL}/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, email, password }),
@@ -32,13 +43,14 @@
const form = new FormData();
form.append('username', username);
form.append('password', password);
const res = await fetch('http://localhost:8000/login', {
const res = await fetch(`${API_URL}/login`, {
method: 'POST',
body: form,
});
const data = await res.json();
if (!res.ok) throw new Error(data.detail);
localStorage.setItem('token', data.access_token);
localStorage.setItem('refresh_token', data.refresh_token);
goto('/');
} catch (e) {
error = e.message;
@@ -79,6 +91,7 @@
<button onclick={submit} disabled={loading}>
{loading ? 'Please wait...' : mode === 'login' ? 'Sign In' : 'Create Account'}
</button>
<a href="/forgot-password" class="forgot-link">Forgot your password?</a>
<p class="toggle">
{mode === 'login' ? "Don't have an account?" : 'Already have an account?'}
@@ -198,4 +211,15 @@
.link:hover {
color: #f0d080;
}
.forgot-link {
font-family: 'Crimson Text', serif;
font-size: 13px;
color: rgba(245, 208, 96, 0.45);
text-align: center;
text-decoration: none;
transition: color 0.15s;
}
.forgot-link:hover { color: rgba(245, 208, 96, 0.8); }
</style>

View File

@@ -0,0 +1,635 @@
<script>
import { API_URL, WS_URL } from '$lib/api.js';
import { apiFetch } from '$lib/api.js';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import Card from '$lib/Card.svelte';
let allCards = $state([]);
let loading = $state(true);
const token = () => localStorage.getItem('token');
// Sort
let sortBy = $state('name');
// Filters
const RARITIES = ['common', 'uncommon', 'rare', 'super_rare', 'epic', 'legendary'];
const TYPES = ['person', 'location', 'artwork', 'life_form', 'event', 'group', 'science_thing', 'vehicle', 'organization', 'other'];
let selectedRarities = $state(new Set(RARITIES));
let selectedTypes = $state(new Set(TYPES));
let filtersOpen = $state(false);
const RARITY_ORDER = Object.fromEntries(RARITIES.map((r, i) => [r, i]));
function label(str) {
return str.charAt(0).toUpperCase() + str.slice(1).replace(/_/g, ' ');
}
let sortAsc = $state(true);
let costMin = $state(1);
let costMax = $state(12);
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 = a.cost - b.cost || a.name.localeCompare(b.name);
else if (sortBy === 'attack') cmp = a.attack - b.attack || a.name.localeCompare(b.name);
else if (sortBy === 'defense') cmp = a.defense - b.defense || a.name.localeCompare(b.name);
else if (sortBy === 'rarity') cmp = RARITY_ORDER[a.card_rarity] - RARITY_ORDER[b.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);
}
onMount(async () => {
if (!token()) { goto('/auth'); return; }
const res = await apiFetch(`${API_URL}/cards`);
if (res.status === 401) { goto('/auth'); return; }
allCards = await res.json();
loading = false;
});
let selectedCard = $state(null);
let refreshStatus = $state(null);
let countdownDisplay = $state('');
let countdownInterval = null;
let reportLoading = $state(false);
let refreshLoading = $state(false);
let actionMessage = $state('');
async function fetchRefreshStatus() {
const res = await apiFetch(`${API_URL}/profile/refresh-status`);
refreshStatus = await res.json();
if (!refreshStatus.can_refresh && refreshStatus.next_refresh_at) {
startRefreshCountdown(new Date(refreshStatus.next_refresh_at));
}
}
function startRefreshCountdown(nextRefreshAt) {
clearInterval(countdownInterval);
countdownInterval = setInterval(() => {
const diff = nextRefreshAt - Date.now();
if (diff <= 0) {
clearInterval(countdownInterval);
refreshStatus = { can_refresh: true, next_refresh_at: null };
countdownDisplay = '';
return;
}
const h = Math.floor(diff / 3600000);
const m = Math.floor((diff % 3600000) / 60000);
const s = Math.floor((diff % 60000) / 1000);
countdownDisplay = `${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}`;
}, 1000);
}
function openCard(card) {
selectedCard = card;
actionMessage = '';
fetchRefreshStatus();
}
function closeCard() {
selectedCard = null;
clearInterval(countdownInterval);
countdownDisplay = '';
actionMessage = '';
}
async function reportCard() {
reportLoading = true;
const res = await apiFetch(`${API_URL}/cards/${selectedCard.id}/report`, {
method: 'POST'
});
reportLoading = false;
if (res.ok) {
selectedCard = { ...selectedCard, reported: true };
allCards = allCards.map(c => c.id === selectedCard.id ? { ...c, reported: true } : c);
actionMessage = 'Card reported. Thank you!';
} else {
actionMessage = 'Failed to report card.';
}
}
async function refreshCard() {
refreshLoading = true;
actionMessage = '';
const res = await apiFetch(`${API_URL}/cards/${selectedCard.id}/refresh`, {
method: 'POST'
});
refreshLoading = false;
if (res.ok) {
const updated = await res.json();
// Update card in allCards list
allCards = allCards.map(c => c.id === updated.id ? updated : c);
selectedCard = updated;
refreshStatus = { can_refresh: false, next_refresh_at: null };
await fetchRefreshStatus();
actionMessage = 'Card refreshed!';
} else {
const err = await res.json();
actionMessage = err.detail ?? 'Failed to refresh card.';
}
}
</script>
<main>
<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}
<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 = 12; }}>Reset</button>
</div>
<div class="cost-range">
<span class="range-label">Min: {costMin}</span>
<input type="range" min="1" max="12" bind:value={costMin}
oninput={() => { if (costMin > costMax) costMax = costMin; }} />
<span class="range-label">Max: {costMax}</span>
<input type="range" min="1" max="12" bind:value={costMax}
oninput={() => { if (costMax < costMin) costMin = costMax; }} />
</div>
</div>
</div>
{/if}
</div>
{#if loading}
<p class="status">Loading your cards...</p>
{:else if filtered.length === 0}
<p class="status">No cards match your filters.</p>
{:else}
<p class="card-count">{filtered.length} card{filtered.length === 1 ? '' : 's'}</p>
<div class="grid">
{#each filtered as card (card.id)}
<button class="card-btn" onclick={() => openCard(card)}>
<Card {card} />
</button>
{/each}
</div>
{/if}
{#if selectedCard}
<div class="backdrop" onclick={closeCard}>
<div class="card-popup" onclick={(e) => e.stopPropagation()}>
<Card card={selectedCard} />
<div class="popup-actions">
<div class="action-col">
<button
class="report-btn"
onclick={reportCard}
disabled={reportLoading || selectedCard.reported}
>
{selectedCard.reported ? 'Already Reported' : reportLoading ? 'Reporting...' : 'Report Error'}
</button>
</div>
<div class="action-col">
<button
class="refresh-btn"
onclick={refreshCard}
disabled={refreshLoading || (refreshStatus && !refreshStatus.can_refresh)}
>
{refreshLoading ? 'Refreshing...' : 'Refresh Card'}
</button>
<span class="refresh-countdown">
{refreshStatus && !refreshStatus.can_refresh && countdownDisplay ? `${countdownDisplay} until next refresh` : ''}
</span>
</div>
</div>
<p class="action-message">{actionMessage}</p>
<button class="close-btn" onclick={closeCard}>✕</button>
</div>
</div>
{/if}
</main>
<style>
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;700&family=Crimson+Text:ital,wght@0,400;0,600;1,400&display=swap');
main {
height: 100vh;
overflow-y: auto;
background: #0d0a04;
padding: 0 2rem 2rem 2rem;
}
.toolbar {
position: sticky;
top: 0px;
z-index: 50;
background: #0d0a04;
padding-bottom: 1rem;
border-bottom: 1px solid rgba(107, 76, 30, 0.3);
margin-bottom: 2rem;
padding-top: 32px;
}
.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;
}
.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;
}
.card-count {
font-family: 'Crimson Text', serif;
font-size: 16px;
font-style: italic;
color: rgba(240, 180, 80, 0.4);
margin-bottom: 1.5rem;
}
.grid {
display: flex;
flex-wrap: wrap;
gap: 20px;
justify-content: center;
padding-bottom: 50px
}
.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;
}
.sort-arrow {
font-size: 10px;
margin-left: 3px;
}
.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;
}
.card-btn {
all: unset;
cursor: pointer;
display: block;
border-radius: 12px;
transition: transform 0.15s;
}
.card-btn:hover {
transform: translateY(-4px);
}
.backdrop {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.8);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
}
.card-popup {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
}
.popup-actions {
display: flex;
gap: 1rem;
justify-content: center;
}
.action-col {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.4rem;
min-height: 60px;
}
.report-btn, .refresh-btn {
font-family: 'Cinzel', serif;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
border-radius: 4px;
padding: 8px 18px;
cursor: pointer;
transition: background 0.15s;
}
.report-btn {
background: rgba(180, 60, 60, 0.5);
border: 1px solid rgba(240,250,240,0.8);
color: white;
}
.report-btn:hover:not(:disabled) {
/* border-color: #c84040; */
color: #E0E0E0;
background: rgba(180, 40, 40, 0.9);
}
.refresh-btn {
background: #3d2507;
border: 1px solid #c8861a;
color: #f0d080;
}
.refresh-btn:hover:not(:disabled) {
background: #5a3510;
}
.report-btn:disabled, .refresh-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.refresh-countdown {
font-family: 'Crimson Text', serif;
font-size: 12px;
font-style: italic;
color: rgba(240, 180, 80, 0.5);
}
.action-message {
font-family: 'Crimson Text', serif;
font-size: 14px;
font-style: italic;
color: rgba(240, 180, 80, 0.7);
margin: 0;
min-height: 1.4em;
text-align: center;
}
.close-btn {
position: absolute;
top: -12px;
right: -12px;
width: 28px;
height: 28px;
border-radius: 50%;
background: #1a1008;
border: 1px solid #6b4c1e;
color: rgba(240, 180, 80, 0.7);
font-size: 12px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.15s;
}
.close-btn:hover {
border-color: #c8861a;
color: #f0d080;
}
</style>

View File

@@ -0,0 +1,441 @@
<script>
import { API_URL, WS_URL } from '$lib/api.js';
import { apiFetch } from '$lib/api.js';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import DeckTypeBadge from '$lib/DeckTypeBadge.svelte';
let decks = $state([]);
let loading = $state(true);
let editConfirm = $state(null); // deck object pending edit confirmation
let deleteConfirm = $state(null); // deck object pending delete confirmation
const token = () => localStorage.getItem('token');
onMount(async () => {
if (!token()) { goto('/auth'); return; }
await fetchDecks();
});
async function fetchDecks() {
const res = await apiFetch(`${API_URL}/decks`);
if (res.status === 401) { goto('/auth'); return; }
decks = await res.json();
loading = false;
}
async function createDeck() {
const res = await apiFetch(`${API_URL}/decks`, {
method: 'POST'
});
const deck = await res.json();
goto(`/decks/${deck.id}`);
}
function clickEdit(deck) {
if (deck.times_played > 0) {
editConfirm = deck;
} else {
goto(`/decks/${deck.id}`);
}
}
function clickDelete(deck) {
deleteConfirm = deck;
}
async function confirmDelete() {
await apiFetch(`${API_URL}/decks/${deleteConfirm.id}`, {
method: 'DELETE'
});
decks = decks.filter(d => d.id !== deleteConfirm.id);
deleteConfirm = null;
}
function winRate(deck) {
if (deck.times_played === 0) return null;
return Math.round((deck.wins / deck.times_played) * 100);
}
</script>
<main>
<div class="header">
<h1 class="title">Your Decks</h1>
<button class="new-btn" onclick={createDeck}>+ New Deck</button>
</div>
{#if loading}
<p class="status">Loading decks...</p>
{:else if decks.length === 0}
<p class="status">You have no decks yet.</p>
{:else}
<table>
<thead>
<tr>
<th>Name</th>
<th>Cards</th>
<th>Type</th>
<th>Played</th>
<th>W / L</th>
<th>Win %</th>
<th></th>
</tr>
</thead>
<tbody>
{#each decks as deck}
{@const wr = winRate(deck)}
<tr>
<td class="deck-name">{deck.name}</td>
<td class="deck-count" class:incomplete={deck.card_count < 20}>
{deck.card_count}/20
</td>
<td class="deck-type">
<DeckTypeBadge deckType={deck.deck_type} />
</td>
<td class="deck-stat">{deck.times_played}</td>
<td class="deck-stat">
{#if deck.times_played > 0}
<span class="wins">{deck.wins}</span>
<span class="separator"> / </span>
<span class="losses">{deck.losses}</span>
{:else}
<span class="no-data"></span>
{/if}
</td>
<td class="deck-stat">
{#if wr !== null}
<span class:good-wr={wr >= 50} class:bad-wr={wr < 50}>{wr}%</span>
{:else}
<span class="no-data"></span>
{/if}
</td>
<td class="deck-actions">
<button class="edit-btn" onclick={() => clickEdit(deck)}>Edit</button>
<button class="delete-btn" onclick={() => clickDelete(deck)}>Delete</button>
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
<!-- Edit confirmation popup -->
{#if editConfirm}
<div class="backdrop" onclick={() => editConfirm = null}>
<div class="popup" onclick={(e) => e.stopPropagation()}>
<h2 class="popup-title">Reset Stats?</h2>
<p class="popup-body">
<strong>{editConfirm.name}</strong> has been played {editConfirm.times_played} time{editConfirm.times_played === 1 ? '' : 's'} ({editConfirm.wins}W / {editConfirm.losses}L). Editing this deck will reset its stats.
</p>
<div class="popup-actions">
<button class="popup-cancel" onclick={() => editConfirm = null}>Cancel</button>
<button class="popup-confirm" onclick={() => { goto(`/decks/${editConfirm.id}`); editConfirm = null; }}>Continue</button>
</div>
</div>
</div>
{/if}
<!-- Delete confirmation popup -->
{#if deleteConfirm}
<div class="backdrop" onclick={() => deleteConfirm = null}>
<div class="popup" onclick={(e) => e.stopPropagation()}>
<h2 class="popup-title">Delete Deck?</h2>
<p class="popup-body">
Are you sure you want to delete <strong>{deleteConfirm.name}</strong>?
{#if deleteConfirm.times_played > 0}
This deck has been played {deleteConfirm.times_played} time{deleteConfirm.times_played === 1 ? '' : 's'} — its stats will be preserved in your profile history.
{/if}
</p>
<div class="popup-actions">
<button class="popup-cancel" onclick={() => deleteConfirm = null}>Cancel</button>
<button class="popup-delete" onclick={confirmDelete}>Delete</button>
</div>
</div>
</div>
{/if}
</main>
<style>
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;700&family=Crimson+Text:ital,wght@0,400;0,600;1,400&display=swap');
main {
height: 100vh;
overflow-y: auto;
background: #0d0a04;
padding: 2rem;
}
.header {
display: flex;
align-items: baseline;
justify-content: space-between;
margin-bottom: 2rem;
border-bottom: 1px solid rgba(107, 76, 30, 0.3);
padding-bottom: 1rem;
}
.title {
font-family: 'Cinzel', serif;
font-size: 22px;
font-weight: 700;
color: #f0d080;
margin: 0;
}
.new-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: 6px 14px;
cursor: pointer;
transition: background 0.15s;
}
.new-btn:hover { background: #5a3510; }
table {
width: 100%;
border-collapse: collapse;
font-family: 'Crimson Text', serif;
}
thead tr {
border-bottom: 1px solid rgba(107, 76, 30, 0.5);
}
th {
font-family: 'Cinzel', serif;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: rgba(240, 180, 80, 0.4);
padding: 0 1rem 0.75rem 0;
text-align: left;
}
tbody tr {
border-bottom: 1px solid rgba(107, 76, 30, 0.2);
transition: background 0.1s;
}
tbody tr:hover { background: rgba(107, 76, 30, 0.08); }
td {
padding: 0.9rem 1rem 0.9rem 0;
vertical-align: middle;
}
.deck-name {
font-size: 17px;
color: #e8d090;
}
.deck-count {
font-family: 'Cinzel', serif;
font-size: 13px;
font-weight: 700;
color: #6aaa6a;
width: 60px;
}
.deck-count.incomplete { color: #c85050; }
.deck-type { width: 90px; }
.type-badge {
font-family: 'Cinzel', serif;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.05em;
padding: 3px 8px;
border-radius: 3px;
cursor: default;
}
.type-wall { background: rgba(58, 104, 120, 0.3); color: #a0d4e8; border: 1px solid rgba(58, 104, 120, 0.5); }
.type-aggro { background: rgba(139, 32, 32, 0.3); color: #e89090; border: 1px solid rgba(139, 32, 32, 0.5); }
.type-god-card { background: rgba(184, 120, 32, 0.3); color: #f5d880; border: 1px solid rgba(184, 120, 32, 0.5); }
.type-rush { background: rgba(74, 122, 80, 0.3); color: #a8dca8; border: 1px solid rgba(74, 122, 80, 0.5); }
.type-control { background: rgba(122, 80, 144, 0.3); color: #d0a0e8; border: 1px solid rgba(122, 80, 144, 0.5); }
.type-unplayable { background: rgba(60, 60, 60, 0.3); color: #909090; border: 1px solid rgba(60, 60, 60, 0.5); }
.type-pantheon { background: rgba(184, 150, 60, 0.3); color: #fce8a0; border: 1px solid rgba(184, 150, 60, 0.5); }
.type-balanced { background: rgba(106, 104, 96, 0.3); color: #c8c6c0; border: 1px solid rgba(106, 104, 96, 0.5); }
.deck-stat {
font-family: 'Cinzel', serif;
font-size: 13px;
color: rgba(240, 180, 80, 0.6);
width: 60px;
}
.wins { color: #6aaa6a; }
.losses { color: #c85050; }
.separator { color: rgba(240, 180, 80, 0.3); }
.good-wr { color: #6aaa6a; }
.bad-wr { color: #c85050; }
.no-data {
color: rgba(240, 180, 80, 0.2);
}
.deck-actions {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
}
.edit-btn, .delete-btn {
font-family: 'Cinzel', serif;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
border-radius: 4px;
padding: 4px 10px;
cursor: pointer;
transition: background 0.15s;
}
.edit-btn {
background: none;
border: 1px solid rgba(107, 76, 30, 0.5);
color: rgba(240, 180, 80, 0.7);
}
.edit-btn:hover {
border-color: #c8861a;
color: #f0d080;
}
.delete-btn {
background: none;
border: 1px solid rgba(180, 60, 60, 0.3);
color: rgba(200, 80, 80, 0.6);
}
.delete-btn:hover {
border-color: #c84040;
color: #e05050;
}
.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;
}
.backdrop {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
}
.popup {
background: #1a1008;
border: 1px solid #6b4c1e;
border-radius: 10px;
padding: 2rem;
max-width: 400px;
width: calc(100% - 2rem);
display: flex;
flex-direction: column;
gap: 1rem;
}
.popup-title {
font-family: 'Cinzel', serif;
font-size: 18px;
font-weight: 700;
color: #f0d080;
margin: 0;
}
.popup-body {
font-family: 'Crimson Text', serif;
font-size: 15px;
color: rgba(240, 180, 80, 0.7);
margin: 0;
line-height: 1.6;
}
.popup-body strong {
color: #f0d080;
}
.popup-actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
}
.popup-cancel {
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: 7px 16px;
cursor: pointer;
transition: all 0.15s;
}
.popup-cancel:hover {
border-color: #c8861a;
color: #f0d080;
}
.popup-confirm {
font-family: 'Cinzel', serif;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
background: #c8861a;
border: none;
border-radius: 4px;
color: #fff8e0;
padding: 7px 16px;
cursor: pointer;
transition: background 0.15s;
}
.popup-confirm:hover { background: #e09820; }
.popup-delete {
font-family: 'Cinzel', serif;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
background: rgba(180, 40, 40, 0.8);
border: none;
border-radius: 4px;
color: #fff;
padding: 7px 16px;
cursor: pointer;
transition: background 0.15s;
}
.popup-delete:hover { background: rgba(220, 60, 60, 0.9); }
</style>

View File

@@ -0,0 +1,534 @@
<script>
import { API_URL, WS_URL } from '$lib/api.js';
import { apiFetch } from '$lib/api.js';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import { page } from '$app/stores';
import Card from '$lib/Card.svelte';
const deckId = $derived($page.params.id);
const token = () => localStorage.getItem('token');
let allCards = $state([]);
let selectedIds = $state(new Set());
let deckName = $state('');
let editingName = $state(false);
let nameInput = $state('');
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(12);
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 = a.cost - b.cost || a.name.localeCompare(b.name);
else if (sortBy === 'attack') cmp = a.attack - b.attack || a.name.localeCompare(b.name);
else if (sortBy === 'defense') cmp = a.defense - b.defense || a.name.localeCompare(b.name);
else if (sortBy === 'rarity') cmp = RARITY_ORDER[a.card_rarity] - RARITY_ORDER[b.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 (s.size >= 20) return;
s.add(id);
}
selectedIds = s;
}
function startEditName() {
nameInput = deckName;
editingName = true;
}
function commitName() {
if (nameInput.trim()) deckName = nameInput.trim();
editingName = false;
}
async function save() {
saving = true;
await apiFetch(`${API_URL}/decks/${deckId}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: deckName,
card_ids: [...selectedIds],
}),
});
saving = false;
goto('/decks');
}
onMount(async () => {
if (!token()) { goto('/auth'); return; }
const [cardsRes, deckCardsRes] = await Promise.all([
apiFetch(`${API_URL}/cards`),
apiFetch(`${API_URL}/decks/${deckId}/cards`),
]);
if (cardsRes.status === 401) { goto('/auth'); return; }
allCards = await cardsRes.json();
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);
deckName = deck?.name ?? 'Untitled Deck';
loading = false;
});
</script>
<main>
<div class="toolbar">
<div class="deck-header">
{#if editingName}
<input
class="name-input"
bind:value={nameInput}
onblur={commitName}
onkeydown={e => e.key === 'Enter' && commitName()}
autofocus
/>
{:else}
<button class="name-btn" onclick={startEditName}>{deckName} ✎</button>
{/if}
<div class="header-right">
<span class="card-counter" class:full={selectedIds.size === 20} class:empty={selectedIds.size === 0}>
{selectedIds.size}/20
</span>
<button class="done-btn" onclick={save} disabled={saving}>
{saving ? 'Saving...' : 'Done'}
</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 < 12}
<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 = 12; }}>Reset</button>
</div>
<div class="cost-range">
<span class="range-label">Min: {costMin}</span>
<input type="range" min="1" max="12" bind:value={costMin} oninput={() => { if (costMin > costMax) costMax = costMin; }} />
<span class="range-label">Max: {costMax}</span>
<input type="range" min="1" max="12" 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) && selectedIds.size >= 20}
onclick={() => toggleCard(card.id)}
>
<Card {card} noHover={true} />
{#if selectedIds.has(card.id)}
<div class="selected-badge"></div>
{/if}
</button>
{/each}
</div>
{/if}
</main>
<style>
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;700&family=Crimson+Text:ital,wght@0,400;0,600;1,400&display=swap');
main {
height: 100vh;
overflow-y: auto;
background: #0d0a04;
padding: 0 2rem 2rem 2rem;
}
.toolbar {
position: sticky;
top: 0;
z-index: 50;
background: #0d0a04;
padding-bottom: 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 {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.name-btn {
font-family: 'Cinzel', serif;
font-size: 18px;
font-weight: 700;
color: #f0d080;
background: none;
border: none;
cursor: pointer;
padding: 0;
letter-spacing: 0.03em;
transition: color 0.15s;
}
.name-btn:hover { color: #fff8d0; }
.name-input {
font-family: 'Cinzel', serif;
font-size: 18px;
font-weight: 700;
color: #f0d080;
background: transparent;
border: none;
border-bottom: 1.5px solid #c8861a;
outline: none;
padding: 0 0 2px 0;
min-width: 200px;
}
.header-right {
display: flex;
align-items: center;
gap: 1rem;
}
.card-counter {
font-family: 'Cinzel', serif;
font-size: 14px;
font-weight: 700;
color: #c85050;
transition: color 0.2s;
}
.card-counter.full { color: #6aaa6a; }
.card-counter.empty { color: rgba(240, 180, 80, 0.3); }
.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: 6px 16px;
cursor: pointer;
transition: background 0.15s;
}
.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;
font-style: italic;
color: rgba(240, 180, 80, 0.5);
text-align: center;
margin-top: 4rem;
}
</style>

View File

@@ -0,0 +1,151 @@
<script>
import { API_URL, WS_URL } from '$lib/api.js';
import { apiFetch } from '$lib/api.js';
let email = $state('');
let submitted = $state(false);
let loading = $state(false);
let error = $state('');
async function submit() {
error = '';
if (!email.trim()) { error = 'Email is required'; return; }
loading = true;
try {
const res = await fetch(`${API_URL}/auth/forgot-password`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
});
const data = await res.json();
if (!res.ok) { error = data.detail; return; }
submitted = true;
} catch {
error = 'Something went wrong. Please try again.';
} finally {
loading = false;
}
}
</script>
<main>
<div class="card">
{#if submitted}
<h1 class="title">Check Your Email</h1>
<p class="hint">If that email address is registered, you'll receive a password reset link shortly.</p>
{:else}
<h1 class="title">Forgot Password</h1>
<p class="hint">Enter your email address and we'll send you a reset link.</p>
<input type="email" placeholder="Email address" bind:value={email} />
<p class="error">{error}</p>
<button class="btn" onclick={submit} disabled={loading}>
{loading ? 'Sending...' : 'Send Reset Link'}
</button>
<a href="/auth" class="back-link">← Back to login</a>
{/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: 340px;
background: #2e1c05;
border: 2px solid #6b4c1e;
border-radius: 12px;
padding: 2rem;
display: flex;
flex-direction: column;
gap: 1.2rem;
}
.title {
font-family: 'Cinzel', serif;
font-size: 20px;
color: #f0d080;
text-align: center;
margin: 0;
}
.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;
}
input {
width: 100%;
padding: 9px 12px;
background: #1a1008;
border: 1.5px solid #8b6420;
border-radius: 6px;
color: #f0d080;
font-family: 'Crimson Text', serif;
font-size: 15px;
box-sizing: border-box;
outline: none;
}
input::placeholder {
color: rgba(240, 180, 80, 0.4);
}
input:focus { border-color: #f5d060; }
button {
width: 100%;
padding: 10px;
background: #6b4c1e;
color: #f0d080;
border: 1.5px solid #8b6420;
border-radius: 6px;
font-family: 'Cinzel', serif;
font-size: 13px;
cursor: pointer;
transition: background 0.15s;
}
button:hover:not(:disabled) {
background: #8b6420;
}
button:disabled {
opacity: 0.5;
cursor: default;
}
.back-link {
font-family: 'Crimson Text', serif;
font-size: 14px;
color: rgba(245, 208, 96, 0.5);
text-align: center;
text-decoration: none;
transition: color 0.15s;
}
.back-link:hover { color: #f5d060; }
.error {
font-family: 'Crimson Text', serif;
font-size: 14px;
color: #f06060;
margin: 0;
min-height: 1.4em;
text-align: center;
}
</style>

View File

@@ -0,0 +1,172 @@
<script>
import { API_URL, WS_URL } from '$lib/api.js';
import { apiFetch } from '$lib/api.js';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
let newPassword = $state('');
let confirmPassword = $state('');
let error = $state('');
let success = $state(false);
let loading = $state(false);
let token = $derived($page.url.searchParams.get('token') ?? '');
function validate() {
if (newPassword.length < 8) return 'Password must be at least 8 characters';
if (newPassword.length > 256) return 'Password must be 256 characters or fewer';
if (newPassword !== confirmPassword) return 'Passwords do not match';
return null;
}
async function submit() {
error = '';
const validationError = validate();
if (validationError) { error = validationError; return; }
loading = true;
try {
const res = await fetch(`${API_URL}/auth/reset-password-with-token`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token, new_password: newPassword }),
});
const data = await res.json();
if (!res.ok) { error = data.detail; return; }
success = true;
} catch {
error = 'Something went wrong. Please try again.';
} finally {
loading = false;
}
}
</script>
<main>
<div class="card">
{#if !token}
<h1 class="title">Invalid Link</h1>
<p class="hint">This reset link is invalid. Please request a new one.</p>
<a href="/forgot-password" class="btn" style="text-align:center; text-decoration:none;">Request New Link</a>
{:else if success}
<h1 class="title">Password Updated</h1>
<p class="hint">Your password has been changed. You can now log in.</p>
<button class="btn" onclick={() => goto('/auth')}>Go to Login</button>
{:else}
<h1 class="title">Set New Password</h1>
<div class="fields">
<label class="field-label" for="new">New Password</label>
<input id="new" type="password" placeholder="At least 8 characters" bind:value={newPassword} />
<label class="field-label" for="confirm">Confirm Password</label>
<input id="confirm" type="password" placeholder="Repeat new password" bind:value={confirmPassword} />
</div>
<p class="error">{error}</p>
<button class="btn" onclick={submit} disabled={loading}>
{loading ? 'Updating...' : 'Set New Password'}
</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;
}
.fields {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.field-label {
font-family: 'Cinzel', serif;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: rgba(245, 208, 96, 0.5);
}
input {
width: 100%;
padding: 9px 12px;
background: #221508;
border: 1.5px solid #c8861a;
border-radius: 6px;
color: #f5d060;
font-family: 'Crimson Text', serif;
font-size: 15px;
box-sizing: border-box;
outline: none;
margin-bottom: 0.4rem;
}
input:focus { border-color: #f5d060; }
input::placeholder { color: rgba(245, 208, 96, 0.35); }
.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;
display: block;
}
.btn:hover:not(:disabled) { background: #e09820; }
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.error {
font-family: 'Crimson Text', serif;
font-size: 14px;
color: #f06060;
margin: 0;
min-height: 1.4em;
text-align: center;
}
</style>

View File

@@ -0,0 +1,545 @@
<script>
import { onMount } from 'svelte';
// A fake card for display purposes
const exampleCard = {
name: "Harald Bluetooth",
image_link: "https://upload.wikimedia.org/wikipedia/commons/thumb/c/ce/1200_Harald_Bl%C3%A5tand_anagoria.jpg/330px-1200_Harald_Bl%C3%A5tand_anagoria.jpg",
card_rarity: "rare",
card_type: "person",
wikidata_instance: "Q5",
text: "Harald \"Bluetooth\" Gormsson was a king of Denmark and Norway.",
attack: 351,
defense: 222,
cost: 5,
created_at: new Date().toISOString(),
reported: false,
};
const annotations = [
{ number: 1, label: "Name", description: "The name of the Wikipedia article this card was generated from." },
{ number: 2, label: "Type", description: "The category of the subject — Person, Location, Artwork, etc." },
{ number: 3, label: "Rarity badge", description: "Rarity is determined by the article's WikiRank quality score. From lowest to highest: Common, Uncommon, Rare, Super Rare, Epic, and Legendary." },
{ number: 4, label: "Wikipedia link", description: "Opens the Wikipedia article this card was generated from." },
{ number: 5, label: "Cost bubbles", description: "How much energy it costs to play this card. Derived from the card's attack and defense stats." },
{ number: 6, label: "Article text", description: "The opening paragraph of the Wikipedia article." },
{ number: 7, label: "Attack", description: "Determines how much damage this card deals when it attacks. Based on how many Wikipedia language editions the article appears in." },
{ number: 8, label: "Defense", description: "How much damage this card can absorb before being destroyed. Based on the article's monthly page views." },
];
// Annotation positions as percentage of card width/height
const markerPositions = [
{ number: 1, x: 15, y: 3 }, // name — top center
{ number: 2, x: 75, y: 3 }, // type badge — top right
{ number: 3, x: 14, y: 20 }, // rarity badge — top left of image
{ number: 4, x: 85, y: 20 }, // wiki link — top right of image
{ number: 5, x: 15, y: 53 }, // cost bubbles — bottom left of image
{ number: 6, x: 50, y: 73 }, // text — middle
{ number: 7, x: 15, y: 88 }, // attack — bottom left
{ number: 8, x: 85, y: 88 }, // defense — bottom right
];
</script>
<main>
<div class="content">
<h1 class="page-title">How to Play</h1>
<section class="section">
<h2 class="section-title">Understanding Your Cards</h2>
<div class="card-explainer">
<div class="card-annotated">
<div class="card-display">
<!-- Inline card rendering matching Card.svelte visuals -->
<div class="demo-card">
<div class="demo-inner" style="--bg: #f0e0c8; --header: #b87830">
<div class="demo-header">
<span class="demo-name">{exampleCard.name}</span>
<span class="demo-type-badge">Person</span>
</div>
<div class="demo-image-wrap">
<img src={exampleCard.image_link} alt={exampleCard.name} class="demo-image" />
<div class="demo-rarity" style="background: #2a5a9b; color: #fff">R</div>
<a href="https://en.wikipedia.org/wiki/Harald_Bluetooth" target="_blank" rel="noopener" class="demo-wiki">
<svg viewBox="0 0 50 50" width="14" height="14"><circle cx="25" cy="25" r="24" fill="white" stroke="#888" stroke-width="1"/><text x="25" y="33" text-anchor="middle" font-family="serif" font-size="28" font-weight="bold" fill="#000">W</text></svg>
</a>
<div class="demo-cost-bubbles">
{#each { length: exampleCard.cost } as _}
<div class="demo-cost-bubble"></div>
{/each}
</div>
</div>
<div class="demo-divider"></div>
<div class="demo-text">{exampleCard.text}</div>
<div class="demo-footer" style="background: #e8d8b8">
<span class="demo-stat">ATK <strong>{exampleCard.attack}</strong></span>
<span class="demo-date">{new Date(exampleCard.created_at).toLocaleDateString()}</span>
<span class="demo-stat">DEF <strong>{exampleCard.defense}</strong></span>
</div>
</div>
</div>
</div>
<!-- Annotation markers -->
<div class="markers">
{#each markerPositions as pos}
<div class="marker" style="left: {pos.x}%; top: {pos.y}%">
<div class="marker-bubble">{pos.number}</div>
</div>
{/each}
</div>
</div>
<ol class="annotation-list">
{#each annotations as a}
<li>
<span class="annotation-number">{a.number}</span>
<div class="annotation-content">
<span class="annotation-label">{a.label}</span>
<span class="annotation-desc">{a.description}</span>
</div>
</li>
{/each}
</ol>
</div>
</section>
<section class="section">
<h2 class="section-title">Taking a Turn</h2>
<div class="rules-grid">
<div class="rule-card">
<div class="rule-icon"></div>
<h3 class="rule-title">Energy</h3>
<p class="rule-body">You start your first turn with 1 energy (or 2 if you're the second player). Each subsequent turn you gain one more, up to a maximum of 6. Energy does not carry over between turns.</p>
</div>
<div class="rule-card">
<div class="rule-icon"></div>
<h3 class="rule-title">Drawing</h3>
<p class="rule-body">At the start of your turn you draw cards until you have 5 in your hand. You cannot draw more than 5 cards.</p>
</div>
<div class="rule-card">
<div class="rule-icon"></div>
<h3 class="rule-title">Playing Cards</h3>
<p class="rule-body">Select a card from your hand, then click an empty slot on your side of the board. The card must have a cost less or equal to your current energy. You can have up to 5 cards in play at once.</p>
</div>
<div class="rule-card">
<div class="rule-icon">🗡</div>
<h3 class="rule-title">Sacrificing</h3>
<p class="rule-body">Click the dagger icon to enter sacrifice mode, then click one of your cards to remove it from play and recover its energy cost. Use this to afford expensive cards.</p>
</div>
</div>
</section>
<section class="section">
<h2 class="section-title">Combat</h2>
<p class="body-text">When you end your turn, all your cards attack simultaneously. Each card attacks the card directly opposite it:</p>
<div class="rules-grid">
<div class="rule-card">
<div class="rule-icon"></div>
<h3 class="rule-title">Opposed Attack</h3>
<p class="rule-body">If there is an enemy card in the opposing slot, your card deals its ATK as damage to that card's DEF. If DEF reaches zero, the card is destroyed.</p>
</div>
<div class="rule-card">
<div class="rule-icon"></div>
<h3 class="rule-title">Direct Attack</h3>
<p class="rule-body">If the opposing slot is empty, your card attacks the opponent's life total directly, dealing damage equal to its ATK.</p>
</div>
<!-- <div class="rule-card">
<div class="rule-icon">🛡</div>
<h3 class="rule-title">No Overflow</h3>
<p class="rule-body">Excess damage does not carry through. A card that destroys an opposing card does not deal the remaining damage to the opponent's life total.</p>
</div> -->
</div>
</section>
<section class="section">
<h2 class="section-title">Winning & Losing</h2>
<div class="rules-grid">
<div class="rule-card">
<div class="rule-icon">💀</div>
<h3 class="rule-title">Life Total</h3>
<p class="rule-body">Each player starts with 500 life. Reduce your opponent's life to zero to win.</p>
</div>
<div class="rule-card">
<div class="rule-icon">🃏</div>
<h3 class="rule-title">No Cards Left</h3>
<p class="rule-body">If you have cards in play and your opponent has no playable cards remaining in their deck, hand, or board, you win.</p>
</div>
<div class="rule-card">
<div class="rule-icon"></div>
<h3 class="rule-title">Timeout</h3>
<p class="rule-body">Each player has 2 minutes per turn. If your opponent's timer runs out, you win.</p>
</div>
<div class="rule-card">
<div class="rule-icon">🔌</div>
<h3 class="rule-title">Disconnect</h3>
<p class="rule-body">If your opponent disconnects and does not reconnect within 15 seconds, you win.</p>
</div>
</div>
</section>
</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 {
height: 100vh;
overflow-y: auto;
background: #0d0a04;
}
.content {
max-width: 1000px;
margin: 0 auto;
padding: 2rem;
}
.page-title {
font-family: 'Cinzel', serif;
font-size: 28px;
font-weight: 700;
color: #f0d080;
margin: 0 0 2rem;
letter-spacing: 0.08em;
}
.section {
margin-bottom: 3rem;
}
.section-title {
font-family: 'Cinzel', serif;
font-size: 14px;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: #f0d080AA;
margin: 0 0 1.25rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid #f0d08055;
}
.body-text ul {
margin-left: 20px;
}
.body-text {
font-family: 'Crimson Text', serif;
font-size: 17px;
color: rgba(240, 180, 80, 0.75);
line-height: 1.7;
margin: 0 0 1rem;
}
/* ── Card explainer ── */
.card-explainer {
display: flex;
gap: 3rem;
align-items: flex-start;
flex-wrap: wrap;
}
.card-annotated {
position: relative;
flex-shrink: 0;
}
.card-display {
position: relative;
}
.markers {
position: absolute;
inset: 0;
pointer-events: none;
}
.marker {
position: absolute;
transform: translate(-50%, -50%);
z-index: 10;
}
.marker-bubble {
width: 22px;
height: 22px;
border-radius: 50%;
background: #c8861a;
border: 2px solid #fff;
color: #fff;
font-family: 'Cinzel', serif;
font-size: 11px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 8px rgba(0,0,0,0.6);
}
/* ── Annotation list ── */
.annotation-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.9rem;
flex: 1;
min-width: 260px;
}
.annotation-list li {
display: flex;
gap: 0.75rem;
align-items: flex-start;
}
.annotation-number {
width: 22px;
height: 22px;
border-radius: 50%;
background: #c8861a;
color: #fff;
font-family: 'Cinzel', serif;
font-size: 11px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-top: 2px;
}
.annotation-content {
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.annotation-label {
font-family: 'Cinzel', serif;
font-size: 12px;
font-weight: 700;
color: #f0d080;
letter-spacing: 0.04em;
}
.annotation-desc {
font-family: 'Crimson Text', serif;
font-size: 14px;
color: rgba(240, 180, 80, 0.6);
line-height: 1.5;
}
/* ── Rules grid ── */
.rules-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 12px;
}
.rule-card {
background: #1a1008;
border: 1px solid rgba(107, 76, 30, 0.3);
border-radius: 8px;
padding: 1.25rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.rule-icon {
color: #f0d080AA;
font-size: 20px;
line-height: 1;
}
.rule-title {
font-family: 'Cinzel', serif;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.06em;
color: #f0d080;
margin: 0;
}
.rule-body {
font-family: 'Crimson Text', serif;
font-size: 14px;
color: rgba(240, 180, 80, 0.6);
line-height: 1.55;
margin: 0;
}
/* ── Demo card ── */
.demo-card {
width: 300px;
border-radius: 12px;
padding: 7px;
background: #111;
border: 2px solid #111;
box-shadow: 0 4px 24px rgba(0,0,0,0.5);
font-family: 'Crimson Text', serif;
position: relative;
user-select: none;
}
.demo-inner {
border-radius: 8px;
overflow: hidden;
background: var(--bg);
border: 2px solid #000;
display: flex;
flex-direction: column;
}
.demo-header {
padding: 9px 12px 7px;
background: var(--header);
border-bottom: 2px solid #000;
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 8px;
}
.demo-name {
font-family: 'Cinzel', serif;
font-size: 13px;
font-weight: 700;
color: #fff;
text-shadow: 0 1px 3px rgba(0,0,0,0.6);
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.demo-type-badge {
font-family: 'Cinzel', serif;
font-size: 9px;
color: rgba(255,255,255,0.95);
text-transform: uppercase;
letter-spacing: 0.05em;
background: rgba(0,0,0,0.25);
padding: 1px 5px;
border-radius: 3px;
white-space: nowrap;
}
.demo-image-wrap {
position: relative;
width: 100%;
aspect-ratio: 4/3;
overflow: hidden;
border-bottom: 2px solid #000;
}
.demo-image {
width: 100%;
height: 100%;
object-fit: cover;
object-position: top;
display: block;
}
.demo-rarity {
position: absolute;
top: 7px;
left: 7px;
width: 26px;
height: 26px;
border-radius: 50%;
border: 2.5px solid #000;
font-family: 'Cinzel', serif;
font-size: 9px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
z-index: 3;
}
.demo-wiki {
position: absolute;
top: 7px;
right: 7px;
width: 26px;
height: 26px;
border-radius: 50%;
background: rgba(255,255,255,0.92);
border: 1.5px solid #000;
display: flex;
align-items: center;
justify-content: center;
z-index: 3;
}
.demo-cost-bubbles {
position: absolute;
bottom: 6px;
left: 8px;
display: flex;
gap: 3px;
flex-wrap: wrap;
max-width: calc(100% - 16px);
}
.demo-cost-bubble {
width: 16px;
height: 16px;
border-radius: 50%;
background: #6ea0ec;
border: 2.5px solid #000;
display: flex;
align-items: center;
justify-content: center;
color: #08152c;
font-size: 12px;
font-weight: 700;
font-family: 'Cinzel', serif;
line-height: 1;
}
.demo-divider {
height: 2px;
background: #000;
}
.demo-text {
padding: 10px 12px;
font-size: 13px;
line-height: 1.55;
color: #1a1208;
font-style: italic;
background: #f0e6cc;
border-bottom: 2px solid #000;
height: 110px;
overflow: hidden;
}
.demo-footer {
padding: 7px 12px;
display: flex;
justify-content: space-between;
align-items: center;
}
.demo-stat {
font-family: 'Cinzel', serif;
font-size: 11px;
color: #2a2010;
letter-spacing: 0.03em;
}
.demo-stat strong {
color: #000;
font-size: 15px;
}
.demo-date {
font-size: 10px;
color: rgba(0,0,0,0.5);
font-style: italic;
font-family: 'Crimson Text', serif;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,344 @@
<script>
import { API_URL, WS_URL } from '$lib/api.js';
import { apiFetch } from '$lib/api.js';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
let profile = $state(null);
let loading = $state(true);
const token = () => localStorage.getItem('token');
onMount(async () => {
if (!token()) { goto('/auth'); return; }
const res = await apiFetch(`${API_URL}/profile`);
if (res.status === 401) { goto('/auth'); return; }
profile = await res.json();
loading = false;
});
function logout() {
localStorage.removeItem('token');
localStorage.removeItem('refresh_token');
goto('/auth');
}
</script>
<main>
{#if loading}
<p class="status">Loading...</p>
{:else if profile}
<div class="profile">
<div class="profile-header">
<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="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>
<a href="/reset-password" class="reset-link">Change password</a>
</div>
<div class="section-divider"></div>
<h2 class="section-title">Stats</h2>
<div class="stats-grid">
<div class="stat-card">
<span class="stat-label">Wins</span>
<span class="stat-value wins">{profile.wins}</span>
</div>
<div class="stat-card">
<span class="stat-label">Losses</span>
<span class="stat-value losses">{profile.losses}</span>
</div>
<div class="stat-card">
<span class="stat-label">Games Played</span>
<span class="stat-value">{profile.wins + profile.losses}</span>
</div>
<div class="stat-card">
<span class="stat-label">Win Rate</span>
<span class="stat-value" class:good-wr={profile.win_rate >= 50} class:bad-wr={profile.win_rate !== null && profile.win_rate < 50}>
{profile.win_rate !== null ? `${profile.win_rate}%` : '—'}
</span>
</div>
</div>
<div class="section-divider"></div>
<h2 class="section-title">Highlights</h2>
<div class="highlights">
<div class="highlight-card">
<span class="highlight-label">Most Played Deck</span>
{#if profile.most_played_deck}
<span class="highlight-value">{profile.most_played_deck.name}</span>
<span class="highlight-sub">{profile.most_played_deck.times_played} games</span>
{:else}
<span class="no-data">No games played yet</span>
{/if}
</div>
<div class="highlight-card">
<span class="highlight-label">Most Played Card</span>
{#if profile.most_played_card}
<div class="card-preview">
{#if profile.most_played_card.image_link}
<img src={profile.most_played_card.image_link} alt={profile.most_played_card.name} class="card-thumb" />
{/if}
<div class="card-preview-info">
<span class="highlight-value">{profile.most_played_card.name}</span>
<span class="highlight-sub">{profile.most_played_card.times_played} times played</span>
<span class="highlight-sub">{profile.most_played_card.card_type} · {profile.most_played_card.card_rarity}</span>
</div>
</div>
{:else}
<span class="no-data">No cards played yet</span>
{/if}
</div>
</div>
</div>
{/if}
</main>
<style>
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;700&family=Crimson+Text:ital,wght@0,400;0,600;1,400&display=swap');
main {
height: 100vh;
overflow-y: auto;
background: #0d0a04;
padding: 2rem;
}
.profile {
max-width: 700px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.profile-header {
display: flex;
align-items: center;
gap: 1.5rem;
}
.avatar {
width: 64px;
height: 64px;
border-radius: 50%;
background: #3d2507;
border: 2px solid #c8861a;
display: flex;
align-items: center;
justify-content: center;
font-family: 'Cinzel', serif;
font-size: 28px;
font-weight: 700;
color: #f0d080;
flex-shrink: 0;
}
.profile-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.username {
font-family: 'Cinzel', serif;
font-size: 24px;
font-weight: 700;
color: #f0d080;
margin: 0;
}
.email {
font-family: 'Crimson Text', serif;
font-size: 15px;
color: rgba(240, 180, 80, 0.5);
margin: 0;
}
.joined {
font-family: 'Crimson Text', serif;
font-size: 13px;
font-style: italic;
color: rgba(240, 180, 80, 0.35);
margin: 0;
}
.logout-btn {
font-family: 'Cinzel', serif;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
background: none;
border: 1px solid rgba(180, 60, 60, 0.4);
border-radius: 4px;
color: rgba(200, 80, 80, 0.7);
padding: 8px 16px;
cursor: pointer;
transition: all 0.15s;
align-self: flex-start;
}
.logout-btn:hover {
border-color: #c84040;
color: #e05050;
background: rgba(180, 40, 40, 0.1);
}
.reset-link {
font-family: 'Crimson Text', serif;
font-size: 14px;
font-style: italic;
color: rgba(240, 180, 80, 0.4);
text-decoration: underline;
align-self: flex-start;
transition: color 0.15s;
}
.reset-link:hover { color: rgba(240, 180, 80, 0.7); }
.section-divider {
height: 1px;
background: rgba(107, 76, 30, 0.3);
}
.section-title {
font-family: 'Cinzel', serif;
font-size: 13px;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: rgba(240, 180, 80, 0.4);
margin: 0;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
}
.stat-card {
background: #1a1008;
border: 1px solid rgba(107, 76, 30, 0.3);
border-radius: 8px;
padding: 1rem;
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.stat-label {
font-family: 'Cinzel', serif;
font-size: 9px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: rgba(240, 180, 80, 0.4);
}
.stat-value {
font-family: 'Cinzel', serif;
font-size: 28px;
font-weight: 700;
color: #f0d080;
}
.wins { color: #6aaa6a; }
.losses { color: #c85050; }
.good-wr { color: #6aaa6a; }
.bad-wr { color: #c85050; }
.highlights {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.highlight-card {
background: #1a1008;
border: 1px solid rgba(107, 76, 30, 0.3);
border-radius: 8px;
padding: 1rem;
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.highlight-label {
font-family: 'Cinzel', serif;
font-size: 9px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: rgba(240, 180, 80, 0.4);
}
.highlight-value {
font-family: 'Cinzel', serif;
font-size: 16px;
font-weight: 700;
color: #f0d080;
}
.highlight-sub {
font-family: 'Crimson Text', serif;
font-size: 13px;
font-style: italic;
color: rgba(240, 180, 80, 0.45);
}
.card-preview {
display: flex;
gap: 0.75rem;
align-items: flex-start;
margin-top: 0.25rem;
}
.card-thumb {
width: 48px;
height: 48px;
object-fit: cover;
object-position: top;
border-radius: 4px;
border: 1px solid rgba(107, 76, 30, 0.4);
flex-shrink: 0;
}
.card-preview-info {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.no-data {
font-family: 'Crimson Text', serif;
font-size: 14px;
font-style: italic;
color: rgba(240, 180, 80, 0.25);
}
.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;
}
@media (max-width: 640px) {
.stats-grid { grid-template-columns: repeat(2, 1fr); }
.highlights { grid-template-columns: 1fr; }
.profile-header { flex-wrap: wrap; }
}
</style>

View File

@@ -0,0 +1,191 @@
<script>
import { API_URL, WS_URL } from '$lib/api.js';
import { apiFetch } from '$lib/api.js';
import { goto } from '$app/navigation';
let currentPassword = $state('');
let newPassword = $state('');
let confirmPassword = $state('');
let error = $state('');
let success = $state(false);
let loading = $state(false);
const token = () => localStorage.getItem('token');
function validate() {
if (!currentPassword) return 'Current password is required';
if (newPassword.length < 8) return 'New password must be at least 8 characters';
if (newPassword.length > 256) return 'New password must be 256 characters or fewer';
if (newPassword !== confirmPassword) return 'Passwords do not match';
if (newPassword === currentPassword) return 'New password must be different from current password';
return null;
}
async function submit() {
error = '';
const validationError = validate();
if (validationError) { error = validationError; return; }
loading = true;
try {
const res = await apiFetch(`${API_URL}/auth/reset-password`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
current_password: currentPassword,
new_password: newPassword,
}),
});
const data = await res.json();
if (!res.ok) { error = data.detail; return; }
success = true;
} catch (e) {
error = 'Something went wrong. Please try again.';
} finally {
loading = false;
}
}
</script>
<main>
<div class="card">
{#if success}
<h1 class="title">Password Updated</h1>
<p class="hint">Your password has been changed successfully.</p>
<button class="btn" onclick={() => goto('/profile')}>Back to Profile</button>
{:else}
<h1 class="title">Reset Password</h1>
<div class="fields">
<label class="field-label" for="current">Current Password</label>
<input id="current" type="password" placeholder="Current password" bind:value={currentPassword} />
<label class="field-label" for="new">New Password</label>
<input id="new" type="password" placeholder="At least 8 characters" bind:value={newPassword} />
<label class="field-label" for="confirm">Confirm New Password</label>
<input id="confirm" type="password" placeholder="Repeat new password" bind:value={confirmPassword} />
</div>
<p class="error">{error}</p>
<button class="btn" onclick={submit} disabled={loading}>
{loading ? 'Updating...' : 'Update Password'}
</button>
<button class="back-link" onclick={() => goto('/profile')}> Back 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;
}
.fields {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.field-label {
font-family: 'Cinzel', serif;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: rgba(245, 208, 96, 0.5);
}
input {
width: 100%;
padding: 9px 12px;
background: #221508;
border: 1.5px solid #c8861a;
border-radius: 6px;
color: #f5d060;
font-family: 'Crimson Text', serif;
font-size: 15px;
box-sizing: border-box;
outline: none;
margin-bottom: 0.4rem;
}
input:focus { border-color: #f5d060; }
input::placeholder { color: rgba(245, 208, 96, 0.35); }
.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:not(:disabled) { background: #e09820; }
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.back-link {
all: unset;
font-family: 'Crimson Text', serif;
font-size: 14px;
color: rgba(245, 208, 96, 0.5);
cursor: pointer;
text-align: center;
transition: color 0.15s;
}
.back-link:hover { color: #f5d060; }
.error {
font-family: 'Crimson Text', serif;
font-size: 14px;
color: #f06060;
margin: 0;
min-height: 1.4em;
text-align: center;
}
</style>