🐐
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
# Design Language
|
||||
|
||||
## Aesthetic
|
||||
Dark fantasy TCG aesthetic. Warm golds and bronzes on near-black brown backgrounds. Cinzel for headings/labels/buttons, Crimson Text for body/prose/inputs.
|
||||
|
||||
## Colors
|
||||
|
||||
All core colors are available as CSS custom properties on `:root` in `app.css` (e.g. `var(--color-bg)`, `var(--color-bronze)`).
|
||||
|
||||
| Role | Value |
|
||||
|------|-------|
|
||||
| Page background | `#0d0a04` |
|
||||
| Header background | `#1a1008` |
|
||||
| Modal / dark surface | `#110d04` |
|
||||
| Raised surface | `#3d2507` |
|
||||
| Primary text / accent | `#f0d080` (gold) |
|
||||
| Secondary text | `rgba(240, 180, 80, 0.6)` |
|
||||
| Placeholder text | `rgba(240, 180, 80, 0.3–0.4)` |
|
||||
| Interactive accent (buttons, borders, hover) | `#c8861a` (bronze-orange) |
|
||||
| Interactive accent hover | `#e09820` |
|
||||
| Border / divider | `#6b4c1e` |
|
||||
| Subtle border | `rgba(107, 76, 30, 0.3–0.5)` |
|
||||
| Button text | `#fff8e0` |
|
||||
| Error / delete | `#c84040` |
|
||||
| Success / positive | `#6aaa6a` |
|
||||
| Energy cost indicator | `#6ea0ec` |
|
||||
|
||||
## Typography
|
||||
- **Headings / labels / buttons**: Cinzel (Google Fonts), weights 400/700/900
|
||||
- **Body / prose / inputs**: Crimson Text (Google Fonts), weights 400/600; italic for flavor/secondary text
|
||||
- Button and label text: **uppercase**, `letter-spacing: 0.06–0.1em`
|
||||
- Form labels: uppercase, `letter-spacing: 0.08em`
|
||||
|
||||
### Type Scale
|
||||
|
||||
All type scale tokens are CSS custom properties on `:root` in `app.css`.
|
||||
|
||||
| Token | Value | Use for |
|
||||
|-------|-------|---------|
|
||||
| `--text-xs` | `9px` | Fine print, badges, metadata labels |
|
||||
| `--text-sm` | `11px` | Secondary text, captions, small labels |
|
||||
| `--text-base` | `13px` | Default body text, buttons, card details |
|
||||
| `--text-md` | `15px` | Form inputs, emphasized body text |
|
||||
| `--text-lg` | `18px` | Section headings, card titles |
|
||||
| `--text-xl` | `22px` | Page headings, prominent labels |
|
||||
| `--text-2xl` | `28px` | Large display headings |
|
||||
| `--text-3xl` | `36px` | Hero headings, splash text |
|
||||
|
||||
## Buttons
|
||||
|
||||
| Variant | Background | Border | Text |
|
||||
|---------|-----------|--------|------|
|
||||
| Primary | `#c8861a` | none | `#fff8e0` |
|
||||
| Secondary | `#3d2507` | `1px solid rgba(107,76,30,0.4)` | `#f0d080` |
|
||||
| Destructive | `rgba(180,40,40,0.8)` | none | `#fff` |
|
||||
|
||||
### Sizes
|
||||
|
||||
All button size tokens are CSS custom properties on `:root` in `app.css`.
|
||||
|
||||
| Size | Padding | Font-size | Border-radius | Use for |
|
||||
|------|---------|-----------|---------------|---------|
|
||||
| Small | `4px 10px` | `10px` | `var(--radius-sm)` | Toolbar filters, sort toggles, inline actions, edit/delete |
|
||||
| Medium | `8px 18px` | `12px` | `var(--radius-md)` | Card actions, done/choose, friend, secondary, cancel |
|
||||
| Large | `10px 32px` | `13px` | `var(--radius-md)` | Primary CTAs, auth submit, play, buy, accept trade |
|
||||
|
||||
- Font: Cinzel 700 uppercase, letter-spacing 0.06–0.1em
|
||||
- Disabled: opacity 0.5; hover: lighten background or brighten border + text
|
||||
|
||||
## Inputs / Forms
|
||||
- Background: `#1a1008`; border-radius: 6px; color: `#f0d080`; font: Crimson Text 15px
|
||||
- Text inputs: `1.5px solid #6b4c1e`; focus border: `#c8861a`
|
||||
- Selects: `1.5px solid #6b4c1e`
|
||||
- Placeholder: `rgba(240, 180, 80, 0.4)`; accent-color (checkboxes, ranges): `#c8861a`
|
||||
|
||||
## Containers / Panels
|
||||
|
||||
Border-radius, shadows, z-index layers, and spacing are available as CSS custom properties on `:root` in `app.css` (e.g. `var(--radius-lg)`, `var(--shadow-card)`, `var(--z-modal)`, `var(--space-md)`).
|
||||
|
||||
- Border-radius: 10–12px; box-shadow: `0 4px 24px rgba(0,0,0,0.5)`
|
||||
- Borders: `1–2px solid #6b4c1e` standard, `#c8861a` for emphasis
|
||||
- Backgrounds: `#1a1008` (surface), `#3d2507` (raised)
|
||||
- Card hover lift: `translateY(-4px) scale(1.02)`, shadow `0 12px 40px rgba(0,0,0,0.6)`
|
||||
|
||||
## Transitions
|
||||
- Default: `0.15s ease` on background, border-color, color, transform
|
||||
- Card hover: `0.2s ease`
|
||||
|
||||
## Card Type Colors (CSS vars `--bg` / `--header`)
|
||||
|
||||
| Type | Background | Header |
|
||||
|------|-----------|--------|
|
||||
| person | `#f0e0c8` | `#b87830` |
|
||||
| location | `#d8e8d4` | `#4a7a50` |
|
||||
| artwork | `#e4d4e8` | `#7a5090` |
|
||||
| life_form | `#ccdce8` | `#3a6878` |
|
||||
| event | `#e8d4d4` | `#8b2020` |
|
||||
| group | `#e8e4d0` | `#748c12` |
|
||||
| science_thing | `#c7c5c1` | `#060c17` |
|
||||
| vehicle | `#c7c1c4` | `#801953` |
|
||||
| organization | `#b7c1c4` | `#3c5251` |
|
||||
|
||||
## Rarity Badge Colors
|
||||
|
||||
| Rarity | Background | Text |
|
||||
|--------|-----------|------|
|
||||
| common | `#c8c8c8` | `#333` |
|
||||
| uncommon | `#4a7a50` | `#fff` |
|
||||
| rare | `#2a5a9b` | `#fff` |
|
||||
| super_rare | `#7a3a9b` | `#fff` |
|
||||
| epic | `#9b3a3a` | `#fff` |
|
||||
| legendary | `#b87820` | `#fff` |
|
||||
|
||||
## Card Component
|
||||
- Outer border and internal borders: `#000` (pure black) — these are structural borders within the card face, distinct from the themed `#6b4c1e` borders on containers/panels.
|
||||
- Card background: `#111`; border-radius: 12px; padding: 7px
|
||||
|
||||
## Spacing
|
||||
- Page padding: `2rem`; section gap: `1–1.5rem`; component internal gap: `0.4–0.75rem`
|
||||
+102
-1
@@ -1,14 +1,115 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;700;900&family=Crimson+Text:ital,wght@0,400;0,600;1,400&display=swap');
|
||||
|
||||
:root {
|
||||
/* Colors */
|
||||
--color-bg: #0d0a04;
|
||||
--color-surface: #1a1008;
|
||||
--color-surface-raised: #3d2507;
|
||||
--color-gold: #f0d080;
|
||||
--color-gold-muted: rgba(240, 180, 80, 0.8);
|
||||
--color-gold-dim: rgba(240, 180, 80, 0.6);
|
||||
--color-gold-faint: rgba(240, 180, 80, 0.4);
|
||||
--color-bronze: #c8861a;
|
||||
--color-bronze-hover: #e09820;
|
||||
--color-border: #6b4c1e;
|
||||
--color-border-subtle: rgba(107, 76, 30, 0.4);
|
||||
--color-border-dim: rgba(107, 76, 30, 0.3);
|
||||
--color-overlay: rgba(0, 0, 0, 0.5);
|
||||
--color-btn-text: #fff8e0;
|
||||
--color-error: #c84040;
|
||||
--color-success: #6aaa6a;
|
||||
--color-energy: #6ea0ec;
|
||||
--color-shard: #b87820;
|
||||
--color-cyan: #7ecfcf; /* shard quantity / cyan accent */
|
||||
|
||||
/* Border-radius */
|
||||
--radius-sm: 4px;
|
||||
--radius-md: 6px;
|
||||
--radius-lg: 10px;
|
||||
--radius-xl: 12px;
|
||||
--radius-full: 50%;
|
||||
|
||||
/* Shadows */
|
||||
--shadow-subtle: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
--shadow-card: 0 4px 24px rgba(0, 0, 0, 0.5);
|
||||
--shadow-elevated: 0 12px 40px rgba(0, 0, 0, 0.6);
|
||||
--shadow-glow: 0 0 20px rgba(200, 134, 26, 0.3);
|
||||
|
||||
/* Z-index layers */
|
||||
--z-base: 1;
|
||||
--z-card: 10;
|
||||
--z-header: 100;
|
||||
--z-dropdown: 200;
|
||||
--z-modal: 300;
|
||||
--z-toast: 400;
|
||||
|
||||
/* Type scale */
|
||||
--text-xs: 9px;
|
||||
--text-sm: 11px;
|
||||
--text-base: 13px;
|
||||
--text-md: 15px;
|
||||
--text-lg: 18px;
|
||||
--text-xl: 22px;
|
||||
--text-2xl: 28px;
|
||||
--text-3xl: 36px;
|
||||
|
||||
/* Button sizes */
|
||||
--btn-padding-sm: 4px 10px;
|
||||
--btn-font-sm: 10px;
|
||||
--btn-padding-md: 8px 18px;
|
||||
--btn-font-md: 12px;
|
||||
--btn-padding-lg: 10px 32px;
|
||||
--btn-font-lg: 13px;
|
||||
|
||||
/* Spacing */
|
||||
--space-xs: 0.25rem;
|
||||
--space-sm: 0.5rem;
|
||||
--space-md: 1rem;
|
||||
--space-lg: 1.5rem;
|
||||
--space-xl: 2rem;
|
||||
}
|
||||
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--color-border) transparent;
|
||||
}
|
||||
|
||||
body {
|
||||
background: #0d0a04;
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--color-border);
|
||||
border-radius: 3px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--color-bronze);
|
||||
}
|
||||
|
||||
@keyframes shard-pulse {
|
||||
0%, 100% {
|
||||
filter: brightness(1);
|
||||
text-shadow: 0 0 6px rgba(126, 207, 207, 0.45);
|
||||
}
|
||||
50% {
|
||||
filter: brightness(1.45);
|
||||
text-shadow: 0 0 14px rgba(126, 207, 207, 0.9), 0 0 28px rgba(126, 207, 207, 0.35);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
<script>
|
||||
<script lang="ts">
|
||||
let { card, noHover = false, defenseOverride = null } = $props();
|
||||
|
||||
const RARITY_BADGE = {
|
||||
@@ -7,7 +7,7 @@
|
||||
rare: { symbol: "R", label: "Rare", bg: "#2a5a9b", color: "#fff" },
|
||||
super_rare: { symbol: "SR", label: "Super Rare", bg: "#7a3a9b", color: "#fff" },
|
||||
epic: { symbol: "E", label: "Epic", bg: "#9b3a3a", color: "#fff" },
|
||||
legendary: { symbol: "L", label: "Legendary", bg: "#b87820", color: "#fff" },
|
||||
legendary: { symbol: "L", label: "Legendary", bg: "#b87820", color: "#fff8e0" },
|
||||
};
|
||||
|
||||
const TYPE_COLORS = {
|
||||
@@ -26,13 +26,13 @@
|
||||
const FOIL_RARITIES = new Set(["super_rare", "epic", "legendary"]);
|
||||
|
||||
let rarity = $derived(card.card_rarity);
|
||||
let badge = $derived(RARITY_BADGE[rarity] ?? RARITY_BADGE.common);
|
||||
let badge = $derived(RARITY_BADGE[rarity as keyof typeof RARITY_BADGE] ?? RARITY_BADGE.common);
|
||||
let foil = $derived(FOIL_RARITIES.has(rarity))
|
||||
let foilOffset = $derived(foil ? `${-(Math.random() * 5).toFixed(2)}s` : '0s');
|
||||
let super_rare = $derived(rarity == "super_rare");
|
||||
let epic = $derived(rarity == "epic");
|
||||
let legendary = $derived(rarity === "legendary");
|
||||
let colors = $derived(TYPE_COLORS[card.card_type] ?? TYPE_COLORS.other);
|
||||
let colors = $derived(TYPE_COLORS[card.card_type as keyof typeof TYPE_COLORS] ?? TYPE_COLORS.other);
|
||||
let typeLabel = $derived(card.card_type.charAt(0).toUpperCase() + card.card_type.slice(1).replace("_", " "));
|
||||
let wikiUrl = $derived("https://en.wikipedia.org/wiki/" + encodeURIComponent(card.name.replace(/ /g, "_")));
|
||||
</script>
|
||||
@@ -47,7 +47,7 @@
|
||||
|
||||
<div class="card-image-wrap">
|
||||
{#if card.image_link}
|
||||
<img src={card.image_link} alt={card.name} class="card-image" draggable="false"/>
|
||||
<img src={card.image_link} alt={card.name} class="card-image" draggable="false" loading="lazy"/>
|
||||
{:else}
|
||||
<div class="card-image-placeholder">
|
||||
<span>{card.name[0]}</span>
|
||||
@@ -56,6 +56,17 @@
|
||||
|
||||
<div class="rarity-badge" title={badge.label} style="--rb: {badge.bg}; --rc: {badge.color}">{badge.symbol}</div>
|
||||
|
||||
{#if card.willing_to_trade || card.is_favorite}
|
||||
<div class="card-badges">
|
||||
{#if card.willing_to_trade}
|
||||
<div class="wtt-badge" title="Willing to trade">⇄</div>
|
||||
{/if}
|
||||
{#if card.is_favorite}
|
||||
<div class="favorite-badge" title="Favorite">★</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<a href={wikiUrl} target="_blank" rel="noopener noreferrer" class="wiki-link" title="Open Wikipedia article">
|
||||
<svg viewBox="0 0 50 50" xmlns="http://www.w3.org/2000/svg" width="16" height="16">
|
||||
<circle cx="25" cy="25" r="24" fill="white" stroke="#888" stroke-width="1"/>
|
||||
@@ -76,7 +87,7 @@
|
||||
|
||||
<div class="card-footer">
|
||||
<span class="stat">ATK <strong>{card.attack}</strong></span>
|
||||
<span class="card-date">{new Date(card.created_at).toLocaleDateString()}</span>
|
||||
<span class="card-date">{new Date(card.generated_at ?? card.created_at).toLocaleDateString('en-GB', { year: 'numeric', month: '2-digit', day: '2-digit' })}</span>
|
||||
<span class="stat">DEF <strong>{defenseOverride !== null ? defenseOverride : card.defense}</strong></span>
|
||||
</div>
|
||||
|
||||
@@ -84,15 +95,13 @@
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;700&family=Crimson+Text:ital,wght@0,400;0,600;1,400&display=swap');
|
||||
|
||||
.card {
|
||||
width: 300px;
|
||||
border-radius: 12px;
|
||||
border-radius: var(--radius-xl);
|
||||
padding: 7px;
|
||||
background: #111;
|
||||
border: 2px solid #111;
|
||||
box-shadow: 0 4px 24px rgba(0,0,0,0.5);
|
||||
border: 2px solid #000;
|
||||
box-shadow: var(--shadow-card);
|
||||
font-family: 'Crimson Text', serif;
|
||||
position: relative;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
@@ -103,14 +112,14 @@
|
||||
|
||||
.card:hover {
|
||||
transform: translateY(-4px) scale(1.02);
|
||||
box-shadow: 0 12px 40px rgba(0,0,0,0.6);
|
||||
box-shadow: var(--shadow-elevated);
|
||||
}
|
||||
|
||||
.card.foil::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 12px;
|
||||
border-radius: var(--radius-xl);
|
||||
animation: foil-shift 2.5s ease-in-out infinite alternate;
|
||||
animation-delay: var(--foil-offset, 0s);
|
||||
pointer-events: none;
|
||||
@@ -182,7 +191,7 @@
|
||||
|
||||
.card-name {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 13px;
|
||||
font-size: var(--text-base);
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
line-height: 1.3;
|
||||
@@ -196,7 +205,7 @@
|
||||
|
||||
.card-type-badge {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 9px;
|
||||
font-size: var(--text-xs);
|
||||
color: rgba(255,255,255,0.95);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
@@ -232,7 +241,7 @@
|
||||
justify-content: center;
|
||||
background: #ddd;
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 64px;
|
||||
font-size: var(--text-3xl);
|
||||
color: rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
@@ -242,12 +251,12 @@
|
||||
left: 7px;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 50%;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--rb);
|
||||
border: 2.5px solid #000;
|
||||
color: var(--rc);
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 9px;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -256,13 +265,44 @@
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.card-badges {
|
||||
position: absolute;
|
||||
bottom: 7px;
|
||||
right: 7px;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.wtt-badge, .favorite-badge {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: var(--radius-full);
|
||||
border: 2px solid #000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.wtt-badge {
|
||||
background: rgba(0, 160, 160, 0.85);
|
||||
color: #fff;
|
||||
font-size: var(--text-base);
|
||||
}
|
||||
|
||||
.favorite-badge {
|
||||
background: rgba(255, 200, 0, 0.92);
|
||||
color: #5a3a00;
|
||||
font-size: var(--text-md);
|
||||
}
|
||||
|
||||
.wiki-link {
|
||||
position: absolute;
|
||||
top: 7px;
|
||||
right: 7px;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 50%;
|
||||
border-radius: var(--radius-full);
|
||||
background: rgba(255,255,255,0.92);
|
||||
border: 1.5px solid #000;
|
||||
display: flex;
|
||||
@@ -284,7 +324,7 @@
|
||||
|
||||
.card-text {
|
||||
padding: 10px 12px;
|
||||
font-size: 13px;
|
||||
font-size: var(--text-base);
|
||||
line-height: 1.55;
|
||||
color: #1a1208;
|
||||
font-style: italic;
|
||||
@@ -304,21 +344,21 @@
|
||||
|
||||
.stat {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 11px;
|
||||
font-size: var(--text-sm);
|
||||
color: #2a2010;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.stat strong {
|
||||
color: #000;
|
||||
font-size: 15px;
|
||||
font-size: var(--text-md);
|
||||
}
|
||||
|
||||
.card-date {
|
||||
font-size: 10px;
|
||||
color: rgba(0,0,0,0.5);
|
||||
font-size: var(--text-sm);
|
||||
color: rgba(0,0,0,0.4);
|
||||
font-style: italic;
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-family: 'Cinzel', serif;
|
||||
}
|
||||
|
||||
.cost-bubbles {
|
||||
@@ -335,15 +375,15 @@
|
||||
.cost-bubble {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: #6ea0ec;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--color-energy);
|
||||
border: 2.5px solid #000;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #08152c;
|
||||
font-size: 12px;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 700;
|
||||
font-family: 'Cinzel', serif;
|
||||
line-height: 1;
|
||||
@@ -351,6 +391,6 @@
|
||||
|
||||
.card.no-hover:hover {
|
||||
transform: none;
|
||||
box-shadow: 0 4px 24px rgba(0,0,0,0.5);
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
</style>
|
||||
@@ -1,26 +1,24 @@
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import Card from '$lib/Card.svelte';
|
||||
import { apiFetch, API_URL } from '$lib/api.js';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
const RARITIES = ['common', 'uncommon', 'rare', 'super_rare', 'epic', 'legendary'];
|
||||
const TYPES = ['person', 'location', 'artwork', 'life_form', 'event', 'group', 'science_thing', 'vehicle', 'organization', 'other'];
|
||||
const RARITY_ORDER = Object.fromEntries(RARITIES.map((r, i) => [r, i]));
|
||||
|
||||
let {
|
||||
allCards = [],
|
||||
staticCards = null as any[] | null, // if provided, use this list instead of self-fetching (e.g. another user's WTT cards)
|
||||
selectedIds = $bindable(new Set()),
|
||||
selectedCards = $bindable([]),
|
||||
selectedCost = $bindable(0),
|
||||
costMap = $bindable(new Map()),
|
||||
inDeckIds = new Set(),
|
||||
onclose = null,
|
||||
costLimit = null, // if set, prevents selecting cards that would exceed it
|
||||
showFooter = true, // set false to hide the Done button (e.g. inline deck builder)
|
||||
} = $props();
|
||||
|
||||
const selectedCost = $derived(
|
||||
costLimit !== null
|
||||
? allCards.filter(c => selectedIds.has(c.id)).reduce((sum, c) => sum + c.cost, 0)
|
||||
: 0
|
||||
);
|
||||
|
||||
function label(str) {
|
||||
function label(str: string) {
|
||||
return str.charAt(0).toUpperCase() + str.slice(1).replace(/_/g, ' ');
|
||||
}
|
||||
|
||||
@@ -32,40 +30,159 @@
|
||||
let costMax = $state(10);
|
||||
let filtersOpen = $state(false);
|
||||
let searchQuery = $state('');
|
||||
let willingToTradeOnly = $state(false);
|
||||
|
||||
let filtered = $derived.by(() => {
|
||||
const RARITY_ORDER = Object.fromEntries(RARITIES.map((r, i) => [r, i]));
|
||||
|
||||
// In static mode (staticCards provided), cards are filtered client-side.
|
||||
// In self-fetch mode, they come from the server with all filtering applied.
|
||||
const PAGE_SIZE = 40;
|
||||
let fetchedCards: any[] = $state([]);
|
||||
let total = $state(0);
|
||||
let loadingMore = $state(false);
|
||||
let hasMore = $derived(fetchedCards.length < total);
|
||||
// Must be $state so the IntersectionObserver $effect re-runs when the element is bound
|
||||
let sentinel: HTMLElement | undefined = $state();
|
||||
// .grid has overflow-y: auto — it is the scroll container, not the viewport.
|
||||
let gridEl: HTMLElement | undefined = $state();
|
||||
|
||||
// In static mode, apply client-side filter/sort to staticCards
|
||||
let cards = $derived.by(() => {
|
||||
if (staticCards === null) return fetchedCards;
|
||||
const q = searchQuery.trim().toLowerCase();
|
||||
let result = allCards.filter(c =>
|
||||
let result = staticCards.filter((c: any) =>
|
||||
selectedRarities.has(c.card_rarity) &&
|
||||
selectedTypes.has(c.card_type) &&
|
||||
c.cost >= costMin &&
|
||||
c.cost <= costMax &&
|
||||
(!q || c.name.toLowerCase().includes(q))
|
||||
(!q || c.name.toLowerCase().includes(q)) &&
|
||||
(!willingToTradeOnly || c.willing_to_trade)
|
||||
);
|
||||
result = result.slice().sort((a, b) => {
|
||||
result = result.slice().sort((a: any, b: any) => {
|
||||
let cmp = 0;
|
||||
if (sortBy === 'name') cmp = a.name.localeCompare(b.name);
|
||||
else if (sortBy === 'cost') cmp = b.cost - a.cost || a.name.localeCompare(b.name);
|
||||
else if (sortBy === 'attack') cmp = b.attack - a.attack || a.name.localeCompare(b.name);
|
||||
else if (sortBy === 'defense') cmp = b.defense - a.defense || a.name.localeCompare(b.name);
|
||||
else if (sortBy === 'rarity') cmp = RARITY_ORDER[b.card_rarity] - RARITY_ORDER[a.card_rarity] || a.name.localeCompare(b.name);
|
||||
else if (sortBy === 'date_generated') cmp = (b.generated_at ?? '').localeCompare(a.generated_at ?? '');
|
||||
else if (sortBy === 'date_received') cmp = (b.received_at ?? b.generated_at ?? '').localeCompare(a.received_at ?? a.generated_at ?? '');
|
||||
return sortAsc ? cmp : -cmp;
|
||||
});
|
||||
return result;
|
||||
});
|
||||
|
||||
function toggleSort(val) {
|
||||
// Keep bindable selectedCards in sync with loaded set.
|
||||
$effect(() => {
|
||||
selectedCards = cards.filter((c: any) => selectedIds.has(c.id));
|
||||
});
|
||||
|
||||
// Update costMap as new cards load so we know costs even after they scroll away.
|
||||
$effect(() => {
|
||||
let changed = false;
|
||||
for (const c of cards) {
|
||||
if (!costMap.has(c.id)) changed = true;
|
||||
}
|
||||
if (changed) {
|
||||
const m = new Map(costMap);
|
||||
for (const c of cards) m.set(c.id, c.cost);
|
||||
costMap = m;
|
||||
}
|
||||
});
|
||||
|
||||
// Compute cost from costMap so it includes cards not yet scrolled into view.
|
||||
$effect(() => {
|
||||
let sum = 0;
|
||||
for (const id of selectedIds) {
|
||||
sum += costMap.get(id) ?? 0;
|
||||
}
|
||||
selectedCost = sum;
|
||||
});
|
||||
|
||||
// For non-name sorts the "natural" first click should show the highest/newest/rarest values.
|
||||
// This matches old client-side behaviour where numeric sorts used b - a (descending first).
|
||||
function sortDir() {
|
||||
return sortBy === 'name'
|
||||
? (sortAsc ? 'asc' : 'desc')
|
||||
: (sortAsc ? 'desc' : 'asc');
|
||||
}
|
||||
|
||||
async function fetchCards(reset = false) {
|
||||
if (staticCards !== null) return; // static mode: nothing to fetch
|
||||
if (reset) { fetchedCards = []; total = 0; }
|
||||
if (loadingMore) return;
|
||||
loadingMore = true;
|
||||
const params = new URLSearchParams({
|
||||
skip: String(reset ? 0 : fetchedCards.length),
|
||||
limit: String(PAGE_SIZE),
|
||||
search: searchQuery.trim(),
|
||||
cost_min: String(costMin),
|
||||
cost_max: String(costMax),
|
||||
favorites_only: 'false',
|
||||
wtt_only: String(willingToTradeOnly),
|
||||
sort_by: sortBy,
|
||||
sort_dir: sortDir(),
|
||||
});
|
||||
for (const r of selectedRarities) params.append('rarities', r);
|
||||
for (const t of selectedTypes) params.append('types', t);
|
||||
const res = await apiFetch(`${API_URL}/cards?${params}`);
|
||||
if (!res.ok) { loadingMore = false; return; }
|
||||
const data = await res.json();
|
||||
fetchedCards = reset ? data.cards : [...fetchedCards, ...data.cards];
|
||||
total = data.total;
|
||||
loadingMore = false;
|
||||
}
|
||||
|
||||
// Exposed so parents (e.g. shatter page) can trigger a full refetch after mutations
|
||||
export function refresh() { fetchCards(true); }
|
||||
|
||||
// Debounced refetch on any filter/sort change (only in self-fetch mode).
|
||||
// Skip the initial run — the component mounts and triggers the first fetch directly
|
||||
// via the onMount-equivalent $effect below, avoiding a double-fetch flash.
|
||||
let mounted = false;
|
||||
let fetchTimer: number;
|
||||
$effect(() => {
|
||||
searchQuery; sortBy; sortAsc; selectedRarities; selectedTypes;
|
||||
costMin; costMax; willingToTradeOnly;
|
||||
if (staticCards !== null || !mounted) return;
|
||||
clearTimeout(fetchTimer);
|
||||
fetchTimer = setTimeout(() => fetchCards(true), 300);
|
||||
});
|
||||
|
||||
// onMount runs once and doesn't track reactive dependencies — safe for the initial fetch.
|
||||
// Using $effect here causes re-runs whenever loadingMore changes, resetting state infinitely.
|
||||
onMount(() => {
|
||||
if (staticCards === null) fetchCards(true).then(() => { mounted = true; });
|
||||
else mounted = true;
|
||||
});
|
||||
|
||||
// IntersectionObserver: load next page when sentinel scrolls into view (self-fetch mode only).
|
||||
// Uses gridEl as root because .grid has overflow-y: auto — it is the scroll container.
|
||||
// Both sentinel and gridEl are $state so this effect re-runs once both are bound.
|
||||
$effect(() => {
|
||||
if (!sentinel || !gridEl || staticCards !== null) return;
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0].isIntersecting && hasMore && !loadingMore) fetchCards(false);
|
||||
},
|
||||
{ root: gridEl, rootMargin: '200px' }
|
||||
);
|
||||
observer.observe(sentinel);
|
||||
return () => observer.disconnect();
|
||||
});
|
||||
|
||||
function toggleSort(val: string) {
|
||||
if (sortBy === val) sortAsc = !sortAsc;
|
||||
else { sortBy = val; sortAsc = true; }
|
||||
}
|
||||
|
||||
function toggleRarity(r) {
|
||||
function toggleRarity(r: string) {
|
||||
const s = new Set(selectedRarities);
|
||||
s.has(r) ? s.delete(r) : s.add(r);
|
||||
selectedRarities = s;
|
||||
}
|
||||
|
||||
function toggleType(t) {
|
||||
function toggleType(t: string) {
|
||||
const s = new Set(selectedTypes);
|
||||
s.has(t) ? s.delete(t) : s.add(t);
|
||||
selectedTypes = s;
|
||||
@@ -76,14 +193,17 @@
|
||||
function toggleAllRarities() { selectedRarities = allRaritiesSelected() ? new Set() : new Set(RARITIES); }
|
||||
function toggleAllTypes() { selectedTypes = allTypesSelected() ? new Set() : new Set(TYPES); }
|
||||
|
||||
function toggleCard(id) {
|
||||
function toggleCard(id: string | number) {
|
||||
const s = new Set(selectedIds);
|
||||
if (s.has(id)) {
|
||||
s.delete(id);
|
||||
} else {
|
||||
if (costLimit !== null) {
|
||||
const card = allCards.find(c => c.id === id);
|
||||
if (card && selectedCost + card.cost > costLimit) return;
|
||||
const card = cards.find((c: any) => c.id === id);
|
||||
if (costLimit !== null && card && selectedCost + card.cost > costLimit) return;
|
||||
if (card && !costMap.has(id)) {
|
||||
const m = new Map(costMap);
|
||||
m.set(id, card.cost);
|
||||
costMap = m;
|
||||
}
|
||||
s.add(id);
|
||||
}
|
||||
@@ -101,7 +221,7 @@
|
||||
<div class="toolbar">
|
||||
<div class="sort-row">
|
||||
<span class="toolbar-label">Sort by</span>
|
||||
{#each [['name','Name'],['cost','Cost'],['attack','Attack'],['defense','Defense'],['rarity','Rarity']] as [val, lbl]}
|
||||
{#each [['name','Name'],['cost','Cost'],['attack','Attack'],['defense','Defense'],['rarity','Rarity'],['date_generated','Generated'],['date_received','Received']] as [val, lbl]}
|
||||
<button class="sort-btn" class:active={sortBy === val} onclick={() => toggleSort(val)}>
|
||||
{lbl}
|
||||
{#if sortBy === val}<span class="sort-arrow">{sortAsc ? '↑' : '↓'}</span>{/if}
|
||||
@@ -115,12 +235,21 @@
|
||||
bind:value={searchQuery}
|
||||
/>
|
||||
|
||||
<button class="filter-toggle" onclick={() => filtersOpen = !filtersOpen}>
|
||||
{filtersOpen ? 'Hide filters' : 'Filter'}
|
||||
{#if selectedRarities.size < RARITIES.length || selectedTypes.size < TYPES.length || costMin > 1 || costMax < 10}
|
||||
<span class="filter-dot"></span>
|
||||
{/if}
|
||||
</button>
|
||||
<div class="filter-actions">
|
||||
<button
|
||||
class="filter-toggle"
|
||||
class:active={willingToTradeOnly}
|
||||
onclick={() => willingToTradeOnly = !willingToTradeOnly}
|
||||
title="Show willing to trade only"
|
||||
>⇄</button>
|
||||
|
||||
<button class="filter-toggle" class:active={filtersOpen} onclick={() => filtersOpen = !filtersOpen}>
|
||||
Filters
|
||||
{#if selectedRarities.size < RARITIES.length || selectedTypes.size < TYPES.length || costMin > 1 || costMax < 10}
|
||||
<span class="filter-dot"></span>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if filtersOpen}
|
||||
@@ -171,36 +300,42 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if filtered.length === 0}
|
||||
{#if !loadingMore && cards.length === 0}
|
||||
<p class="status">No cards match your filters.</p>
|
||||
{:else}
|
||||
<div class="grid">
|
||||
{#each filtered as card (card.id)}
|
||||
<button
|
||||
class="card-wrap"
|
||||
class:selected={selectedIds.has(card.id)}
|
||||
class:disabled={costLimit !== null && !selectedIds.has(card.id) && selectedCost + card.cost > costLimit}
|
||||
onclick={() => toggleCard(card.id)}
|
||||
>
|
||||
<Card {card} noHover={true} />
|
||||
{#if selectedIds.has(card.id)}
|
||||
<div class="selected-badge">✓</div>
|
||||
<div class="grid" bind:this={gridEl}>
|
||||
{#each cards as card (card.id)}
|
||||
<div class="card-item">
|
||||
<button
|
||||
class="card-wrap"
|
||||
class:selected={selectedIds.has(card.id)}
|
||||
class:disabled={costLimit !== null && !selectedIds.has(card.id) && selectedCost + card.cost > costLimit}
|
||||
onclick={() => toggleCard(card.id)}
|
||||
>
|
||||
<Card {card} noHover={true} />
|
||||
{#if selectedIds.has(card.id)}
|
||||
<div class="selected-badge">✓</div>
|
||||
{/if}
|
||||
{#if inDeckIds.has(card.id)}
|
||||
<div class="in-deck-badge">⊞</div>
|
||||
{/if}
|
||||
</button>
|
||||
{#if sortBy === 'date_received' && card.received_at}
|
||||
<span class="received-label">Received {new Date(card.received_at).toLocaleDateString('en-GB', { year: 'numeric', month: '2-digit', day: '2-digit' })}</span>
|
||||
{/if}
|
||||
{#if inDeckIds.has(card.id)}
|
||||
<div class="in-deck-badge" title="In a deck">⊞</div>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
<!-- Sentinel inside the scroll container (.grid) for the IntersectionObserver root -->
|
||||
<div bind:this={sentinel} class="scroll-sentinel"></div>
|
||||
{#if loadingMore}<p class="status loading-more">Loading...</p>{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;700&family=Crimson+Text:ital,wght@0,400;0,600;1,400&display=swap');
|
||||
|
||||
.selector {
|
||||
background: #0d0a04;
|
||||
background: var(--color-bg);
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
@@ -211,7 +346,7 @@
|
||||
.toolbar {
|
||||
flex-shrink: 0;
|
||||
padding: 1.5rem 2rem 1rem;
|
||||
border-bottom: 1px solid rgba(107, 76, 30, 0.3);
|
||||
border-bottom: 1px solid var(--color-border-dim);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
@@ -226,23 +361,23 @@
|
||||
|
||||
.search-input {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 15px;
|
||||
background: rgba(255,255,255,0.04);
|
||||
border: 1px solid rgba(107, 76, 30, 0.4);
|
||||
border-radius: 4px;
|
||||
color: #f0d080;
|
||||
font-size: var(--text-md);
|
||||
background: var(--color-surface);
|
||||
border: 1.5px solid var(--color-bronze);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-gold);
|
||||
padding: 5px 10px;
|
||||
outline: none;
|
||||
width: 220px;
|
||||
margin-left: auto;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.search-input:focus { border-color: #c8861a; }
|
||||
.search-input::placeholder { color: rgba(240, 180, 80, 0.3); }
|
||||
.search-input:focus { border-color: var(--color-gold); }
|
||||
.search-input::placeholder { color: var(--color-gold-faint); }
|
||||
|
||||
.toolbar-label {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 11px;
|
||||
font-size: var(--text-sm);
|
||||
color: rgba(240, 180, 80, 0.5);
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
@@ -251,41 +386,47 @@
|
||||
|
||||
.sort-btn {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 11px;
|
||||
font-size: var(--btn-font-sm);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
background: none;
|
||||
border: 1px solid rgba(107, 76, 30, 0.4);
|
||||
border-radius: 4px;
|
||||
color: rgba(240, 180, 80, 0.6);
|
||||
padding: 4px 10px;
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-gold-dim);
|
||||
padding: var(--btn-padding-sm);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.sort-btn:hover { border-color: #c8861a; color: #f0d080; }
|
||||
.sort-btn.active { background: #3d2507; border-color: #c8861a; color: #f0d080; }
|
||||
.sort-arrow { font-size: 10px; margin-left: 3px; }
|
||||
.sort-btn:hover { border-color: var(--color-bronze); color: var(--color-gold); }
|
||||
.sort-btn.active { background: var(--color-surface-raised); border-color: var(--color-bronze); color: var(--color-gold); }
|
||||
.filter-toggle.active { background: var(--color-surface-raised); border-color: var(--color-bronze); color: var(--color-gold); }
|
||||
.sort-arrow { font-size: var(--text-xs); margin-left: 3px; }
|
||||
|
||||
.filter-toggle {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 11px;
|
||||
font-size: var(--btn-font-sm);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
background: none;
|
||||
border: 1px solid rgba(107, 76, 30, 0.4);
|
||||
border-radius: 4px;
|
||||
color: rgba(240, 180, 80, 0.6);
|
||||
padding: 4px 10px;
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-gold-dim);
|
||||
padding: var(--btn-padding-sm);
|
||||
cursor: pointer;
|
||||
margin-left: 0.5rem;
|
||||
position: relative;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.filter-toggle:hover { border-color: #c8861a; color: #f0d080; }
|
||||
.filter-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-left: 0.75rem;
|
||||
}
|
||||
|
||||
.filter-toggle:hover { border-color: var(--color-bronze); color: var(--color-gold); }
|
||||
|
||||
.filter-dot {
|
||||
position: absolute;
|
||||
@@ -293,8 +434,8 @@
|
||||
right: -3px;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: #c8861a;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--color-bronze);
|
||||
}
|
||||
|
||||
.filters {
|
||||
@@ -309,7 +450,7 @@
|
||||
|
||||
.filter-group-label {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 11px;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
@@ -318,7 +459,7 @@
|
||||
|
||||
.select-all {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 12px;
|
||||
font-size: var(--text-sm);
|
||||
font-style: italic;
|
||||
background: none;
|
||||
border: none;
|
||||
@@ -329,7 +470,7 @@
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.select-all:hover { color: #f0d080; }
|
||||
.select-all:hover { color: var(--color-gold); }
|
||||
|
||||
.checkboxes { display: flex; flex-wrap: wrap; gap: 0.4rem 1rem; }
|
||||
|
||||
@@ -338,13 +479,13 @@
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 14px;
|
||||
color: rgba(240, 180, 80, 0.8);
|
||||
font-size: var(--text-base);
|
||||
color: var(--color-gold-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.checkbox-label input {
|
||||
accent-color: #c8861a;
|
||||
accent-color: var(--color-bronze);
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
cursor: pointer;
|
||||
@@ -354,13 +495,13 @@
|
||||
|
||||
.range-label {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 11px;
|
||||
font-size: var(--text-sm);
|
||||
color: rgba(240, 180, 80, 0.7);
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
input[type=range] {
|
||||
accent-color: #c8861a;
|
||||
accent-color: var(--color-bronze);
|
||||
width: 160px;
|
||||
}
|
||||
|
||||
@@ -374,22 +515,46 @@
|
||||
padding: 2rem 2rem 0;
|
||||
}
|
||||
|
||||
.card-item {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.received-label {
|
||||
position: absolute;
|
||||
top: 44px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
background: rgba(13, 10, 4, 0.88);
|
||||
backdrop-filter: blur(6px);
|
||||
border: 1px solid rgba(200, 134, 26, 0.55);
|
||||
border-radius: 20px;
|
||||
padding: 3px 12px;
|
||||
font-size: var(--text-sm);
|
||||
font-family: 'Cinzel', serif;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.07em;
|
||||
color: var(--color-gold);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.card-wrap {
|
||||
all: unset;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
display: block;
|
||||
border-radius: 12px;
|
||||
border-radius: var(--radius-xl);
|
||||
transition: transform 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.card-wrap:hover {
|
||||
transform: translateY(-4px) scale(1.02);
|
||||
box-shadow: 0 12px 40px rgba(0,0,0,0.6);
|
||||
box-shadow: var(--shadow-elevated);
|
||||
}
|
||||
|
||||
.card-wrap.selected {
|
||||
box-shadow: 0 0 0 3px #c8861a, 0 0 20px rgba(200, 134, 26, 0.4);
|
||||
box-shadow: 0 0 0 3px var(--color-bronze), var(--shadow-glow);
|
||||
}
|
||||
|
||||
.card-wrap.disabled {
|
||||
@@ -402,69 +567,104 @@
|
||||
top: 80px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: #c8861a;
|
||||
color: #fff8e0;
|
||||
background: var(--color-bronze);
|
||||
color: var(--color-btn-text);
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 23.875px;
|
||||
font-weight: 1000;
|
||||
font-size: var(--text-xl);
|
||||
font-weight: 900;
|
||||
padding: 4px 10px;
|
||||
border-radius: 23px;
|
||||
border: black 3px solid;
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
z-index: var(--z-card);
|
||||
}
|
||||
|
||||
.in-deck-badge::after {
|
||||
content: 'In deck';
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
right: 0;
|
||||
background: #000;
|
||||
color: #7ecfcf;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
padding: 4px 7px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid rgba(126, 207, 207, 0.5);
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
z-index: var(--z-toast);
|
||||
}
|
||||
|
||||
.in-deck-badge:hover::after {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.in-deck-badge {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 6px;
|
||||
background: rgba(13, 8, 2, 0.75);
|
||||
background: #000;
|
||||
color: #7ecfcf;
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
padding: 3px 5px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgba(126, 207, 207, 0.5);
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 700;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
padding: 0;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid rgba(126, 207, 207, 0.9);
|
||||
box-shadow: 0 0 6px rgba(126, 207, 207, 0.4);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: var(--z-card);
|
||||
}
|
||||
|
||||
.scroll-sentinel { height: 1px; width: 100%; flex-basis: 100%; }
|
||||
|
||||
.status {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 16px;
|
||||
font-size: var(--text-md);
|
||||
font-style: italic;
|
||||
color: rgba(240, 180, 80, 0.5);
|
||||
text-align: center;
|
||||
margin-top: 4rem;
|
||||
}
|
||||
|
||||
.loading-more {
|
||||
flex-basis: 100%;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.top-bar {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1rem 2rem;
|
||||
border-bottom: 1px solid rgba(107, 76, 30, 0.3);
|
||||
background: #0d0a04;
|
||||
border-bottom: 1px solid var(--color-border-dim);
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
.counter {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 13px;
|
||||
color: rgba(240, 180, 80, 0.6);
|
||||
font-size: var(--text-base);
|
||||
color: var(--color-gold-dim);
|
||||
}
|
||||
|
||||
.done-btn {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 11px;
|
||||
font-size: var(--btn-font-md);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
background: #3d2507;
|
||||
border: 1px solid #c8861a;
|
||||
border-radius: 4px;
|
||||
color: #f0d080;
|
||||
padding: 8px 24px;
|
||||
background: var(--color-surface-raised);
|
||||
border: 1px solid var(--color-bronze);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-gold);
|
||||
padding: var(--btn-padding-md);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
@@ -11,11 +11,12 @@
|
||||
<style>
|
||||
.type-badge {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 10px;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
padding: 3px 8px;
|
||||
border-radius: 3px;
|
||||
white-space: nowrap;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: default;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
let isRefreshing = false;
|
||||
/** @type {Promise<string> | null} */
|
||||
let refreshPromise = null;
|
||||
import { PUBLIC_API_URL } from '$env/static/public';
|
||||
export const API_URL = PUBLIC_API_URL;
|
||||
@@ -27,6 +28,10 @@ async function refreshTokens() {
|
||||
return data.access_token;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} url
|
||||
* @param {RequestInit} [options]
|
||||
*/
|
||||
export async function apiFetch(url, options = {}) {
|
||||
const token = localStorage.getItem('token');
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
const cache = {};
|
||||
|
||||
const FILES = {
|
||||
cardFlip: '/sounds/card-flip.mp3',
|
||||
packOpen: '/sounds/pack-open.mp3',
|
||||
packRip: '/sounds/pack-rip.mp3',
|
||||
cardPlay: '/sounds/card-play.mp3',
|
||||
attack: '/sounds/attack.mp3',
|
||||
defend: '/sounds/defend.mp3',
|
||||
cardDestroy: '/sounds/card-destroy.mp3',
|
||||
cardShatter: '/sounds/card-shatter.mp3',
|
||||
win: '/sounds/win.mp3',
|
||||
loss: '/sounds/loss.mp3',
|
||||
buttonClick: '/sounds/button-click.mp3',
|
||||
};
|
||||
|
||||
const VOLUMES = {
|
||||
cardShatter: 0.1,
|
||||
attack: 0.1,
|
||||
defend: 0.1,
|
||||
};
|
||||
|
||||
const DEFAULT_VOLUME = 0.3;
|
||||
|
||||
export function play(name) {
|
||||
if (!FILES[name]) return;
|
||||
if (!cache[name]) cache[name] = new Audio(FILES[name]);
|
||||
// cloneNode allows the same sound to overlap itself
|
||||
const audio = cache[name].cloneNode();
|
||||
audio.volume = VOLUMES[name] ?? DEFAULT_VOLUME;
|
||||
audio.play().catch(() => {});
|
||||
}
|
||||
+803
-35
@@ -1,21 +1,224 @@
|
||||
<script>
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { apiFetch, WS_URL, API_URL } from '$lib/api.js';
|
||||
|
||||
let menuOpen = $state(false);
|
||||
let socialOpen = $state(false);
|
||||
let shardsOpen = $state(false);
|
||||
let notifOpen = $state(false);
|
||||
|
||||
let notifications = $state([]);
|
||||
let notifErrors = $state({});
|
||||
let notifWs = null;
|
||||
let reconnectTimer = null;
|
||||
let notifReconnectDelay = 1000;
|
||||
let notifReconnecting = $state(false);
|
||||
|
||||
let unreadCount = $derived(notifications.filter(n => !n.read).length);
|
||||
|
||||
const links = [
|
||||
{ href: '/', label: 'Booster Packs' },
|
||||
{ href: '/cards', label: 'Cards' },
|
||||
{ href: '/decks', label: 'Decks' },
|
||||
{ href: '/play', label: 'Play' },
|
||||
{ href: '/trade', label: 'Trade' },
|
||||
{ href: '/store', label: 'Store' },
|
||||
{ href: '/', label: 'Booster Packs' },
|
||||
{ href: '/cards', label: 'Cards' },
|
||||
{ href: '/decks', label: 'Decks' },
|
||||
{ href: '/play', label: 'Play' },
|
||||
];
|
||||
|
||||
const socialLinks = [
|
||||
{ href: '/trade', label: 'Trade' },
|
||||
{ href: '/users', label: 'Users' },
|
||||
];
|
||||
|
||||
const shardsLinks = [
|
||||
{ href: '/store', label: 'Store' },
|
||||
{ href: '/shatter', label: 'Shatter' },
|
||||
];
|
||||
|
||||
function close() { menuOpen = false; }
|
||||
|
||||
function closeDropdowns() {
|
||||
socialOpen = false;
|
||||
shardsOpen = false;
|
||||
notifOpen = false;
|
||||
}
|
||||
|
||||
function handleWindowClick(e) {
|
||||
if (!e.target.closest('.dropdown')) {
|
||||
closeDropdowns();
|
||||
}
|
||||
}
|
||||
|
||||
function connectNotificationWS() {
|
||||
const token = typeof localStorage !== 'undefined' ? localStorage.getItem('token') : null;
|
||||
if (!token) return;
|
||||
|
||||
notifWs = new WebSocket(`${WS_URL}/ws/notifications`);
|
||||
|
||||
notifWs.onopen = () => {
|
||||
notifWs.send(token);
|
||||
notifReconnecting = false;
|
||||
notifReconnectDelay = 1000;
|
||||
};
|
||||
|
||||
notifWs.onmessage = (e) => {
|
||||
const msg = JSON.parse(e.data);
|
||||
if (msg.type === 'flush') {
|
||||
notifications = msg.notifications;
|
||||
} else if (msg.type === 'push') {
|
||||
notifications = [...notifications, msg.notification];
|
||||
} else if (msg.type === 'delete') {
|
||||
notifications = notifications.filter(n => n.id !== msg.notification_id);
|
||||
}
|
||||
};
|
||||
|
||||
notifWs.onclose = () => {
|
||||
notifReconnecting = true;
|
||||
reconnectTimer = setTimeout(() => {
|
||||
notifReconnectDelay = Math.min(notifReconnectDelay * 2, 30000);
|
||||
connectNotificationWS();
|
||||
}, notifReconnectDelay);
|
||||
};
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
connectNotificationWS();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
clearTimeout(reconnectTimer);
|
||||
notifWs?.close();
|
||||
});
|
||||
|
||||
async function markAllRead() {
|
||||
const unread = notifications.filter(n => !n.read);
|
||||
await Promise.all(
|
||||
unread.map(n => apiFetch(`${API_URL}/notifications/${n.id}/read`, { method: 'POST' }))
|
||||
);
|
||||
notifications = notifications.map(n => ({ ...n, read: true }));
|
||||
}
|
||||
|
||||
async function deleteNotification(id) {
|
||||
await apiFetch(`${API_URL}/notifications/${id}`, { method: 'DELETE' });
|
||||
notifications = notifications.filter(n => n.id !== id);
|
||||
}
|
||||
|
||||
async function markRead(notif) {
|
||||
if (notif.read) return;
|
||||
await apiFetch(`${API_URL}/notifications/${notif.id}/read`, { method: 'POST' });
|
||||
notifications = notifications.map(n => n.id === notif.id ? { ...n, read: true } : n);
|
||||
}
|
||||
|
||||
// Countdown state: map of notif.id → remaining seconds
|
||||
let countdowns = $state({});
|
||||
let countdownInterval = null;
|
||||
|
||||
function startCountdowns() {
|
||||
if (countdownInterval) clearInterval(countdownInterval);
|
||||
countdownInterval = setInterval(() => {
|
||||
const now = Date.now();
|
||||
const updated = {};
|
||||
for (const notif of notifications) {
|
||||
if (notif.type === 'game_challenge' && notif.expires_at && !notif.payload.status) {
|
||||
const secs = Math.max(0, Math.floor((new Date(notif.expires_at).getTime() - now) / 1000));
|
||||
updated[notif.id] = secs;
|
||||
}
|
||||
}
|
||||
countdowns = updated;
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const hasChallenges = notifications.some(n => n.type === 'game_challenge' && !n.payload.status);
|
||||
if (hasChallenges) startCountdowns();
|
||||
else if (countdownInterval) { clearInterval(countdownInterval); countdownInterval = null; }
|
||||
});
|
||||
|
||||
// Load decks when the notification panel opens and there are incoming challenges
|
||||
$effect(() => {
|
||||
if (notifOpen && notifications.some(n => n.type === 'game_challenge' && !n.payload.status)) {
|
||||
loadChallengeDecks();
|
||||
}
|
||||
});
|
||||
|
||||
// Per-challenge deck selection state
|
||||
let challengeDeckSelections = $state({}); // notif.id → deck_id
|
||||
let challengeDecks = $state([]);
|
||||
let challengeDecksLoaded = $state(false);
|
||||
|
||||
async function loadChallengeDecks() {
|
||||
if (challengeDecksLoaded) return;
|
||||
const res = await apiFetch(`${API_URL}/decks`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
challengeDecks = data.filter(d => !d.deleted);
|
||||
if (challengeDecks.length) {
|
||||
// Set default deck for all pending incoming challenges
|
||||
const updated = { ...challengeDeckSelections };
|
||||
for (const n of notifications) {
|
||||
if (n.type === 'game_challenge' && !n.payload.status && !updated[n.id]) {
|
||||
updated[n.id] = challengeDecks[0].id;
|
||||
}
|
||||
}
|
||||
challengeDeckSelections = updated;
|
||||
}
|
||||
challengeDecksLoaded = true;
|
||||
}
|
||||
}
|
||||
|
||||
async function acceptChallenge(notif) {
|
||||
const deckId = challengeDeckSelections[notif.id];
|
||||
if (!deckId) return;
|
||||
const res = await apiFetch(`${API_URL}/challenges/${notif.payload.challenge_id}/accept`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ deck_id: deckId }),
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
closeDropdowns();
|
||||
goto(`/play?game_id=${data.game_id}`);
|
||||
} else {
|
||||
notifErrors = { ...notifErrors, [notif.id]: 'Failed to accept challenge.' };
|
||||
}
|
||||
}
|
||||
|
||||
async function declineChallenge(notif) {
|
||||
const res = await apiFetch(`${API_URL}/challenges/${notif.payload.challenge_id}/decline`, { method: 'POST' });
|
||||
if (!res.ok) { notifErrors = { ...notifErrors, [notif.id]: 'Failed to decline.' }; return; }
|
||||
notifications = notifications.filter(n => n.id !== notif.id);
|
||||
}
|
||||
|
||||
async function acceptFriendRequest(notif) {
|
||||
const res = await apiFetch(`${API_URL}/friendships/${notif.payload.friendship_id}/accept`, { method: 'POST' });
|
||||
if (!res.ok) { notifErrors = { ...notifErrors, [notif.id]: 'Failed to accept.' }; return; }
|
||||
await apiFetch(`${API_URL}/notifications/${notif.id}/read`, { method: 'POST' });
|
||||
notifications = notifications.filter(n => n.id !== notif.id);
|
||||
}
|
||||
|
||||
async function declineFriendRequest(notif) {
|
||||
const res = await apiFetch(`${API_URL}/friendships/${notif.payload.friendship_id}/decline`, { method: 'POST' });
|
||||
if (!res.ok) { notifErrors = { ...notifErrors, [notif.id]: 'Failed to decline.' }; return; }
|
||||
notifications = notifications.filter(n => n.id !== notif.id);
|
||||
}
|
||||
|
||||
function relativeTime(isoString) {
|
||||
const diff = Date.now() - new Date(isoString).getTime();
|
||||
const mins = Math.floor(diff / 60000);
|
||||
if (mins < 1) return 'just now';
|
||||
if (mins < 60) return `${mins}m ago`;
|
||||
const hrs = Math.floor(mins / 60);
|
||||
if (hrs < 24) return `${hrs}h ago`;
|
||||
return `${Math.floor(hrs / 24)}d ago`;
|
||||
}
|
||||
|
||||
function typeLabel(type) {
|
||||
return { friend_request: 'Friend Request', trade_offer: 'Trade Offer', trade_response: 'Trade Response', game_challenge: 'Game Challenge' }[type] ?? type;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onclick={handleWindowClick} />
|
||||
|
||||
<header>
|
||||
<a href="/" class="logo" onclick={close}>WikiTCG</a>
|
||||
|
||||
@@ -23,7 +226,168 @@
|
||||
{#each links as link}
|
||||
<a href={link.href} class:active={$page.url.pathname === link.href}>{link.label}</a>
|
||||
{/each}
|
||||
<a href="/profile" class:active={$page.url.pathname === '/profile'}>Profile</a>
|
||||
|
||||
<!-- Social dropdown: Trade + Users -->
|
||||
<div class="dropdown">
|
||||
<button
|
||||
class="dropdown-trigger"
|
||||
class:active={$page.url.pathname.startsWith('/trade') || $page.url.pathname.startsWith('/users')}
|
||||
class:open={socialOpen}
|
||||
onclick={(e) => { e.stopPropagation(); shardsOpen = false; notifOpen = false; socialOpen = !socialOpen; }}
|
||||
>
|
||||
Social <span class="chevron" class:open={socialOpen}>▾</span>
|
||||
</button>
|
||||
{#if socialOpen}
|
||||
<div class="dropdown-menu">
|
||||
{#each socialLinks as link}
|
||||
<a href={link.href} onclick={closeDropdowns}>{link.label}</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Shards dropdown: Store + Shatter -->
|
||||
<div class="dropdown">
|
||||
<button
|
||||
class="dropdown-trigger"
|
||||
class:active={$page.url.pathname === '/store' || $page.url.pathname === '/shatter'}
|
||||
class:open={shardsOpen}
|
||||
onclick={(e) => { e.stopPropagation(); socialOpen = false; notifOpen = false; shardsOpen = !shardsOpen; }}
|
||||
>
|
||||
Shards <span class="chevron" class:open={shardsOpen}>▾</span>
|
||||
</button>
|
||||
{#if shardsOpen}
|
||||
<div class="dropdown-menu">
|
||||
{#each shardsLinks as link}
|
||||
<a href={link.href} onclick={closeDropdowns}>{link.label}</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Profile icon -->
|
||||
<a href="/profile" class="profile-icon" class:active={$page.url.pathname === '/profile'} aria-label="Profile">
|
||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="12" cy="8" r="4" fill="currentColor"/>
|
||||
<path d="M4 20c0-4 3.6-7 8-7s8 3 8 7" fill="currentColor"/>
|
||||
</svg>
|
||||
</a>
|
||||
|
||||
<!-- Notification bell -->
|
||||
<div class="dropdown">
|
||||
<button
|
||||
class="bell-btn"
|
||||
class:active={notifOpen}
|
||||
aria-label="Notifications"
|
||||
onclick={(e) => { e.stopPropagation(); socialOpen = false; shardsOpen = false; notifOpen = !notifOpen; }}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" class="bell-icon">
|
||||
<path d="M12 2a7 7 0 0 0-7 7v4l-2 3h18l-2-3V9a7 7 0 0 0-7-7z" fill="currentColor"/>
|
||||
<path d="M10 19a2 2 0 0 0 4 0" fill="currentColor"/>
|
||||
</svg>
|
||||
{#if unreadCount > 0}
|
||||
<span class="badge">{unreadCount > 9 ? '9+' : unreadCount}</span>
|
||||
{/if}
|
||||
{#if notifReconnecting}
|
||||
<span class="reconnecting-dot" title="Reconnecting..."></span>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
{#if notifOpen}
|
||||
<div class="notif-panel">
|
||||
<div class="notif-header">
|
||||
<span class="notif-title">Notifications</span>
|
||||
{#if unreadCount > 0}
|
||||
<button class="mark-all-btn" onclick={markAllRead}>Mark all read</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if notifications.length === 0}
|
||||
<div class="notif-empty">No notifications</div>
|
||||
{:else}
|
||||
<ul class="notif-list">
|
||||
{#each notifications as notif (notif.id)}
|
||||
<li class="notif-item" class:unread={!notif.read}>
|
||||
<div class="notif-top">
|
||||
<span class="notif-type">{typeLabel(notif.type)}</span>
|
||||
<span class="notif-time">{relativeTime(notif.created_at)}</span>
|
||||
<button class="dismiss-btn" onclick={() => deleteNotification(notif.id)} aria-label="Dismiss">✕</button>
|
||||
</div>
|
||||
|
||||
{#if notif.type === 'friend_request'}
|
||||
<p class="notif-body"><strong>{notif.payload.from_username ?? 'Someone'}</strong> sent you a friend request.</p>
|
||||
<div class="notif-actions">
|
||||
<button class="action-btn accept" onclick={() => acceptFriendRequest(notif)}>Accept</button>
|
||||
<button class="action-btn decline" onclick={() => declineFriendRequest(notif)}>Decline</button>
|
||||
</div>
|
||||
|
||||
{:else if notif.type === 'trade_offer'}
|
||||
<p class="notif-body">
|
||||
<strong>{notif.payload.from_username ?? 'Someone'}</strong> sent you a trade offer
|
||||
({notif.payload.offered_count ?? 0} offered, {notif.payload.requested_count ?? 0} requested).
|
||||
</p>
|
||||
<div class="notif-actions">
|
||||
<button class="action-btn accept" onclick={() => { markRead(notif); closeDropdowns(); goto('/profile'); }}>View Proposals</button>
|
||||
</div>
|
||||
|
||||
{:else if notif.type === 'trade_response'}
|
||||
<p class="notif-body">
|
||||
<strong>{notif.payload.from_username ?? 'Someone'}</strong>
|
||||
{notif.payload.status === 'accepted' ? 'accepted' : 'declined'} your trade offer.
|
||||
</p>
|
||||
<div class="notif-actions">
|
||||
<a href="/trade/proposal/{notif.payload.proposal_id}" class="action-btn accept" onclick={() => { markRead(notif); closeDropdowns(); }}>View Trade</a>
|
||||
</div>
|
||||
|
||||
{:else if notif.type === 'game_challenge'}
|
||||
{#if notif.payload.status === 'accepted'}
|
||||
<!-- Response notification sent to challenger -->
|
||||
<p class="notif-body"><strong>{notif.payload.from_username ?? 'Someone'}</strong> accepted your challenge!</p>
|
||||
<div class="notif-actions">
|
||||
<a href="/play?game_id={notif.payload.game_id}" class="action-btn accept" onclick={() => { markRead(notif); closeDropdowns(); }}>Join Game</a>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Incoming challenge for the challenged player -->
|
||||
{@const secs = countdowns[notif.id] ?? Math.max(0, Math.floor((new Date(notif.expires_at ?? 0).getTime() - Date.now()) / 1000))}
|
||||
{@const expired = secs <= 0}
|
||||
<p class="notif-body">
|
||||
<strong>{notif.payload.from_username ?? 'Someone'}</strong> challenged you with <em>{notif.payload.deck_name ?? 'a deck'}</em>.
|
||||
</p>
|
||||
{#if expired}
|
||||
<span class="challenge-expired">Expired</span>
|
||||
{:else}
|
||||
<span class="challenge-countdown" class:urgent={secs <= 60}>{Math.floor(secs / 60)}:{String(secs % 60).padStart(2, '0')} remaining</span>
|
||||
<div class="notif-actions">
|
||||
{#if challengeDecks.length > 0}
|
||||
<select
|
||||
class="challenge-deck-select"
|
||||
value={challengeDeckSelections[notif.id] ?? ''}
|
||||
onchange={(e) => challengeDeckSelections = { ...challengeDeckSelections, [notif.id]: e.target.value }}
|
||||
>
|
||||
{#each challengeDecks as deck}
|
||||
<option value={deck.id}>{deck.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{/if}
|
||||
<button class="action-btn accept" onclick={() => acceptChallenge(notif)} disabled={!challengeDeckSelections[notif.id]}>Accept</button>
|
||||
<button class="action-btn decline" onclick={() => declineChallenge(notif)}>Decline</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{:else}
|
||||
<p class="notif-body">{notif.payload.message ?? ''}</p>
|
||||
{/if}
|
||||
{#if notifErrors[notif.id]}
|
||||
<p class="notif-error">{notifErrors[notif.id]}</p>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<button class="hamburger" onclick={() => menuOpen = !menuOpen} aria-label="Toggle menu">
|
||||
@@ -39,56 +403,460 @@
|
||||
{#each links as link}
|
||||
<a href={link.href} class:active={$page.url.pathname === link.href} onclick={close}>{link.label}</a>
|
||||
{/each}
|
||||
{#each socialLinks as link}
|
||||
<a href={link.href} class:active={$page.url.pathname === link.href} onclick={close}>{link.label}</a>
|
||||
{/each}
|
||||
{#each shardsLinks as link}
|
||||
<a href={link.href} class:active={$page.url.pathname === link.href} onclick={close}>{link.label}</a>
|
||||
{/each}
|
||||
<a href="/profile" class:active={$page.url.pathname === '/profile'} onclick={close}>
|
||||
Profile{unreadCount > 0 ? ` (${unreadCount})` : ''}
|
||||
</a>
|
||||
</nav>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;700&family=Crimson+Text:ital,wght@0,400;0,600;1,400&display=swap');
|
||||
|
||||
header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
z-index: var(--z-header);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 2rem;
|
||||
padding: 0 var(--space-xl);
|
||||
height: 56px;
|
||||
background: #1a1008;
|
||||
border-bottom: 2px solid #6b4c1e;
|
||||
background: var(--color-surface);
|
||||
border-bottom: 2px solid var(--color-border);
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 18px;
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 700;
|
||||
color: #f0d080;
|
||||
color: var(--color-gold);
|
||||
text-decoration: none;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
nav.desktop {
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
align-items: center;
|
||||
gap: var(--space-lg);
|
||||
}
|
||||
|
||||
nav.desktop a {
|
||||
nav.desktop > a {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 13px;
|
||||
font-size: var(--text-base);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(240, 180, 80, 0.8);
|
||||
color: var(--color-gold-muted);
|
||||
text-decoration: none;
|
||||
transition: color 0.15s;
|
||||
padding: 4px 0;
|
||||
border-bottom: 1.5px solid transparent;
|
||||
}
|
||||
|
||||
nav.desktop a:hover,
|
||||
nav.desktop a.active {
|
||||
color: #f0d080;
|
||||
border-bottom-color: #f0d080;
|
||||
nav.desktop > a:hover,
|
||||
nav.desktop > a.active {
|
||||
color: var(--color-gold);
|
||||
border-bottom-color: var(--color-gold);
|
||||
}
|
||||
|
||||
/* Dropdown container */
|
||||
.dropdown {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.dropdown-trigger {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: var(--text-base);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-gold-muted);
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 1.5px solid transparent;
|
||||
cursor: pointer;
|
||||
padding: 4px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
transition: color 0.15s;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.dropdown-trigger:hover,
|
||||
.dropdown-trigger.active {
|
||||
color: var(--color-gold);
|
||||
border-bottom-color: var(--color-gold);
|
||||
}
|
||||
|
||||
.chevron {
|
||||
font-size: var(--text-xs);
|
||||
line-height: 1;
|
||||
transition: transform 0.15s;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.chevron.open {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
left: 0;
|
||||
background: var(--color-surface);
|
||||
border: 2px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 0.4rem 0;
|
||||
min-width: 130px;
|
||||
z-index: var(--z-dropdown);
|
||||
}
|
||||
|
||||
.dropdown-menu a {
|
||||
display: block;
|
||||
padding: 0.55rem 1rem;
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-gold-muted);
|
||||
text-decoration: none;
|
||||
transition: color 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
.dropdown-menu a:hover {
|
||||
color: var(--color-gold);
|
||||
background: var(--color-border-dim);
|
||||
}
|
||||
|
||||
/* Profile icon link */
|
||||
.profile-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--color-gold-muted);
|
||||
text-decoration: none;
|
||||
padding: 4px 0;
|
||||
border-bottom: 1.5px solid transparent;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.profile-icon:hover,
|
||||
.profile-icon.active {
|
||||
color: var(--color-gold);
|
||||
border-bottom-color: var(--color-gold);
|
||||
}
|
||||
|
||||
.profile-icon svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
/* Bell button */
|
||||
.bell-btn {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 1.5px solid transparent;
|
||||
cursor: pointer;
|
||||
padding: 4px 0;
|
||||
color: var(--color-gold-muted);
|
||||
transition: color 0.15s;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.bell-btn:hover,
|
||||
.bell-btn.active {
|
||||
color: var(--color-gold);
|
||||
border-bottom-color: var(--color-gold);
|
||||
}
|
||||
|
||||
.bell-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
right: -6px;
|
||||
background: var(--color-bronze);
|
||||
color: var(--color-btn-text);
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
min-width: 16px;
|
||||
height: 16px;
|
||||
border-radius: var(--radius-full);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 3px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Notification panel */
|
||||
.notif-panel {
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
right: 0;
|
||||
width: min(320px, 90vw);
|
||||
background: var(--color-surface);
|
||||
border: 2px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
z-index: var(--z-dropdown);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.notif-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.6rem 1rem;
|
||||
border-bottom: 1px solid var(--color-border-subtle);
|
||||
}
|
||||
|
||||
.notif-title {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-gold-dim);
|
||||
}
|
||||
|
||||
.mark-all-btn {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-bronze);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
transition: color 0.15s;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.mark-all-btn:hover {
|
||||
color: var(--color-gold);
|
||||
}
|
||||
|
||||
.notif-empty {
|
||||
padding: 1.5rem 1rem;
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-gold-faint);
|
||||
text-align: center;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.notif-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
max-height: 380px;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--color-border) transparent;
|
||||
}
|
||||
|
||||
.notif-list::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
.notif-list::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
.notif-list::-webkit-scrollbar-thumb {
|
||||
background: var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.notif-item {
|
||||
padding: 0.7rem 1rem;
|
||||
border-bottom: 1px solid var(--color-border-dim);
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.notif-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.notif-item.unread {
|
||||
background: var(--color-border-dim);
|
||||
}
|
||||
|
||||
.notif-item:hover {
|
||||
background: var(--color-border-dim);
|
||||
}
|
||||
|
||||
.notif-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.notif-type {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-bronze);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.notif-time {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-gold-faint);
|
||||
font-family: 'Crimson Text', serif;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dismiss-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--color-gold-faint);
|
||||
font-size: var(--text-sm);
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
transition: color 0.15s;
|
||||
width: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dismiss-btn:hover {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.notif-body {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: var(--text-base);
|
||||
color: var(--color-gold-muted);
|
||||
margin: 0 0 0.5rem 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.notif-body strong {
|
||||
color: var(--color-gold);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.notif-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.notif-error {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-error);
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: var(--btn-font-sm);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
padding: var(--btn-padding-sm);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
transition: background 0.15s, color 0.15s;
|
||||
text-decoration: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.action-btn.accept {
|
||||
background: var(--color-surface-raised);
|
||||
color: var(--color-gold);
|
||||
}
|
||||
|
||||
.action-btn.accept:hover {
|
||||
background: var(--color-bronze);
|
||||
color: var(--color-btn-text);
|
||||
border-color: var(--color-bronze);
|
||||
}
|
||||
|
||||
.action-btn.decline {
|
||||
background: transparent;
|
||||
color: var(--color-gold-dim);
|
||||
}
|
||||
|
||||
.action-btn.decline:hover {
|
||||
background: rgba(180, 40, 40, 0.3);
|
||||
color: var(--color-gold);
|
||||
border-color: rgba(180, 40, 40, 0.5);
|
||||
}
|
||||
|
||||
.challenge-countdown {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--color-gold-dim);
|
||||
margin-bottom: 0.4rem;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.challenge-countdown.urgent { color: var(--color-error); }
|
||||
|
||||
.challenge-expired {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
color: rgba(180, 80, 80, 0.5);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.challenge-deck-select {
|
||||
background: var(--color-bg);
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-gold);
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: var(--text-base);
|
||||
padding: 4px 8px;
|
||||
cursor: pointer;
|
||||
flex-shrink: 1;
|
||||
min-width: 0;
|
||||
max-width: 140px;
|
||||
}
|
||||
|
||||
.challenge-deck-select:focus { outline: none; border-color: var(--color-bronze); }
|
||||
|
||||
.reconnecting-dot {
|
||||
position: absolute;
|
||||
bottom: 1px;
|
||||
right: -3px;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background: var(--color-bronze);
|
||||
border-radius: var(--radius-full);
|
||||
pointer-events: none;
|
||||
animation: pulse-dot 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse-dot {
|
||||
0%, 100% { opacity: 0.4; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
|
||||
.hamburger {
|
||||
@@ -106,7 +874,7 @@
|
||||
display: block;
|
||||
width: 22px;
|
||||
height: 2px;
|
||||
background: #f0d080;
|
||||
background: var(--color-gold);
|
||||
border-radius: 2px;
|
||||
transition: transform 0.2s, opacity 0.2s;
|
||||
}
|
||||
@@ -118,8 +886,8 @@
|
||||
.mobile-backdrop {
|
||||
position: fixed;
|
||||
inset: 56px 0 0 0;
|
||||
z-index: 99;
|
||||
background: rgba(0,0,0,0.5);
|
||||
z-index: var(--z-header);
|
||||
background: var(--color-overlay);
|
||||
}
|
||||
|
||||
nav.mobile {
|
||||
@@ -128,34 +896,34 @@
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 240px;
|
||||
z-index: 100;
|
||||
background: #1a1008;
|
||||
border-left: 2px solid #6b4c1e;
|
||||
z-index: var(--z-header);
|
||||
background: var(--color-surface);
|
||||
border-left: 2px solid var(--color-border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 1.5rem;
|
||||
padding: var(--space-lg);
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
nav.mobile a {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 13px;
|
||||
font-size: var(--text-base);
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(240, 180, 80, 0.6);
|
||||
color: var(--color-gold-dim);
|
||||
text-decoration: none;
|
||||
padding: 0.75rem 0;
|
||||
border-bottom: 1px solid rgba(107, 76, 30, 0.3);
|
||||
border-bottom: 1px solid var(--color-border-dim);
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
nav.mobile a:hover,
|
||||
nav.mobile a.active {
|
||||
color: #f0d080;
|
||||
color: var(--color-gold);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
nav.desktop { display: none; }
|
||||
.hamburger { display: flex; }
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
<script>
|
||||
import { page } from '$app/stores';
|
||||
|
||||
$: is404 = $page.status === 404;
|
||||
$: title = is404 ? 'Page Not Found' : 'Something Went Wrong';
|
||||
$: message = is404 ? null : ($page.error?.message || 'An unexpected error occurred. Please try again.');
|
||||
</script>
|
||||
|
||||
<div class="error-page">
|
||||
<div class="card-ghost" aria-hidden="true"></div>
|
||||
|
||||
<div class="content">
|
||||
<div class="rune-divider" aria-hidden="true">
|
||||
<span class="rune">⬡</span>
|
||||
<span class="line"></span>
|
||||
<span class="rune">⬡</span>
|
||||
</div>
|
||||
|
||||
<p class="status-code">{$page.status}</p>
|
||||
<h1 class="title">{title}</h1>
|
||||
{#if message}<p class="message">{message}</p>{/if}
|
||||
|
||||
<a href="/" class="btn-home">Return Home</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.error-page {
|
||||
min-height: calc(100vh - 56px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
padding: 40px 24px;
|
||||
}
|
||||
|
||||
/* Ghost card decorative background element */
|
||||
.card-ghost {
|
||||
position: absolute;
|
||||
right: 12%;
|
||||
top: 50%;
|
||||
transform: translateY(-50%) rotate(8deg);
|
||||
width: 180px;
|
||||
height: 260px;
|
||||
border: 2px solid #f0d080;
|
||||
border-radius: 10px;
|
||||
opacity: 0.04;
|
||||
pointer-events: none;
|
||||
animation: ghost-float 6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes ghost-float {
|
||||
0%, 100% { transform: translateY(-50%) rotate(8deg); }
|
||||
50% { transform: translateY(calc(-50% - 12px)) rotate(8deg); }
|
||||
}
|
||||
|
||||
/* Second ghost card, mirror left */
|
||||
.card-ghost::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: calc(-100% - 60vw + 180px);
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 2px solid #f0d080;
|
||||
border-radius: 10px;
|
||||
transform: rotate(-12deg);
|
||||
}
|
||||
|
||||
.content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
max-width: 480px;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.rune-divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 32px;
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.rune {
|
||||
font-size: var(--text-xs);
|
||||
color: rgba(240, 180, 80, 0.3);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.line {
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: rgba(107, 76, 30, 0.5);
|
||||
}
|
||||
|
||||
.status-code {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: clamp(6rem, 18vw, 9rem);
|
||||
font-weight: 900;
|
||||
color: #f0d080;
|
||||
line-height: 0.9;
|
||||
margin: 0 0 12px;
|
||||
letter-spacing: -0.02em;
|
||||
/* Faint inner glow */
|
||||
text-shadow:
|
||||
0 0 60px rgba(200, 134, 26, 0.2),
|
||||
0 0 120px rgba(200, 134, 26, 0.1);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: clamp(1rem, 3.5vw, 1.4rem);
|
||||
font-weight: 700;
|
||||
color: rgba(240, 180, 80, 0.7);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
margin: 0 0 28px;
|
||||
}
|
||||
|
||||
.message {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: var(--text-lg);
|
||||
font-style: italic;
|
||||
color: rgba(240, 180, 80, 0.5);
|
||||
line-height: 1.6;
|
||||
margin: 0 0 40px;
|
||||
}
|
||||
|
||||
.btn-home {
|
||||
display: inline-block;
|
||||
background: #c8861a;
|
||||
color: #fff8e0;
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: var(--btn-font-lg);
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
text-decoration: none;
|
||||
padding: var(--btn-padding-lg);
|
||||
border-radius: var(--radius-md);
|
||||
border: none;
|
||||
transition: background 0.15s ease, transform 0.15s ease, box-shadow 0.15s ease;
|
||||
box-shadow: 0 2px 12px rgba(200, 134, 26, 0.3);
|
||||
}
|
||||
|
||||
.btn-home:hover {
|
||||
background: #e09820;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 20px rgba(200, 134, 26, 0.45);
|
||||
}
|
||||
|
||||
.btn-home:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
</style>
|
||||
@@ -1,17 +1,20 @@
|
||||
<script lang="ts">
|
||||
import '../app.css';
|
||||
import Header from "$lib/header.svelte";
|
||||
import favicon from '$lib/assets/favicon.svg';
|
||||
import { page } from '$app/state';
|
||||
import '../app.css';
|
||||
import Header from "$lib/header.svelte";
|
||||
import favicon from '$lib/assets/favicon.svg';
|
||||
import { page } from '$app/state';
|
||||
|
||||
let { children } = $props();
|
||||
let { children } = $props();
|
||||
|
||||
const showHeader = $derived(!['auth', 'forgot-password'].some(p => page.url.pathname.startsWith(`/${p}`)));
|
||||
const showHeader = $derived(
|
||||
!['auth', 'forgot-password'].some(p => page.url.pathname.startsWith(`/${p}`)) &&
|
||||
!/^\/decks\/.+/.test(page.url.pathname)
|
||||
);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>WikiTCG</title>
|
||||
<link rel="icon" href={favicon} />
|
||||
<title>WikiTCG</title>
|
||||
<link rel="icon" href={favicon} />
|
||||
</svelte:head>
|
||||
|
||||
<div class="layout">
|
||||
|
||||
@@ -1,27 +1,28 @@
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import { API_URL, WS_URL } from '$lib/api.js';
|
||||
import { apiFetch } from '$lib/api.js';
|
||||
import { goto } from '$app/navigation';
|
||||
import { onMount } from 'svelte';
|
||||
import Card from '$lib/Card.svelte';
|
||||
import { play } from '$lib/audio.js';
|
||||
|
||||
let cards = $state([]);
|
||||
let cards: any[] = $state([]);
|
||||
let loading = $state(false);
|
||||
let boosters = $state(null);
|
||||
let countdown = $state(null);
|
||||
let boosters: number | null = $state(null);
|
||||
let countdown: Date | null = $state(null);
|
||||
let emailVerified = $state(true);
|
||||
let countdownDisplay = $state('');
|
||||
let countdownInterval = null;
|
||||
let countdownInterval: number | undefined = undefined;
|
||||
|
||||
let phase = $state('idle');
|
||||
let flippedCards = $state([]);
|
||||
let flippedCards: boolean[] = $state([]);
|
||||
let fanVisible = $state(false);
|
||||
let packRef = $state(null);
|
||||
let overlayPackRef = $state(null);
|
||||
let packRef: HTMLDivElement | null = $state(null);
|
||||
let overlayPackRef: HTMLElement | null = $state(null);
|
||||
|
||||
onMount(async () => {
|
||||
onMount(() => {
|
||||
if (!localStorage.getItem('token')) { goto('/auth'); return; }
|
||||
await fetchBoosters();
|
||||
fetchBoosters();
|
||||
return () => clearInterval(countdownInterval);
|
||||
});
|
||||
|
||||
@@ -37,10 +38,11 @@
|
||||
|
||||
function startCountdown() {
|
||||
clearInterval(countdownInterval);
|
||||
if (!countdown || boosters >= 5) return;
|
||||
const cd = countdown;
|
||||
if (!cd || boosters === null || boosters >= 5) return;
|
||||
countdownInterval = setInterval(() => {
|
||||
const nextTick = new Date(countdown.getTime() + 5 * 60 * 60 * 1000);
|
||||
const diff = nextTick - Date.now();
|
||||
const nextTick = new Date(cd.getTime() + 5 * 60 * 60 * 1000);
|
||||
const diff = nextTick.getTime() - Date.now();
|
||||
if (diff <= 0) { clearInterval(countdownInterval); fetchBoosters(); return; }
|
||||
const h = Math.floor(diff / 3600000);
|
||||
const m = Math.floor((diff % 3600000) / 60000);
|
||||
@@ -49,7 +51,7 @@
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function delay(ms) { return new Promise(r => setTimeout(r, ms)); }
|
||||
function delay(ms: number) { return new Promise(r => setTimeout(r, ms)); }
|
||||
|
||||
// Get the screen position of the idle pack so the overlay pack starts there
|
||||
function getPackRect() {
|
||||
@@ -71,6 +73,7 @@
|
||||
await delay(600);
|
||||
|
||||
phase = 'ripping';
|
||||
play('packRip');
|
||||
await delay(900);
|
||||
|
||||
phase = 'dropping';
|
||||
@@ -86,12 +89,14 @@
|
||||
if (!res.ok) { phase = 'idle'; loading = false; return; }
|
||||
cards = await res.json();
|
||||
flippedCards = new Array(cards.length).fill(false);
|
||||
boosters -= 1;
|
||||
if (boosters < 5 && !countdown) { countdown = new Date(); startCountdown(); }
|
||||
cardActions = cards.map(() => ({ favorited: false, tradeListed: false, shattered: false, shardGain: 0 }));
|
||||
if (boosters !== null) boosters -= 1;
|
||||
if (boosters !== null && boosters < 5 && !countdown) { countdown = new Date(); startCountdown(); }
|
||||
|
||||
phase = 'fanning';
|
||||
await delay(50);
|
||||
fanVisible = true;
|
||||
play('packOpen');
|
||||
await delay(800);
|
||||
|
||||
phase = 'flipping';
|
||||
@@ -102,6 +107,7 @@
|
||||
|
||||
for (let i of indices) {
|
||||
await delay(350);
|
||||
play('cardFlip');
|
||||
flippedCards = flippedCards.map((v, idx) => idx === i ? true : v);
|
||||
}
|
||||
|
||||
@@ -113,13 +119,51 @@
|
||||
phase = 'idle';
|
||||
cards = [];
|
||||
flippedCards = [];
|
||||
cardActions = [];
|
||||
fanVisible = false;
|
||||
}
|
||||
|
||||
const FOIL_RARITIES = new Set(['super_rare', 'epic', 'legendary']);
|
||||
|
||||
// Per-card action states for the pack reveal
|
||||
let cardActions: { favorited: boolean; tradeListed: boolean; shattered: boolean; shardGain: number }[] = $state([]);
|
||||
|
||||
async function packToggleFavorite(i: number) {
|
||||
const card = cards[i];
|
||||
const res = await apiFetch(`${API_URL}/cards/${card.id}/favorite`, { method: 'POST' });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
cards[i] = { ...cards[i], is_favorite: data.is_favorite };
|
||||
cardActions[i] = { ...cardActions[i], favorited: data.is_favorite };
|
||||
}
|
||||
}
|
||||
|
||||
async function packToggleTrade(i: number) {
|
||||
const card = cards[i];
|
||||
const res = await apiFetch(`${API_URL}/cards/${card.id}/willing-to-trade`, { method: 'POST' });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
cards[i] = { ...cards[i], willing_to_trade: data.willing_to_trade };
|
||||
cardActions[i] = { ...cardActions[i], tradeListed: data.willing_to_trade };
|
||||
}
|
||||
}
|
||||
|
||||
async function packShatter(i: number) {
|
||||
const card = cards[i];
|
||||
const res = await apiFetch(`${API_URL}/shards/shatter`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ card_ids: [card.id] }),
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
play('cardShatter');
|
||||
cardActions[i] = { ...cardActions[i], shattered: true, shardGain: data.gained };
|
||||
}
|
||||
}
|
||||
|
||||
// Compute fan positions for each card
|
||||
function fanStyle(i, total) {
|
||||
function fanStyle(i: number, total: number) {
|
||||
const isMobile = window.innerWidth <= 640;
|
||||
if (isMobile) {
|
||||
return `--tx: 0px; --ty: 0px;`;
|
||||
@@ -136,9 +180,9 @@
|
||||
<h1 class="pack-count">
|
||||
{#if boosters !== null}{boosters}/5 BOOSTER PACKS REMAINING{/if}
|
||||
</h1>
|
||||
{#if boosters !== null && boosters < 5 && countdownDisplay}
|
||||
<p class="countdown">{countdownDisplay} until next pack</p>
|
||||
{/if}
|
||||
<p class="countdown" class:invisible={!(boosters !== null && boosters < 5 && countdownDisplay)}>
|
||||
{countdownDisplay || ''} until next pack
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Idle pack -->
|
||||
@@ -157,6 +201,11 @@
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
{:else if boosters !== null && boosters === 0 && phase === 'idle'}
|
||||
<div class="no-packs">
|
||||
<p class="no-packs-msg">No packs remaining</p>
|
||||
<a href="/store" class="btn-buy-packs">Buy more packs</a>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if phase !== 'idle'}
|
||||
@@ -180,16 +229,44 @@
|
||||
{#each cards as card, i}
|
||||
{@const flipped = flippedCards[i]}
|
||||
{@const foil = FOIL_RARITIES.has(card.card_rarity)}
|
||||
{@const action = cardActions[i] ?? {}}
|
||||
<div
|
||||
class="fan-card"
|
||||
class:fan-visible={fanVisible}
|
||||
class:foil-reveal={flipped && foil}
|
||||
style="--i: {i};"
|
||||
>
|
||||
<div class="card-flipper" class:flipped>
|
||||
<div class="card-face back"><div class="card-back-face"></div></div>
|
||||
<div class="card-face front"><Card {card} /></div>
|
||||
<div class="card-shatter-wrap" class:shattered={action.shattered}>
|
||||
<div class="card-flipper" class:flipped>
|
||||
{#if !action.shattered}
|
||||
<div class="card-face back"><div class="card-back-face"></div></div>
|
||||
{/if}
|
||||
<div class="card-face front"><Card {card} /></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pack-card-actions" class:actions-visible={phase === 'done' && flipped}>
|
||||
{#if action.shattered}
|
||||
<span class="shard-gained">+{action.shardGain} ◈</span>
|
||||
{:else}
|
||||
<button
|
||||
class="pack-action-btn fav"
|
||||
class:active={action.favorited}
|
||||
onclick={() => packToggleFavorite(i)}
|
||||
title="Favorite"
|
||||
>★</button>
|
||||
<button
|
||||
class="pack-action-btn trade"
|
||||
class:active={action.tradeListed}
|
||||
onclick={() => packToggleTrade(i)}
|
||||
title="Mark for Trade"
|
||||
>⇄</button>
|
||||
<button
|
||||
class="pack-action-btn shatter"
|
||||
onclick={() => packShatter(i)}
|
||||
title="Shatter for shards"
|
||||
>◈</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -204,12 +281,10 @@
|
||||
</main>
|
||||
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;700;900&family=Crimson+Text:ital,wght@0,400;0,600;1,400&display=swap');
|
||||
|
||||
main {
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
background: #0d0a04;
|
||||
background: var(--color-bg);
|
||||
padding: 2rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -225,28 +300,67 @@
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: clamp(16px, 3vw, 26px);
|
||||
font-weight: 900;
|
||||
color: #f0d080;
|
||||
color: var(--color-gold);
|
||||
letter-spacing: 0.1em;
|
||||
margin: 0 0 0.4rem;
|
||||
}
|
||||
|
||||
.countdown {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 15px;
|
||||
font-size: var(--text-md);
|
||||
font-style: italic;
|
||||
color: rgba(240, 180, 80, 0.6);
|
||||
color: var(--color-gold-dim);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.invisible {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.verify-notice {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 18px;
|
||||
font-size: var(--text-lg);
|
||||
font-style: italic;
|
||||
color: rgba(240, 180, 80, 0.55);
|
||||
color: var(--color-gold-dim);
|
||||
text-align: center;
|
||||
margin-top: 4rem;
|
||||
}
|
||||
|
||||
.no-packs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1.2rem;
|
||||
margin-top: 4rem;
|
||||
}
|
||||
|
||||
.no-packs-msg {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: var(--text-lg);
|
||||
font-style: italic;
|
||||
color: var(--color-gold-dim);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.btn-buy-packs {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: var(--btn-font-lg);
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--color-btn-text);
|
||||
background: var(--color-bronze);
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--btn-padding-lg);
|
||||
text-decoration: none;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.btn-buy-packs:hover {
|
||||
background: var(--color-bronze-hover);
|
||||
}
|
||||
|
||||
.pack-wrap {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
@@ -340,7 +454,7 @@
|
||||
position: fixed;
|
||||
overflow: hidden;
|
||||
inset: 0;
|
||||
z-index: 200;
|
||||
z-index: var(--z-dropdown);
|
||||
background: rgba(0,0,0,0);
|
||||
transition: background 0.6s ease;
|
||||
display: flex;
|
||||
@@ -432,6 +546,9 @@
|
||||
transform: translateY(80vh);
|
||||
transition: opacity 0.5s ease, transform 0.7s cubic-bezier(0.2, 0.8, 0.3, 1);
|
||||
transition-delay: calc(var(--i) * 0.1s);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.fan-card.fan-visible {
|
||||
@@ -463,6 +580,18 @@
|
||||
transform: rotateY(180deg);
|
||||
}
|
||||
|
||||
/* Wrapper holds the animation — kept separate from card-flipper so that
|
||||
CSS filter doesn't conflict with transform-style: preserve-3d */
|
||||
.card-shatter-wrap.shattered {
|
||||
animation: shatter-fade 0.7s ease-out forwards;
|
||||
}
|
||||
|
||||
@keyframes shatter-fade {
|
||||
0% { opacity: 1; filter: brightness(1); transform: scale(1); }
|
||||
30% { opacity: 1; filter: brightness(2.5) saturate(3) hue-rotate(160deg); transform: scale(1.04); }
|
||||
100% { opacity: 0; filter: brightness(1); transform: scale(0.97); }
|
||||
}
|
||||
|
||||
.card-face {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
@@ -501,12 +630,13 @@
|
||||
transform: translateX(-50%);
|
||||
padding: 10px 32px;
|
||||
background: rgba(60,30,5,0.85);
|
||||
color: #f0d080;
|
||||
border: 1.5px solid #c8861a;
|
||||
border-radius: 6px;
|
||||
color: var(--color-gold);
|
||||
border: 1.5px solid var(--color-bronze);
|
||||
border-radius: var(--radius-md);
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 13px;
|
||||
font-size: var(--text-base);
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
letter-spacing: 0.08em;
|
||||
transition: background 0.15s;
|
||||
@@ -517,6 +647,81 @@
|
||||
background: rgba(100,60,10,0.9);
|
||||
}
|
||||
|
||||
/* ── Pack card actions ── */
|
||||
.pack-card-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin-top: 8px;
|
||||
height: 44px;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.pack-card-actions.actions-visible {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.pack-action-btn {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: var(--radius-full);
|
||||
border: 1.5px solid;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.pack-action-btn.fav {
|
||||
background: rgba(30, 20, 0, 0.7);
|
||||
border-color: rgba(200, 160, 0, 0.5);
|
||||
color: rgba(240, 200, 0, 0.6);
|
||||
}
|
||||
.pack-action-btn.fav:hover, .pack-action-btn.fav.active {
|
||||
background: rgba(60, 45, 0, 0.9);
|
||||
border-color: #c8a000;
|
||||
color: #f0c800;
|
||||
}
|
||||
|
||||
.pack-action-btn.trade {
|
||||
background: rgba(0, 25, 25, 0.7);
|
||||
border-color: rgba(0, 150, 150, 0.5);
|
||||
color: rgba(0, 190, 190, 0.6);
|
||||
}
|
||||
.pack-action-btn.trade:hover, .pack-action-btn.trade.active {
|
||||
background: rgba(0, 50, 50, 0.9);
|
||||
border-color: #00a0a0;
|
||||
color: var(--color-cyan);
|
||||
}
|
||||
|
||||
.pack-action-btn.shatter {
|
||||
background: rgba(0, 20, 30, 0.7);
|
||||
border-color: rgba(100, 200, 200, 0.4);
|
||||
color: rgba(126, 207, 207, 0.6);
|
||||
}
|
||||
.pack-action-btn.shatter:hover {
|
||||
background: rgba(0, 40, 50, 0.9);
|
||||
border-color: var(--color-cyan);
|
||||
color: var(--color-cyan);
|
||||
}
|
||||
|
||||
.shard-gained {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: var(--text-md);
|
||||
font-weight: 700;
|
||||
color: var(--color-cyan);
|
||||
padding: 8px 16px;
|
||||
background: rgba(0, 40, 50, 0.8);
|
||||
border: 1.5px solid rgba(126, 207, 207, 0.5);
|
||||
border-radius: 22px;
|
||||
}
|
||||
|
||||
/* ── Mobile ── */
|
||||
@media (max-width: 640px) {
|
||||
.fan-wrap {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import { API_URL, WS_URL } from '$lib/api.js';
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
|
||||
function validate() {
|
||||
if (!username.trim()) return 'Username is required';
|
||||
if (username.length < 2) return 'Username must be at least 2 characters';
|
||||
if (username.length > 16) return 'Username must be 16 characters or fewer';
|
||||
if (mode === 'register') {
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return 'Please enter a valid email';
|
||||
@@ -40,20 +41,18 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const form = new FormData();
|
||||
form.append('username', username);
|
||||
form.append('password', password);
|
||||
const res = await fetch(`${API_URL}/login`, {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({ username, password }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.detail);
|
||||
localStorage.setItem('token', data.access_token);
|
||||
localStorage.setItem('refresh_token', data.refresh_token);
|
||||
goto('/');
|
||||
} catch (e) {
|
||||
error = e.message;
|
||||
} catch (e: any) {
|
||||
error = e.message || 'Connection failed — check your network and try again';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
@@ -61,7 +60,15 @@
|
||||
</script>
|
||||
|
||||
<main>
|
||||
<div class="card-ghost" aria-hidden="true"></div>
|
||||
|
||||
<div class="card">
|
||||
<div class="wordmark">WikiTCG</div>
|
||||
<div class="rune-divider" aria-hidden="true">
|
||||
<span class="line"></span>
|
||||
<span class="rune">✦</span>
|
||||
<span class="line"></span>
|
||||
</div>
|
||||
<h1>{mode === 'login' ? 'Sign In' : 'Register'}</h1>
|
||||
|
||||
<div class="fields">
|
||||
@@ -103,33 +110,99 @@
|
||||
</main>
|
||||
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;700&family=Crimson+Text:ital,wght@0,400;0,600;1,400&display=swap');
|
||||
|
||||
main {
|
||||
min-height: 100vh;
|
||||
background: #0d0a04;
|
||||
background: var(--color-bg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Faint ghost card silhouettes in the background */
|
||||
.card-ghost {
|
||||
position: absolute;
|
||||
right: 12%;
|
||||
top: 50%;
|
||||
transform: translateY(-50%) rotate(8deg);
|
||||
width: 180px;
|
||||
height: 260px;
|
||||
border: 2px solid var(--color-gold);
|
||||
border-radius: var(--radius-lg);
|
||||
opacity: 0.04;
|
||||
pointer-events: none;
|
||||
animation: ghost-float 6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.card-ghost::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: calc(-100% - 60vw + 180px);
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 2px solid var(--color-gold);
|
||||
border-radius: var(--radius-lg);
|
||||
transform: rotate(-12deg);
|
||||
}
|
||||
|
||||
@keyframes ghost-float {
|
||||
0%, 100% { transform: translateY(-50%) rotate(8deg); }
|
||||
50% { transform: translateY(calc(-50% - 12px)) rotate(8deg); }
|
||||
}
|
||||
|
||||
.card {
|
||||
position: relative;
|
||||
z-index: var(--z-base);
|
||||
width: 340px;
|
||||
background: #2e1c05;
|
||||
border: 2px solid #6b4c1e;
|
||||
border-radius: 12px;
|
||||
background: var(--color-surface);
|
||||
border: 2px solid var(--color-bronze);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: 2rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.2rem;
|
||||
}
|
||||
|
||||
.wordmark {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: var(--text-xl);
|
||||
font-weight: 900;
|
||||
color: var(--color-gold);
|
||||
text-align: center;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: -0.4rem;
|
||||
}
|
||||
|
||||
.rune-divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.rune-divider .line {
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--color-border-subtle);
|
||||
}
|
||||
|
||||
.rune-divider .rune {
|
||||
font-size: var(--text-xs);
|
||||
color: rgba(240, 180, 80, 0.35);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 20px;
|
||||
color: #f0d080;
|
||||
font-size: var(--text-md);
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--color-gold-dim);
|
||||
text-align: center;
|
||||
margin: 0;
|
||||
margin: -0.4rem 0 0;
|
||||
}
|
||||
|
||||
.fields {
|
||||
@@ -141,39 +214,42 @@
|
||||
input {
|
||||
width: 100%;
|
||||
padding: 9px 12px;
|
||||
background: #1a1008;
|
||||
border: 1.5px solid #8b6420;
|
||||
border-radius: 6px;
|
||||
color: #f0d080;
|
||||
background: var(--color-surface);
|
||||
border: 1.5px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-gold);
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 15px;
|
||||
font-size: var(--text-md);
|
||||
box-sizing: border-box;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
input::placeholder {
|
||||
color: rgba(240, 180, 80, 0.4);
|
||||
color: var(--color-gold-faint);
|
||||
}
|
||||
|
||||
input:focus {
|
||||
border-color: #f0d080;
|
||||
border-color: var(--color-bronze);
|
||||
}
|
||||
|
||||
button {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
background: #6b4c1e;
|
||||
color: #f0d080;
|
||||
border: 1.5px solid #8b6420;
|
||||
border-radius: 6px;
|
||||
padding: var(--btn-padding-lg);
|
||||
background: var(--color-bronze);
|
||||
color: var(--color-btn-text);
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 13px;
|
||||
font-size: var(--btn-font-lg);
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
button:hover:not(:disabled) {
|
||||
background: #8b6420;
|
||||
background: var(--color-bronze-hover);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
@@ -182,44 +258,44 @@
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #c84040;
|
||||
color: var(--color-error);
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 13px;
|
||||
font-size: var(--text-base);
|
||||
text-align: center;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.toggle {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 13px;
|
||||
color: rgba(240, 180, 80, 0.7);
|
||||
font-size: var(--text-base);
|
||||
color: var(--color-gold-dim);
|
||||
text-align: center;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.link {
|
||||
all: unset;
|
||||
color: #f0d080;
|
||||
color: var(--color-gold);
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
width: auto;
|
||||
padding: 0;
|
||||
font-size: 13px;
|
||||
font-size: var(--text-base);
|
||||
font-family: 'Crimson Text', serif;
|
||||
}
|
||||
|
||||
.link:hover {
|
||||
color: #f0d080;
|
||||
color: var(--color-gold);
|
||||
}
|
||||
|
||||
.forgot-link {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 13px;
|
||||
color: rgba(245, 208, 96, 0.45);
|
||||
font-size: var(--text-base);
|
||||
color: var(--color-gold-faint);
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.forgot-link:hover { color: rgba(245, 208, 96, 0.8); }
|
||||
.forgot-link:hover { color: var(--color-gold-muted); }
|
||||
</style>
|
||||
@@ -1,11 +1,10 @@
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import { API_URL, WS_URL } from '$lib/api.js';
|
||||
import { apiFetch } from '$lib/api.js';
|
||||
import { goto } from '$app/navigation';
|
||||
import { onMount } from 'svelte';
|
||||
import Card from '$lib/Card.svelte';
|
||||
|
||||
let allCards = $state([]);
|
||||
let loading = $state(true);
|
||||
const token = () => localStorage.getItem('token');
|
||||
|
||||
@@ -21,53 +20,106 @@
|
||||
|
||||
let filtersOpen = $state(false);
|
||||
|
||||
const RARITY_ORDER = Object.fromEntries(RARITIES.map((r, i) => [r, i]));
|
||||
|
||||
function label(str) {
|
||||
function label(str: string) {
|
||||
return str.charAt(0).toUpperCase() + str.slice(1).replace(/_/g, ' ');
|
||||
}
|
||||
|
||||
|
||||
let sortAsc = $state(true);
|
||||
let costMin = $state(1);
|
||||
let costMax = $state(10);
|
||||
let searchQuery = $state('');
|
||||
let favoritesOnly = $state(false);
|
||||
let willingToTradeOnly = $state(false);
|
||||
|
||||
let filtered = $derived.by(() => {
|
||||
const q = searchQuery.trim().toLowerCase();
|
||||
let result = allCards.filter(c =>
|
||||
selectedRarities.has(c.card_rarity) &&
|
||||
selectedTypes.has(c.card_type) &&
|
||||
c.cost >= costMin &&
|
||||
c.cost <= costMax &&
|
||||
(!q || c.name.toLowerCase().includes(q))
|
||||
);
|
||||
// Selection mode for bulk actions
|
||||
let selectionMode = $state(false);
|
||||
let selectedIds = $state(new Set<string>());
|
||||
let bulkLoading = $state(false);
|
||||
|
||||
result = result.slice().sort((a, b) => {
|
||||
let cmp = 0;
|
||||
if (sortBy === 'name') cmp = a.name.localeCompare(b.name);
|
||||
else if (sortBy === 'cost') cmp = b.cost - a.cost || a.name.localeCompare(b.name);
|
||||
else if (sortBy === 'attack') cmp = b.attack - a.attack || a.name.localeCompare(b.name);
|
||||
else if (sortBy === 'defense') cmp = b.defense - a.defense || a.name.localeCompare(b.name);
|
||||
else if (sortBy === 'rarity') cmp = RARITY_ORDER[b.card_rarity] - RARITY_ORDER[a.card_rarity] || a.name.localeCompare(b.name);
|
||||
return sortAsc ? cmp : -cmp;
|
||||
// Server-side fetch state
|
||||
const PAGE_SIZE = 40;
|
||||
let cards: any[] = $state([]);
|
||||
let total = $state(0);
|
||||
let loadingMore = $state(false);
|
||||
let hasMore = $derived(cards.length < total);
|
||||
// Must be $state so the IntersectionObserver $effect re-runs when the element is bound
|
||||
let sentinel: HTMLElement | undefined = $state();
|
||||
// <main> has overflow-y: auto — it is the scroll container, not the viewport.
|
||||
// The IntersectionObserver root must be the actual scroll container.
|
||||
let scrollContainer: HTMLElement | undefined = $state();
|
||||
|
||||
// For non-name sorts the "natural" first click should show the highest/newest/rarest values,
|
||||
// which is descending. This matches the old client-side behaviour (b.cost - a.cost etc.).
|
||||
function sortDir() {
|
||||
return sortBy === 'name'
|
||||
? (sortAsc ? 'asc' : 'desc')
|
||||
: (sortAsc ? 'desc' : 'asc');
|
||||
}
|
||||
|
||||
async function fetchCards(reset = false) {
|
||||
if (reset) { cards = []; total = 0; }
|
||||
if (loadingMore) return;
|
||||
loadingMore = true;
|
||||
const params = new URLSearchParams({
|
||||
skip: String(reset ? 0 : cards.length),
|
||||
limit: String(PAGE_SIZE),
|
||||
search: searchQuery.trim(),
|
||||
cost_min: String(costMin),
|
||||
cost_max: String(costMax),
|
||||
favorites_only: String(favoritesOnly),
|
||||
wtt_only: String(willingToTradeOnly),
|
||||
sort_by: sortBy,
|
||||
sort_dir: sortDir(),
|
||||
});
|
||||
for (const r of selectedRarities) params.append('rarities', r);
|
||||
for (const t of selectedTypes) params.append('types', t);
|
||||
const res = await apiFetch(`${API_URL}/cards?${params}`);
|
||||
if (res.status === 401) { goto('/auth'); loadingMore = false; return; }
|
||||
const data = await res.json();
|
||||
cards = reset ? data.cards : [...cards, ...data.cards];
|
||||
total = data.total;
|
||||
loadingMore = false;
|
||||
}
|
||||
|
||||
return result;
|
||||
// Debounced refetch when any filter/sort state changes.
|
||||
// Skip the initial run on mount — onMount handles the first fetch directly so
|
||||
// there's no double-fetch (which would cause a visible flash as cards reset).
|
||||
let mounted = false;
|
||||
let fetchTimer: number;
|
||||
$effect(() => {
|
||||
searchQuery; sortBy; sortAsc; selectedRarities; selectedTypes;
|
||||
costMin; costMax; favoritesOnly; willingToTradeOnly;
|
||||
if (!mounted) return;
|
||||
clearTimeout(fetchTimer);
|
||||
fetchTimer = setTimeout(() => fetchCards(true), 300);
|
||||
});
|
||||
|
||||
function toggleSort(val) {
|
||||
// IntersectionObserver: load next page when sentinel scrolls into view.
|
||||
// Both sentinel and scrollContainer are $state so this effect re-runs once bound.
|
||||
$effect(() => {
|
||||
if (!sentinel || !scrollContainer) return;
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0].isIntersecting && hasMore && !loadingMore) fetchCards(false);
|
||||
},
|
||||
{ root: scrollContainer, rootMargin: '200px' }
|
||||
);
|
||||
observer.observe(sentinel);
|
||||
return () => observer.disconnect();
|
||||
});
|
||||
|
||||
function toggleSort(val: string) {
|
||||
if (sortBy === val) sortAsc = !sortAsc;
|
||||
else { sortBy = val; sortAsc = true; }
|
||||
}
|
||||
|
||||
function toggleRarity(r) {
|
||||
function toggleRarity(r: string) {
|
||||
const s = new Set(selectedRarities);
|
||||
s.has(r) ? s.delete(r) : s.add(r);
|
||||
selectedRarities = s;
|
||||
}
|
||||
|
||||
function toggleType(t) {
|
||||
function toggleType(t: string) {
|
||||
const s = new Set(selectedTypes);
|
||||
s.has(t) ? s.delete(t) : s.add(t);
|
||||
selectedTypes = s;
|
||||
@@ -86,35 +138,65 @@
|
||||
|
||||
onMount(async () => {
|
||||
if (!token()) { goto('/auth'); return; }
|
||||
const res = await apiFetch(`${API_URL}/cards`);
|
||||
if (res.status === 401) { goto('/auth'); return; }
|
||||
allCards = await res.json();
|
||||
await fetchCards(true);
|
||||
loading = false;
|
||||
mounted = true;
|
||||
});
|
||||
|
||||
let selectedCard = $state(null);
|
||||
let refreshStatus = $state(null);
|
||||
let selectedCard: any = $state(null);
|
||||
let refreshStatus: { can_refresh: boolean; next_refresh_at: string | null } | null = $state(null);
|
||||
let countdownDisplay = $state('');
|
||||
let countdownInterval = null;
|
||||
let countdownInterval: number | undefined = undefined;
|
||||
let reportLoading = $state(false);
|
||||
let refreshLoading = $state(false);
|
||||
let actionMessage = $state('');
|
||||
|
||||
let popupEl: HTMLElement;
|
||||
let previousFocus: Element | null = null;
|
||||
|
||||
// Move focus to first focusable element when modal opens
|
||||
$effect(() => {
|
||||
if (selectedCard && popupEl) {
|
||||
const first = popupEl.querySelector<HTMLElement>(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
||||
);
|
||||
first?.focus();
|
||||
}
|
||||
});
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape' && selectedCard) closeCard();
|
||||
}
|
||||
|
||||
function trapFocus(e: KeyboardEvent) {
|
||||
if (e.key !== 'Tab') return;
|
||||
const focusable = [...popupEl.querySelectorAll<HTMLElement>(
|
||||
'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
||||
)];
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
if (e.shiftKey) {
|
||||
if (document.activeElement === first) { e.preventDefault(); last.focus(); }
|
||||
} else {
|
||||
if (document.activeElement === last) { e.preventDefault(); first.focus(); }
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchRefreshStatus() {
|
||||
const res = await apiFetch(`${API_URL}/profile/refresh-status`);
|
||||
refreshStatus = await res.json();
|
||||
if (!refreshStatus.can_refresh && refreshStatus.next_refresh_at) {
|
||||
if (refreshStatus && !refreshStatus.can_refresh && refreshStatus.next_refresh_at) {
|
||||
startRefreshCountdown(new Date(refreshStatus.next_refresh_at));
|
||||
}
|
||||
}
|
||||
|
||||
function startRefreshCountdown(nextRefreshAt) {
|
||||
function startRefreshCountdown(nextRefreshAt: Date) {
|
||||
clearInterval(countdownInterval);
|
||||
countdownInterval = setInterval(() => {
|
||||
const diff = nextRefreshAt - Date.now();
|
||||
const diff = nextRefreshAt.getTime() - Date.now();
|
||||
if (diff <= 0) {
|
||||
clearInterval(countdownInterval);
|
||||
refreshStatus = { can_refresh: true, next_refresh_at: null };
|
||||
refreshStatus = { can_refresh: true, next_refresh_at: null } as typeof refreshStatus;
|
||||
countdownDisplay = '';
|
||||
return;
|
||||
}
|
||||
@@ -125,7 +207,54 @@
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function openCard(card) {
|
||||
function toggleSelectionMode() {
|
||||
selectionMode = !selectionMode;
|
||||
if (!selectionMode) selectedIds = new Set();
|
||||
}
|
||||
|
||||
function toggleCardSelection(id: string) {
|
||||
const s = new Set(selectedIds);
|
||||
s.has(id) ? s.delete(id) : s.add(id);
|
||||
selectedIds = s;
|
||||
}
|
||||
|
||||
function selectAllLoaded() {
|
||||
if (cards.every(c => selectedIds.has(c.id))) {
|
||||
selectedIds = new Set();
|
||||
} else {
|
||||
selectedIds = new Set(cards.map((c: any) => c.id));
|
||||
}
|
||||
}
|
||||
|
||||
async function bulkFavorite(setTo: boolean) {
|
||||
bulkLoading = true;
|
||||
const targets = cards.filter(c => selectedIds.has(c.id) && c.is_favorite !== setTo);
|
||||
await Promise.all(targets.map(async (c: any) => {
|
||||
const res = await apiFetch(`${API_URL}/cards/${c.id}/favorite`, { method: 'POST' });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
cards = cards.map(x => x.id === c.id ? { ...x, is_favorite: data.is_favorite } : x);
|
||||
}
|
||||
}));
|
||||
bulkLoading = false;
|
||||
}
|
||||
|
||||
async function bulkWTT(setTo: boolean) {
|
||||
bulkLoading = true;
|
||||
const targets = cards.filter(c => selectedIds.has(c.id) && c.willing_to_trade !== setTo);
|
||||
await Promise.all(targets.map(async (c: any) => {
|
||||
const res = await apiFetch(`${API_URL}/cards/${c.id}/willing-to-trade`, { method: 'POST' });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
cards = cards.map(x => x.id === c.id ? { ...x, willing_to_trade: data.willing_to_trade } : x);
|
||||
}
|
||||
}));
|
||||
bulkLoading = false;
|
||||
}
|
||||
|
||||
function openCard(card: any) {
|
||||
if (selectionMode) return;
|
||||
previousFocus = document.activeElement;
|
||||
selectedCard = card;
|
||||
actionMessage = '';
|
||||
fetchRefreshStatus();
|
||||
@@ -136,6 +265,8 @@
|
||||
clearInterval(countdownInterval);
|
||||
countdownDisplay = '';
|
||||
actionMessage = '';
|
||||
(previousFocus as HTMLElement)?.focus();
|
||||
previousFocus = null;
|
||||
}
|
||||
|
||||
async function reportCard() {
|
||||
@@ -146,13 +277,31 @@
|
||||
reportLoading = false;
|
||||
if (res.ok) {
|
||||
selectedCard = { ...selectedCard, reported: true };
|
||||
allCards = allCards.map(c => c.id === selectedCard.id ? { ...c, reported: true } : c);
|
||||
cards = cards.map(c => c.id === selectedCard.id ? { ...c, reported: true } : c);
|
||||
actionMessage = 'Card reported. Thank you!';
|
||||
} else {
|
||||
actionMessage = 'Failed to report card.';
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleFavorite() {
|
||||
const res = await apiFetch(`${API_URL}/cards/${selectedCard.id}/favorite`, { method: 'POST' });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
selectedCard = { ...selectedCard, is_favorite: data.is_favorite };
|
||||
cards = cards.map(c => c.id === selectedCard.id ? { ...c, is_favorite: data.is_favorite } : c);
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleWillingToTrade() {
|
||||
const res = await apiFetch(`${API_URL}/cards/${selectedCard.id}/willing-to-trade`, { method: 'POST' });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
selectedCard = { ...selectedCard, willing_to_trade: data.willing_to_trade };
|
||||
cards = cards.map(c => c.id === selectedCard.id ? { ...c, willing_to_trade: data.willing_to_trade } : c);
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshCard() {
|
||||
refreshLoading = true;
|
||||
actionMessage = '';
|
||||
@@ -162,10 +311,9 @@
|
||||
refreshLoading = false;
|
||||
if (res.ok) {
|
||||
const updated = await res.json();
|
||||
// Update card in allCards list
|
||||
allCards = allCards.map(c => c.id === updated.id ? updated : c);
|
||||
cards = cards.map(c => c.id === updated.id ? updated : c);
|
||||
selectedCard = updated;
|
||||
refreshStatus = { can_refresh: false, next_refresh_at: null };
|
||||
refreshStatus = { can_refresh: false, next_refresh_at: null } as typeof refreshStatus;
|
||||
await fetchRefreshStatus();
|
||||
actionMessage = 'Card refreshed!';
|
||||
} else {
|
||||
@@ -175,11 +323,13 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<main>
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
<main bind:this={scrollContainer}>
|
||||
<div class="toolbar">
|
||||
<div class="sort-row">
|
||||
<span class="toolbar-label">Sort by</span>
|
||||
{#each [['name','Name'],['cost','Cost'],['attack','Attack'],['defense','Defense'],['rarity','Rarity']] as [val, lbl]}
|
||||
{#each [['name','Name'],['cost','Cost'],['attack','Attack'],['defense','Defense'],['rarity','Rarity'],['date_generated','Generated'],['date_received','Received']] as [val, lbl]}
|
||||
<button
|
||||
class="sort-btn"
|
||||
class:active={sortBy === val}
|
||||
@@ -199,12 +349,32 @@
|
||||
bind:value={searchQuery}
|
||||
/>
|
||||
|
||||
<button class="filter-toggle" onclick={() => filtersOpen = !filtersOpen}>
|
||||
{filtersOpen ? 'Hide filters' : 'Filter'}
|
||||
{#if selectedRarities.size < RARITIES.length || selectedTypes.size < TYPES.length || costMin > 1 || costMax < 10}
|
||||
<span class="filter-dot"></span>
|
||||
{/if}
|
||||
</button>
|
||||
<div class="filter-actions">
|
||||
<button
|
||||
class="filter-toggle"
|
||||
class:active={favoritesOnly}
|
||||
onclick={() => favoritesOnly = !favoritesOnly}
|
||||
title="Show favorites only"
|
||||
>★</button>
|
||||
|
||||
<button
|
||||
class="filter-toggle"
|
||||
class:active={willingToTradeOnly}
|
||||
onclick={() => willingToTradeOnly = !willingToTradeOnly}
|
||||
title="Show willing to trade only"
|
||||
>⇄</button>
|
||||
|
||||
<button class="filter-toggle" class:active={filtersOpen} onclick={() => filtersOpen = !filtersOpen}>
|
||||
Filters
|
||||
{#if selectedRarities.size < RARITIES.length || selectedTypes.size < TYPES.length || costMin > 1 || costMax < 10}
|
||||
<span class="filter-dot"></span>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<button class="filter-toggle" class:active={selectionMode} onclick={toggleSelectionMode}>
|
||||
{selectionMode ? 'Done' : 'Select'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if filtersOpen}
|
||||
@@ -263,21 +433,53 @@
|
||||
|
||||
{#if loading}
|
||||
<p class="status">Loading your cards...</p>
|
||||
{:else if filtered.length === 0}
|
||||
{:else if !loadingMore && cards.length === 0}
|
||||
<p class="status">No cards match your filters.</p>
|
||||
{:else}
|
||||
<p class="card-count">{filtered.length} card{filtered.length === 1 ? '' : 's'}</p>
|
||||
<div class="grid">
|
||||
{#each filtered as card (card.id)}
|
||||
<button class="card-btn" onclick={() => openCard(card)}>
|
||||
<Card {card} />
|
||||
</button>
|
||||
<p class="card-count">{total} card{total === 1 ? '' : 's'}</p>
|
||||
<div class="grid" style={selectionMode ? 'padding-bottom: 100px' : ''}>
|
||||
{#each cards as card (card.id)}
|
||||
<div class="card-item">
|
||||
<button
|
||||
class="card-btn"
|
||||
class:selected={selectionMode && selectedIds.has(card.id)}
|
||||
onclick={() => selectionMode ? toggleCardSelection(card.id) : openCard(card)}
|
||||
>
|
||||
<Card {card} noHover={selectionMode} />
|
||||
{#if selectionMode && selectedIds.has(card.id)}
|
||||
<div class="selected-badge">✓</div>
|
||||
{/if}
|
||||
</button>
|
||||
{#if sortBy === 'date_received' && card.received_at}
|
||||
<span class="received-label" aria-label="Date received">Received {new Date(card.received_at).toLocaleDateString('en-GB', { year: 'numeric', month: '2-digit', day: '2-digit' })}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<div bind:this={sentinel} class="scroll-sentinel"></div>
|
||||
{#if loadingMore}<p class="status">Loading more...</p>{/if}
|
||||
{/if}
|
||||
|
||||
{#if selectionMode}
|
||||
<div class="bulk-bar">
|
||||
<span class="bulk-count">{selectedIds.size} card{selectedIds.size === 1 ? '' : 's'} selected</span>
|
||||
<div class="bulk-actions">
|
||||
<button class="bulk-btn" disabled={bulkLoading} onclick={() => bulkFavorite(true)}>★ Favorite</button>
|
||||
<button class="bulk-btn" disabled={bulkLoading} onclick={() => bulkFavorite(false)}>★ Unfavorite</button>
|
||||
<button class="bulk-btn" disabled={bulkLoading} onclick={() => bulkWTT(true)}>⇄ Mark WTT</button>
|
||||
<button class="bulk-btn" disabled={bulkLoading} onclick={() => bulkWTT(false)}>⇄ Unmark WTT</button>
|
||||
</div>
|
||||
<div class="bulk-secondary">
|
||||
<button class="bulk-select-all" onclick={selectAllLoaded}>
|
||||
{cards.every(c => selectedIds.has(c.id)) ? 'Deselect All' : 'Select All'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if selectedCard}
|
||||
<div class="backdrop" onclick={closeCard}>
|
||||
<div class="card-popup" onclick={(e) => e.stopPropagation()}>
|
||||
<div class="card-popup" onclick={(e) => e.stopPropagation()} onkeydown={trapFocus} bind:this={popupEl} role="dialog" aria-modal="true" aria-label="Card details">
|
||||
<Card card={selectedCard} />
|
||||
<div class="popup-actions">
|
||||
<div class="action-col">
|
||||
@@ -289,6 +491,24 @@
|
||||
{selectedCard.reported ? 'Already Reported' : reportLoading ? 'Reporting...' : 'Report Error'}
|
||||
</button>
|
||||
</div>
|
||||
<div class="action-col">
|
||||
<button
|
||||
class="fav-btn"
|
||||
class:fav-active={selectedCard.is_favorite}
|
||||
onclick={toggleFavorite}
|
||||
>
|
||||
{selectedCard.is_favorite ? '★ Favorited' : '★ Favorite'}
|
||||
</button>
|
||||
</div>
|
||||
<div class="action-col">
|
||||
<button
|
||||
class="wtt-btn"
|
||||
class:wtt-active={selectedCard.willing_to_trade}
|
||||
onclick={toggleWillingToTrade}
|
||||
>
|
||||
{selectedCard.willing_to_trade ? '⇄ Listed for Trade' : '⇄ Mark for Trade'}
|
||||
</button>
|
||||
</div>
|
||||
<div class="action-col">
|
||||
<button
|
||||
class="refresh-btn"
|
||||
@@ -310,12 +530,10 @@
|
||||
</main>
|
||||
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;700&family=Crimson+Text:ital,wght@0,400;0,600;1,400&display=swap');
|
||||
|
||||
main {
|
||||
height: 100vh;
|
||||
overflow-y: auto;
|
||||
background: #0d0a04;
|
||||
background: var(--color-bg);
|
||||
padding: 0 2rem 2rem 2rem;
|
||||
}
|
||||
|
||||
@@ -323,9 +541,9 @@
|
||||
position: sticky;
|
||||
top: 0px;
|
||||
z-index: 50;
|
||||
background: #0d0a04;
|
||||
background: var(--color-bg);
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 1px solid rgba(107, 76, 30, 0.3);
|
||||
border-bottom: 1px solid var(--color-border-dim);
|
||||
margin-bottom: 2rem;
|
||||
padding-top: 32px;
|
||||
}
|
||||
@@ -339,23 +557,23 @@
|
||||
|
||||
.search-input {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 15px;
|
||||
background: rgba(255,255,255,0.04);
|
||||
border: 1px solid rgba(107, 76, 30, 0.4);
|
||||
border-radius: 4px;
|
||||
color: #f0d080;
|
||||
font-size: var(--text-md);
|
||||
background: var(--color-surface);
|
||||
border: 1.5px solid var(--color-bronze);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-gold);
|
||||
padding: 5px 10px;
|
||||
outline: none;
|
||||
width: 220px;
|
||||
margin-left: auto;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.search-input:focus { border-color: #c8861a; }
|
||||
.search-input:focus { border-color: var(--color-gold); }
|
||||
.search-input::placeholder { color: rgba(240, 180, 80, 0.3); }
|
||||
|
||||
.toolbar-label {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 11px;
|
||||
font-size: var(--text-sm);
|
||||
color: rgba(240, 180, 80, 0.5);
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
@@ -364,50 +582,61 @@
|
||||
|
||||
.sort-btn {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 11px;
|
||||
font-size: var(--btn-font-sm);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
background: none;
|
||||
border: 1px solid rgba(107, 76, 30, 0.4);
|
||||
border-radius: 4px;
|
||||
color: rgba(240, 180, 80, 0.6);
|
||||
padding: 4px 10px;
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-gold-dim);
|
||||
padding: var(--btn-padding-sm);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.sort-btn:hover {
|
||||
border-color: #c8861a;
|
||||
color: #f0d080;
|
||||
border-color: var(--color-bronze);
|
||||
color: var(--color-gold);
|
||||
}
|
||||
|
||||
.sort-btn.active {
|
||||
background: #3d2507;
|
||||
border-color: #c8861a;
|
||||
color: #f0d080;
|
||||
background: var(--color-surface-raised);
|
||||
border-color: var(--color-bronze);
|
||||
color: var(--color-gold);
|
||||
}
|
||||
|
||||
.filter-toggle {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 11px;
|
||||
font-size: var(--btn-font-sm);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
background: none;
|
||||
border: 1px solid rgba(107, 76, 30, 0.4);
|
||||
border-radius: 4px;
|
||||
color: rgba(240, 180, 80, 0.6);
|
||||
padding: 4px 10px;
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-gold-dim);
|
||||
padding: var(--btn-padding-sm);
|
||||
cursor: pointer;
|
||||
margin-left: 0.5rem;
|
||||
position: relative;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.filter-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-left: 0.75rem;
|
||||
}
|
||||
|
||||
.filter-toggle:hover {
|
||||
border-color: #c8861a;
|
||||
color: #f0d080;
|
||||
border-color: var(--color-bronze);
|
||||
color: var(--color-gold);
|
||||
}
|
||||
|
||||
.filter-toggle.active {
|
||||
background: var(--color-surface-raised);
|
||||
border-color: var(--color-bronze);
|
||||
color: var(--color-gold);
|
||||
}
|
||||
|
||||
.filter-dot {
|
||||
@@ -416,8 +645,8 @@
|
||||
right: -3px;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: #c8861a;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--color-bronze);
|
||||
}
|
||||
|
||||
.filters {
|
||||
@@ -441,7 +670,7 @@
|
||||
|
||||
.filter-group-label {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 11px;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
@@ -450,7 +679,7 @@
|
||||
|
||||
.select-all {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 12px;
|
||||
font-size: var(--text-sm);
|
||||
font-style: italic;
|
||||
background: none;
|
||||
border: none;
|
||||
@@ -462,7 +691,7 @@
|
||||
}
|
||||
|
||||
.select-all:hover {
|
||||
color: #f0d080;
|
||||
color: var(--color-gold);
|
||||
}
|
||||
|
||||
.checkboxes {
|
||||
@@ -476,23 +705,25 @@
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 14px;
|
||||
color: rgba(240, 180, 80, 0.8);
|
||||
font-size: var(--text-base);
|
||||
color: var(--color-gold-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.checkbox-label input {
|
||||
accent-color: #c8861a;
|
||||
accent-color: var(--color-bronze);
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.scroll-sentinel { height: 1px; }
|
||||
|
||||
.card-count {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 16px;
|
||||
font-size: var(--text-md);
|
||||
font-style: italic;
|
||||
color: rgba(240, 180, 80, 0.4);
|
||||
color: var(--color-gold-faint);
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
@@ -506,7 +737,7 @@
|
||||
|
||||
.status {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 16px;
|
||||
font-size: var(--text-md);
|
||||
font-style: italic;
|
||||
color: rgba(240, 180, 80, 0.5);
|
||||
text-align: center;
|
||||
@@ -514,7 +745,7 @@
|
||||
}
|
||||
|
||||
.sort-arrow {
|
||||
font-size: 10px;
|
||||
font-size: var(--text-xs);
|
||||
margin-left: 3px;
|
||||
}
|
||||
|
||||
@@ -526,21 +757,25 @@
|
||||
|
||||
.range-label {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 11px;
|
||||
color: rgba(240, 180, 80, 0.7);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-gold-dim);
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
input[type=range] {
|
||||
accent-color: #c8861a;
|
||||
accent-color: var(--color-bronze);
|
||||
width: 160px;
|
||||
}
|
||||
|
||||
.card-item {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.card-btn {
|
||||
all: unset;
|
||||
cursor: pointer;
|
||||
display: block;
|
||||
border-radius: 12px;
|
||||
border-radius: var(--radius-xl);
|
||||
transition: transform 0.15s;
|
||||
}
|
||||
|
||||
@@ -548,6 +783,26 @@
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
.received-label {
|
||||
position: absolute;
|
||||
top: 44px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
background: rgba(13, 10, 4, 0.88);
|
||||
backdrop-filter: blur(6px);
|
||||
border: 1px solid rgba(200, 134, 26, 0.55);
|
||||
border-radius: 20px;
|
||||
padding: 3px 12px;
|
||||
font-size: var(--text-sm);
|
||||
font-family: 'Cinzel', serif;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.07em;
|
||||
color: var(--color-gold);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
@@ -555,7 +810,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 100;
|
||||
z-index: var(--z-header);
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
|
||||
@@ -565,12 +820,17 @@
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding-top: 5rem;
|
||||
}
|
||||
|
||||
.popup-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
align-items: stretch;
|
||||
width: 100%;
|
||||
max-width: 260px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.action-col {
|
||||
@@ -578,37 +838,70 @@
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
min-height: 60px;
|
||||
}
|
||||
|
||||
.report-btn, .refresh-btn {
|
||||
.fav-btn, .wtt-btn, .report-btn, .refresh-btn {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 11px;
|
||||
font-size: var(--btn-font-md);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
border-radius: 4px;
|
||||
padding: 8px 18px;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: var(--btn-padding-md);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.fav-btn {
|
||||
background: rgba(30, 20, 0, 0.6);
|
||||
border: 1px solid rgba(200, 160, 0, 0.4);
|
||||
color: rgba(240, 200, 0, 0.6);
|
||||
}
|
||||
|
||||
.fav-btn:hover {
|
||||
border-color: rgba(200, 160, 0, 0.8);
|
||||
color: #f0c800;
|
||||
}
|
||||
|
||||
.fav-btn.fav-active {
|
||||
background: rgba(60, 45, 0, 0.8);
|
||||
border-color: #c8a000;
|
||||
color: #f0c800;
|
||||
}
|
||||
|
||||
.wtt-btn {
|
||||
background: rgba(0, 30, 30, 0.6);
|
||||
border: 1px solid rgba(0, 160, 160, 0.4);
|
||||
color: rgba(0, 200, 200, 0.6);
|
||||
}
|
||||
|
||||
.wtt-btn:hover {
|
||||
border-color: rgba(0, 180, 180, 0.8);
|
||||
color: #7ecfcf;
|
||||
}
|
||||
|
||||
.wtt-btn.wtt-active {
|
||||
background: rgba(0, 50, 50, 0.8);
|
||||
border-color: #00a0a0;
|
||||
color: #7ecfcf;
|
||||
}
|
||||
|
||||
.report-btn {
|
||||
background: rgba(180, 60, 60, 0.5);
|
||||
border: 1px solid rgba(240,250,240,0.8);
|
||||
border: 1px solid rgba(200, 60, 60, 0.5);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.report-btn:hover:not(:disabled) {
|
||||
/* border-color: #c84040; */
|
||||
color: #E0E0E0;
|
||||
background: rgba(180, 40, 40, 0.9);
|
||||
}
|
||||
|
||||
.refresh-btn {
|
||||
background: #3d2507;
|
||||
border: 1px solid #c8861a;
|
||||
color: #f0d080;
|
||||
background: var(--color-surface-raised);
|
||||
border: 1px solid var(--color-bronze);
|
||||
color: var(--color-gold);
|
||||
}
|
||||
|
||||
.refresh-btn:hover:not(:disabled) {
|
||||
@@ -622,16 +915,16 @@
|
||||
|
||||
.refresh-countdown {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 12px;
|
||||
font-size: var(--text-sm);
|
||||
font-style: italic;
|
||||
color: rgba(240, 180, 80, 0.5);
|
||||
}
|
||||
|
||||
.action-message {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 14px;
|
||||
font-size: var(--text-base);
|
||||
font-style: italic;
|
||||
color: rgba(240, 180, 80, 0.7);
|
||||
color: var(--color-gold-dim);
|
||||
margin: 0;
|
||||
min-height: 1.4em;
|
||||
text-align: center;
|
||||
@@ -639,15 +932,15 @@
|
||||
|
||||
.close-btn {
|
||||
position: absolute;
|
||||
top: -12px;
|
||||
top: calc(5rem - 14px);
|
||||
right: -12px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background: #1a1008;
|
||||
border: 1px solid #6b4c1e;
|
||||
color: rgba(240, 180, 80, 0.7);
|
||||
font-size: 12px;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-gold-dim);
|
||||
font-size: var(--text-sm);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -656,7 +949,110 @@
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
border-color: #c8861a;
|
||||
color: #f0d080;
|
||||
border-color: var(--color-bronze);
|
||||
color: var(--color-gold);
|
||||
}
|
||||
|
||||
.card-btn.selected {
|
||||
box-shadow: 0 0 0 3px var(--color-bronze), var(--shadow-glow);
|
||||
}
|
||||
|
||||
.selected-badge {
|
||||
position: absolute;
|
||||
top: 80px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: var(--color-bronze);
|
||||
color: var(--color-btn-text);
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: var(--text-xl);
|
||||
font-weight: 900;
|
||||
padding: 4px 10px;
|
||||
border-radius: 23px;
|
||||
border: black 3px solid;
|
||||
pointer-events: none;
|
||||
z-index: var(--z-card);
|
||||
}
|
||||
|
||||
.bulk-bar {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 60;
|
||||
background: var(--color-surface);
|
||||
border-top: 1px solid var(--color-border);
|
||||
padding: 12px 2rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
box-shadow: 0 -4px 20px rgba(0,0,0,0.6);
|
||||
}
|
||||
|
||||
.bulk-count {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-gold);
|
||||
white-space: nowrap;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.bulk-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.bulk-btn {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: var(--btn-font-md);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
background: var(--color-surface-raised);
|
||||
border: 1px solid rgba(107, 76, 30, 0.6);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-gold);
|
||||
padding: var(--btn-padding-md);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.bulk-btn:hover:not(:disabled) {
|
||||
border-color: var(--color-bronze);
|
||||
background: #5a3510;
|
||||
color: var(--color-btn-text);
|
||||
}
|
||||
|
||||
.bulk-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.bulk-secondary {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.bulk-select-all {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: var(--text-base);
|
||||
font-style: italic;
|
||||
background: none;
|
||||
border: none;
|
||||
color: rgba(240, 180, 80, 0.5);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
text-decoration: underline;
|
||||
transition: color 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.bulk-select-all:hover {
|
||||
color: var(--color-gold);
|
||||
}
|
||||
</style>
|
||||
@@ -1,15 +1,15 @@
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import { API_URL, WS_URL } from '$lib/api.js';
|
||||
import { apiFetch } from '$lib/api.js';
|
||||
import { goto } from '$app/navigation';
|
||||
import { onMount } from 'svelte';
|
||||
import DeckTypeBadge from '$lib/DeckTypeBadge.svelte';
|
||||
|
||||
let decks = $state([]);
|
||||
let decks: any[] = $state([]);
|
||||
let loading = $state(true);
|
||||
|
||||
let editConfirm = $state(null); // deck object pending edit confirmation
|
||||
let deleteConfirm = $state(null); // deck object pending delete confirmation
|
||||
let editConfirm: any = $state(null); // deck object pending edit confirmation
|
||||
let deleteConfirm: any = $state(null); // deck object pending delete confirmation
|
||||
|
||||
const token = () => localStorage.getItem('token');
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
goto(`/decks/${deck.id}`);
|
||||
}
|
||||
|
||||
function clickEdit(deck) {
|
||||
function clickEdit(deck: any) {
|
||||
if (deck.times_played > 0) {
|
||||
editConfirm = deck;
|
||||
} else {
|
||||
@@ -41,7 +41,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
function clickDelete(deck) {
|
||||
function clickDelete(deck: any) {
|
||||
deleteConfirm = deck;
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
deleteConfirm = null;
|
||||
}
|
||||
|
||||
function winRate(deck) {
|
||||
function winRate(deck: any) {
|
||||
if (deck.times_played === 0) return null;
|
||||
return Math.round((deck.wins / deck.times_played) * 100);
|
||||
}
|
||||
@@ -160,12 +160,10 @@
|
||||
</main>
|
||||
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;700&family=Crimson+Text:ital,wght@0,400;0,600;1,400&display=swap');
|
||||
|
||||
main {
|
||||
height: 100vh;
|
||||
overflow-y: auto;
|
||||
background: #0d0a04;
|
||||
background: var(--color-bg);
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
@@ -174,29 +172,31 @@
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 2rem;
|
||||
border-bottom: 1px solid rgba(107, 76, 30, 0.3);
|
||||
border-bottom: 1px solid var(--color-border-dim);
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: #f0d080;
|
||||
font-size: var(--text-xl);
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-gold);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.new-btn {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 11px;
|
||||
font-size: var(--btn-font-md);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
background: #3d2507;
|
||||
border: 1px solid #c8861a;
|
||||
border-radius: 4px;
|
||||
color: #f0d080;
|
||||
padding: 6px 14px;
|
||||
background: var(--color-surface-raised);
|
||||
border: 1px solid var(--color-bronze);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-gold);
|
||||
padding: var(--btn-padding-md);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
@@ -210,16 +210,16 @@
|
||||
}
|
||||
|
||||
thead tr {
|
||||
border-bottom: 1px solid rgba(107, 76, 30, 0.5);
|
||||
border-bottom: 1px solid var(--color-border-subtle);
|
||||
}
|
||||
|
||||
th {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 10px;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(240, 180, 80, 0.4);
|
||||
color: var(--color-gold-faint);
|
||||
padding: 0 1rem 0.75rem 0;
|
||||
text-align: left;
|
||||
}
|
||||
@@ -237,62 +237,43 @@
|
||||
}
|
||||
|
||||
.deck-name {
|
||||
font-size: 17px;
|
||||
color: #e8d090;
|
||||
font-size: var(--text-lg);
|
||||
color: var(--color-gold);
|
||||
}
|
||||
|
||||
.deck-count {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 13px;
|
||||
font-size: var(--text-base);
|
||||
font-weight: 700;
|
||||
color: rgba(240, 180, 80, 0.6);
|
||||
color: var(--color-gold-dim);
|
||||
width: 60px;
|
||||
}
|
||||
|
||||
.deck-cost {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 13px;
|
||||
font-size: var(--text-base);
|
||||
font-weight: 700;
|
||||
color: #6aaa6a;
|
||||
color: var(--color-success);
|
||||
width: 60px;
|
||||
}
|
||||
|
||||
.deck-cost.over-budget { color: #c85050; }
|
||||
.deck-cost.over-budget { color: var(--color-error); }
|
||||
|
||||
.deck-type { width: 90px; }
|
||||
|
||||
.type-badge {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
padding: 3px 8px;
|
||||
border-radius: 3px;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.type-wall { background: rgba(58, 104, 120, 0.3); color: #a0d4e8; border: 1px solid rgba(58, 104, 120, 0.5); }
|
||||
.type-aggro { background: rgba(139, 32, 32, 0.3); color: #e89090; border: 1px solid rgba(139, 32, 32, 0.5); }
|
||||
.type-god-card { background: rgba(184, 120, 32, 0.3); color: #f5d880; border: 1px solid rgba(184, 120, 32, 0.5); }
|
||||
.type-rush { background: rgba(74, 122, 80, 0.3); color: #a8dca8; border: 1px solid rgba(74, 122, 80, 0.5); }
|
||||
.type-control { background: rgba(122, 80, 144, 0.3); color: #d0a0e8; border: 1px solid rgba(122, 80, 144, 0.5); }
|
||||
.type-unplayable { background: rgba(60, 60, 60, 0.3); color: #909090; border: 1px solid rgba(60, 60, 60, 0.5); }
|
||||
.type-pantheon { background: rgba(184, 150, 60, 0.3); color: #fce8a0; border: 1px solid rgba(184, 150, 60, 0.5); }
|
||||
.type-balanced { background: rgba(106, 104, 96, 0.3); color: #c8c6c0; border: 1px solid rgba(106, 104, 96, 0.5); }
|
||||
.deck-type { white-space: nowrap; }
|
||||
|
||||
.deck-stat {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 13px;
|
||||
color: rgba(240, 180, 80, 0.6);
|
||||
font-size: var(--text-base);
|
||||
color: var(--color-gold-dim);
|
||||
width: 60px;
|
||||
}
|
||||
|
||||
.wins { color: #6aaa6a; }
|
||||
.losses { color: #c85050; }
|
||||
.wins { color: var(--color-success); }
|
||||
.losses { color: var(--color-error); }
|
||||
.separator { color: rgba(240, 180, 80, 0.3); }
|
||||
|
||||
.good-wr { color: #6aaa6a; }
|
||||
.bad-wr { color: #c85050; }
|
||||
.good-wr { color: var(--color-success); }
|
||||
.bad-wr { color: var(--color-error); }
|
||||
|
||||
.no-data {
|
||||
color: rgba(240, 180, 80, 0.2);
|
||||
@@ -306,25 +287,25 @@
|
||||
|
||||
.edit-btn, .delete-btn {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 10px;
|
||||
font-size: var(--btn-font-sm);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
border-radius: 4px;
|
||||
padding: 4px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: var(--btn-padding-sm);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.edit-btn {
|
||||
background: none;
|
||||
border: 1px solid rgba(107, 76, 30, 0.5);
|
||||
color: rgba(240, 180, 80, 0.7);
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
color: var(--color-gold-dim);
|
||||
}
|
||||
|
||||
.edit-btn:hover {
|
||||
border-color: #c8861a;
|
||||
color: #f0d080;
|
||||
border-color: var(--color-bronze);
|
||||
color: var(--color-gold);
|
||||
}
|
||||
|
||||
.delete-btn {
|
||||
@@ -334,13 +315,13 @@
|
||||
}
|
||||
|
||||
.delete-btn:hover {
|
||||
border-color: #c84040;
|
||||
color: #e05050;
|
||||
border-color: var(--color-error);
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.status {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 16px;
|
||||
font-size: var(--text-md);
|
||||
font-style: italic;
|
||||
color: rgba(240, 180, 80, 0.5);
|
||||
text-align: center;
|
||||
@@ -354,13 +335,13 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 100;
|
||||
z-index: var(--z-header);
|
||||
}
|
||||
|
||||
.popup {
|
||||
background: #1a1008;
|
||||
border: 1px solid #6b4c1e;
|
||||
border-radius: 10px;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 2rem;
|
||||
max-width: 400px;
|
||||
width: calc(100% - 2rem);
|
||||
@@ -371,22 +352,22 @@
|
||||
|
||||
.popup-title {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 18px;
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 700;
|
||||
color: #f0d080;
|
||||
color: var(--color-gold);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.popup-body {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 15px;
|
||||
color: rgba(240, 180, 80, 0.7);
|
||||
font-size: var(--text-md);
|
||||
color: var(--color-gold-dim);
|
||||
margin: 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.popup-body strong {
|
||||
color: #f0d080;
|
||||
color: var(--color-gold);
|
||||
}
|
||||
|
||||
.popup-actions {
|
||||
@@ -397,52 +378,52 @@
|
||||
|
||||
.popup-cancel {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 11px;
|
||||
font-size: var(--btn-font-md);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
background: none;
|
||||
border: 1px solid rgba(107, 76, 30, 0.4);
|
||||
border-radius: 4px;
|
||||
color: rgba(240, 180, 80, 0.6);
|
||||
padding: 7px 16px;
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-gold-dim);
|
||||
padding: var(--btn-padding-md);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.popup-cancel:hover {
|
||||
border-color: #c8861a;
|
||||
color: #f0d080;
|
||||
border-color: var(--color-bronze);
|
||||
color: var(--color-gold);
|
||||
}
|
||||
|
||||
.popup-confirm {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 11px;
|
||||
font-size: var(--btn-font-md);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
background: #c8861a;
|
||||
background: var(--color-bronze);
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: #fff8e0;
|
||||
padding: 7px 16px;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-btn-text);
|
||||
padding: var(--btn-padding-md);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.popup-confirm:hover { background: #e09820; }
|
||||
.popup-confirm:hover { background: var(--color-bronze-hover); }
|
||||
|
||||
.popup-delete {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 11px;
|
||||
font-size: var(--btn-font-md);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
background: rgba(180, 40, 40, 0.8);
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
border-radius: var(--radius-sm);
|
||||
color: #fff;
|
||||
padding: 7px 16px;
|
||||
padding: var(--btn-padding-md);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import { API_URL } from '$lib/api.js';
|
||||
import { apiFetch } from '$lib/api.js';
|
||||
import { goto } from '$app/navigation';
|
||||
@@ -9,17 +9,18 @@
|
||||
const deckId = $derived($page.params.id);
|
||||
const token = () => localStorage.getItem('token');
|
||||
|
||||
let allCards = $state([]);
|
||||
let selectedIds = $state(new Set());
|
||||
let selectedCost = $state(0);
|
||||
let costMap: Map<string, number> = $state(new Map());
|
||||
let deckName = $state('');
|
||||
let editingName = $state(false);
|
||||
let nameInput = $state('');
|
||||
let loading = $state(true);
|
||||
let saving = $state(false);
|
||||
let nameError = $state('');
|
||||
let saveError = $state('');
|
||||
|
||||
const selectedCost = $derived(
|
||||
allCards.filter(c => selectedIds.has(c.id)).reduce((sum, c) => sum + c.cost, 0)
|
||||
);
|
||||
const MAX_NAME = 64;
|
||||
|
||||
function startEditName() {
|
||||
nameInput = deckName;
|
||||
@@ -27,13 +28,20 @@
|
||||
}
|
||||
|
||||
function commitName() {
|
||||
if (nameInput.trim()) deckName = nameInput.trim();
|
||||
const trimmed = nameInput.trim();
|
||||
if (trimmed && trimmed.length <= MAX_NAME) {
|
||||
deckName = trimmed;
|
||||
nameError = '';
|
||||
} else if (trimmed.length > MAX_NAME) {
|
||||
nameError = `Name must be ${MAX_NAME} characters or fewer`;
|
||||
}
|
||||
editingName = false;
|
||||
}
|
||||
|
||||
async function save() {
|
||||
saving = true;
|
||||
await apiFetch(`${API_URL}/decks/${deckId}`, {
|
||||
saveError = '';
|
||||
const res = await apiFetch(`${API_URL}/decks/${deckId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
@@ -42,26 +50,29 @@
|
||||
}),
|
||||
});
|
||||
saving = false;
|
||||
if (!res.ok) {
|
||||
saveError = 'Failed to save deck. Please try again.';
|
||||
return;
|
||||
}
|
||||
goto('/decks');
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
if (!token()) { goto('/auth'); return; }
|
||||
|
||||
const [cardsRes, deckCardsRes] = await Promise.all([
|
||||
apiFetch(`${API_URL}/cards`),
|
||||
const [deckCardsRes, decksRes] = await Promise.all([
|
||||
apiFetch(`${API_URL}/decks/${deckId}/cards`),
|
||||
apiFetch(`${API_URL}/decks`),
|
||||
]);
|
||||
|
||||
if (cardsRes.status === 401) { goto('/auth'); return; }
|
||||
if (deckCardsRes.status === 401) { goto('/auth'); return; }
|
||||
|
||||
allCards = await cardsRes.json();
|
||||
const currentCardIds = await deckCardsRes.json();
|
||||
selectedIds = new Set(currentCardIds);
|
||||
const deckCards = await deckCardsRes.json();
|
||||
selectedIds = new Set(deckCards.map((c: any) => c.id));
|
||||
costMap = new Map(deckCards.map((c: any) => [c.id, c.cost]));
|
||||
|
||||
const decksRes = await apiFetch(`${API_URL}/decks`);
|
||||
const decks = await decksRes.json();
|
||||
const deck = decks.find(d => d.id === deckId);
|
||||
const deck = decks.find((d: any) => d.id === deckId);
|
||||
deckName = deck?.name ?? 'Untitled Deck';
|
||||
|
||||
loading = false;
|
||||
@@ -71,25 +82,31 @@
|
||||
<main>
|
||||
<div class="toolbar">
|
||||
<div class="deck-header">
|
||||
{#if editingName}
|
||||
<input
|
||||
class="name-input"
|
||||
bind:value={nameInput}
|
||||
onblur={commitName}
|
||||
onkeydown={e => e.key === 'Enter' && commitName()}
|
||||
autofocus
|
||||
/>
|
||||
{:else}
|
||||
<button class="name-btn" onclick={startEditName}>{deckName} ✎</button>
|
||||
{/if}
|
||||
<div class="name-area">
|
||||
{#if editingName}
|
||||
<input
|
||||
class="name-input"
|
||||
bind:value={nameInput}
|
||||
onblur={commitName}
|
||||
onkeydown={e => e.key === 'Enter' && commitName()}
|
||||
autofocus
|
||||
/>
|
||||
{:else}
|
||||
<button class="name-btn" onclick={startEditName}>{deckName} ✎</button>
|
||||
{/if}
|
||||
<p class="field-error">{nameError}</p>
|
||||
</div>
|
||||
|
||||
<div class="header-right">
|
||||
<span class="card-counter" class:full={selectedCost === 50} class:over={selectedCost > 50} class:empty={selectedIds.size === 0}>
|
||||
{selectedIds.size} cards · {selectedCost}/50
|
||||
</span>
|
||||
<button class="done-btn" onclick={save} disabled={saving}>
|
||||
{saving ? 'Saving...' : 'Done'}
|
||||
</button>
|
||||
<div class="save-area">
|
||||
<button class="done-btn" onclick={save} disabled={saving}>
|
||||
{saving ? 'Saving...' : 'Done'}
|
||||
</button>
|
||||
<p class="field-error">{saveError}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -98,8 +115,9 @@
|
||||
<p class="status">Loading...</p>
|
||||
{:else}
|
||||
<CardSelector
|
||||
allCards={allCards}
|
||||
bind:selectedIds={selectedIds}
|
||||
bind:selectedCost={selectedCost}
|
||||
bind:costMap={costMap}
|
||||
costLimit={50}
|
||||
showFooter={false}
|
||||
/>
|
||||
@@ -107,21 +125,19 @@
|
||||
</main>
|
||||
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;700&family=Crimson+Text:ital,wght@0,400;0,600;1,400&display=swap');
|
||||
|
||||
main {
|
||||
height: 100vh;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: #0d0a04;
|
||||
background: var(--color-bg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
flex-shrink: 0;
|
||||
background: #0d0a04;
|
||||
background: var(--color-bg);
|
||||
padding: 1.5rem 2rem 1rem;
|
||||
border-bottom: 1px solid rgba(107, 76, 30, 0.3);
|
||||
border-bottom: 1px solid var(--color-border-dim);
|
||||
}
|
||||
|
||||
.deck-header {
|
||||
@@ -133,9 +149,9 @@
|
||||
|
||||
.name-btn {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 18px;
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 700;
|
||||
color: #f0d080;
|
||||
color: var(--color-gold);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
@@ -148,12 +164,12 @@
|
||||
|
||||
.name-input {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 18px;
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 700;
|
||||
color: #f0d080;
|
||||
color: var(--color-gold);
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 1.5px solid #c8861a;
|
||||
border-bottom: 1.5px solid var(--color-bronze);
|
||||
outline: none;
|
||||
padding: 0 0 2px 0;
|
||||
min-width: 200px;
|
||||
@@ -167,27 +183,27 @@
|
||||
|
||||
.card-counter {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 14px;
|
||||
font-size: var(--text-base);
|
||||
font-weight: 700;
|
||||
color: rgba(240, 180, 80, 0.7);
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.card-counter.full { color: #6aaa6a; }
|
||||
.card-counter.over { color: #c85050; }
|
||||
.card-counter.full { color: var(--color-success); }
|
||||
.card-counter.over { color: var(--color-error); }
|
||||
.card-counter.empty { color: rgba(240, 180, 80, 0.3); }
|
||||
|
||||
.done-btn {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 11px;
|
||||
font-size: var(--btn-font-md);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
background: #3d2507;
|
||||
border: 1px solid #c8861a;
|
||||
border-radius: 4px;
|
||||
color: #f0d080;
|
||||
padding: 6px 16px;
|
||||
background: var(--color-surface-raised);
|
||||
border: 1px solid var(--color-bronze);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-gold);
|
||||
padding: var(--btn-padding-md);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
@@ -195,9 +211,30 @@
|
||||
.done-btn:hover:not(:disabled) { background: #5a3510; }
|
||||
.done-btn:disabled { opacity: 0.5; cursor: default; }
|
||||
|
||||
.name-area {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.save-area {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.field-error {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-error);
|
||||
margin: 0;
|
||||
min-height: 1em;
|
||||
}
|
||||
|
||||
.status {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 16px;
|
||||
font-size: var(--text-md);
|
||||
font-style: italic;
|
||||
color: rgba(240, 180, 80, 0.5);
|
||||
text-align: center;
|
||||
|
||||
@@ -48,11 +48,9 @@
|
||||
</main>
|
||||
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;700&family=Crimson+Text:ital,wght@0,400;0,600;1,400&display=swap');
|
||||
|
||||
main {
|
||||
min-height: 100vh;
|
||||
background: #0d0a04;
|
||||
background: var(--color-bg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -61,9 +59,9 @@
|
||||
|
||||
.card {
|
||||
width: 340px;
|
||||
background: #2e1c05;
|
||||
border: 2px solid #6b4c1e;
|
||||
border-radius: 12px;
|
||||
background: var(--color-surface);
|
||||
border: 2px solid var(--color-bronze);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: 2rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -72,16 +70,16 @@
|
||||
|
||||
.title {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 20px;
|
||||
color: #f0d080;
|
||||
font-size: var(--text-xl);
|
||||
color: var(--color-gold);
|
||||
text-align: center;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 15px;
|
||||
color: rgba(245, 208, 96, 0.7);
|
||||
font-size: var(--text-md);
|
||||
color: var(--color-gold-dim);
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
line-height: 1.6;
|
||||
@@ -90,38 +88,41 @@
|
||||
input {
|
||||
width: 100%;
|
||||
padding: 9px 12px;
|
||||
background: #1a1008;
|
||||
border: 1.5px solid #8b6420;
|
||||
border-radius: 6px;
|
||||
color: #f0d080;
|
||||
background: var(--color-surface);
|
||||
border: 1.5px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-gold);
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 15px;
|
||||
font-size: var(--text-md);
|
||||
box-sizing: border-box;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
input::placeholder {
|
||||
color: rgba(240, 180, 80, 0.4);
|
||||
color: var(--color-gold-faint);
|
||||
}
|
||||
|
||||
input:focus { border-color: #f5d060; }
|
||||
input:focus { border-color: var(--color-bronze); }
|
||||
|
||||
|
||||
button {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
background: #6b4c1e;
|
||||
color: #f0d080;
|
||||
border: 1.5px solid #8b6420;
|
||||
border-radius: 6px;
|
||||
padding: var(--btn-padding-lg);
|
||||
background: var(--color-bronze);
|
||||
color: var(--color-btn-text);
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 13px;
|
||||
font-size: var(--btn-font-lg);
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
button:hover:not(:disabled) {
|
||||
background: #8b6420;
|
||||
background: var(--color-bronze-hover);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
@@ -131,19 +132,19 @@
|
||||
|
||||
.back-link {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 14px;
|
||||
color: rgba(245, 208, 96, 0.5);
|
||||
font-size: var(--text-base);
|
||||
color: var(--color-gold-faint);
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.back-link:hover { color: #f5d060; }
|
||||
.back-link:hover { color: var(--color-gold); }
|
||||
|
||||
.error {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 14px;
|
||||
color: #f06060;
|
||||
font-size: var(--text-base);
|
||||
color: var(--color-error);
|
||||
margin: 0;
|
||||
min-height: 1.4em;
|
||||
text-align: center;
|
||||
|
||||
@@ -71,11 +71,9 @@
|
||||
</main>
|
||||
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;700&family=Crimson+Text:ital,wght@0,400;0,600;1,400&display=swap');
|
||||
|
||||
main {
|
||||
min-height: 100vh;
|
||||
background: #0d0a04;
|
||||
background: var(--color-bg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -84,9 +82,9 @@
|
||||
|
||||
.card {
|
||||
width: 380px;
|
||||
background: #3d2507;
|
||||
border: 2px solid #c8861a;
|
||||
border-radius: 12px;
|
||||
background: var(--color-surface);
|
||||
border: 2px solid var(--color-bronze);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: 2rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -95,17 +93,17 @@
|
||||
|
||||
.title {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 20px;
|
||||
font-size: var(--text-xl);
|
||||
font-weight: 700;
|
||||
color: #f5d060;
|
||||
color: var(--color-gold);
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 15px;
|
||||
color: rgba(245, 208, 96, 0.7);
|
||||
font-size: var(--text-md);
|
||||
color: var(--color-gold-dim);
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
line-height: 1.6;
|
||||
@@ -119,52 +117,54 @@
|
||||
|
||||
.field-label {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 10px;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(245, 208, 96, 0.5);
|
||||
color: var(--color-gold-faint);
|
||||
}
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
padding: 9px 12px;
|
||||
background: #221508;
|
||||
border: 1.5px solid #c8861a;
|
||||
border-radius: 6px;
|
||||
color: #f5d060;
|
||||
background: var(--color-surface);
|
||||
border: 1.5px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-gold);
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 15px;
|
||||
font-size: var(--text-md);
|
||||
box-sizing: border-box;
|
||||
outline: none;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
input:focus { border-color: #f5d060; }
|
||||
input::placeholder { color: rgba(245, 208, 96, 0.35); }
|
||||
input:focus { border-color: var(--color-bronze); }
|
||||
input::placeholder { color: var(--color-gold-faint); }
|
||||
|
||||
.btn {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
background: #c8861a;
|
||||
color: #fff8e0;
|
||||
padding: var(--btn-padding-lg);
|
||||
background: var(--color-bronze);
|
||||
color: var(--color-btn-text);
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
border-radius: var(--radius-md);
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 13px;
|
||||
font-size: var(--btn-font-lg);
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.btn:hover:not(:disabled) { background: #e09820; }
|
||||
.btn:hover:not(:disabled) { background: var(--color-bronze-hover); }
|
||||
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
.error {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 14px;
|
||||
color: #f06060;
|
||||
font-size: var(--text-base);
|
||||
color: var(--color-error);
|
||||
margin: 0;
|
||||
min-height: 1.4em;
|
||||
text-align: center;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import Card from '$lib/Card.svelte';
|
||||
|
||||
// A fake card for display purposes
|
||||
const exampleCard = {
|
||||
@@ -12,7 +12,7 @@
|
||||
attack: 351,
|
||||
defense: 222,
|
||||
cost: 5,
|
||||
created_at: new Date().toISOString(),
|
||||
generated_at: new Date().toISOString(),
|
||||
reported: false,
|
||||
};
|
||||
|
||||
@@ -48,34 +48,7 @@
|
||||
<div class="card-explainer">
|
||||
<div class="card-annotated">
|
||||
<div class="card-display">
|
||||
<!-- Inline card rendering matching Card.svelte visuals -->
|
||||
<div class="demo-card">
|
||||
<div class="demo-inner" style="--bg: #f0e0c8; --header: #b87830">
|
||||
<div class="demo-header">
|
||||
<span class="demo-name">{exampleCard.name}</span>
|
||||
<span class="demo-type-badge">Person</span>
|
||||
</div>
|
||||
<div class="demo-image-wrap">
|
||||
<img src={exampleCard.image_link} alt={exampleCard.name} class="demo-image" />
|
||||
<div class="demo-rarity" style="background: #2a5a9b; color: #fff">R</div>
|
||||
<a href="https://en.wikipedia.org/wiki/Harald_Bluetooth" target="_blank" rel="noopener" class="demo-wiki">
|
||||
<svg viewBox="0 0 50 50" width="14" height="14"><circle cx="25" cy="25" r="24" fill="white" stroke="#888" stroke-width="1"/><text x="25" y="33" text-anchor="middle" font-family="serif" font-size="28" font-weight="bold" fill="#000">W</text></svg>
|
||||
</a>
|
||||
<div class="demo-cost-bubbles">
|
||||
{#each { length: exampleCard.cost } as _}
|
||||
<div class="demo-cost-bubble">✦</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<div class="demo-divider"></div>
|
||||
<div class="demo-text">{exampleCard.text}</div>
|
||||
<div class="demo-footer" style="background: #e8d8b8">
|
||||
<span class="demo-stat">ATK <strong>{exampleCard.attack}</strong></span>
|
||||
<span class="demo-date">{new Date(exampleCard.created_at).toLocaleDateString()}</span>
|
||||
<span class="demo-stat">DEF <strong>{exampleCard.defense}</strong></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Card card={exampleCard} noHover={true} />
|
||||
</div>
|
||||
|
||||
<!-- Annotation markers -->
|
||||
@@ -195,12 +168,10 @@
|
||||
</main>
|
||||
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;700&family=Crimson+Text:ital,wght@0,400;0,600;1,400&display=swap');
|
||||
|
||||
main {
|
||||
height: 100vh;
|
||||
overflow-y: auto;
|
||||
background: #0d0a04;
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
.content {
|
||||
@@ -211,11 +182,12 @@
|
||||
|
||||
.page-title {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: #f0d080;
|
||||
font-size: clamp(22px, 4vw, 32px);
|
||||
font-weight: 900;
|
||||
color: var(--color-gold);
|
||||
margin: 0 0 2rem;
|
||||
letter-spacing: 0.08em;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.section {
|
||||
@@ -224,14 +196,14 @@
|
||||
|
||||
.section-title {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 14px;
|
||||
font-size: var(--text-base);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: #f0d080AA;
|
||||
color: var(--color-gold-dim);
|
||||
margin: 0 0 1.25rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px solid #f0d08055;
|
||||
border-bottom: 1px solid var(--color-gold-faint);
|
||||
}
|
||||
|
||||
.body-text ul {
|
||||
@@ -240,8 +212,8 @@
|
||||
|
||||
.body-text {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 17px;
|
||||
color: rgba(240, 180, 80, 0.75);
|
||||
font-size: var(--text-lg);
|
||||
color: var(--color-gold-muted);
|
||||
line-height: 1.7;
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
@@ -272,23 +244,23 @@
|
||||
.marker {
|
||||
position: absolute;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 10;
|
||||
z-index: var(--z-card);
|
||||
}
|
||||
|
||||
.marker-bubble {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
background: #c8861a;
|
||||
border: 2px solid #fff;
|
||||
color: #fff;
|
||||
background: var(--color-bronze);
|
||||
border: 2px solid var(--color-btn-text);
|
||||
color: var(--color-btn-text);
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 11px;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.6);
|
||||
box-shadow: var(--shadow-subtle);
|
||||
}
|
||||
|
||||
/* ── Annotation list ── */
|
||||
@@ -313,10 +285,10 @@
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
background: #c8861a;
|
||||
color: #fff;
|
||||
background: var(--color-bronze);
|
||||
color: var(--color-btn-text);
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 11px;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -333,16 +305,16 @@
|
||||
|
||||
.annotation-label {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 12px;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 700;
|
||||
color: #f0d080;
|
||||
color: var(--color-gold);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.annotation-desc {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 14px;
|
||||
color: rgba(240, 180, 80, 0.6);
|
||||
font-size: var(--text-base);
|
||||
color: var(--color-gold-dim);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
@@ -350,13 +322,13 @@
|
||||
.rules-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 12px;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.rule-card {
|
||||
background: #1a1008;
|
||||
border: 1px solid rgba(107, 76, 30, 0.3);
|
||||
border-radius: 8px;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border-dim);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 1.25rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -364,198 +336,27 @@
|
||||
}
|
||||
|
||||
.rule-icon {
|
||||
color: #f0d080AA;
|
||||
font-size: 20px;
|
||||
color: var(--color-gold-dim);
|
||||
font-size: var(--text-xl);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.rule-title {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 12px;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
color: #f0d080;
|
||||
color: var(--color-gold);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.rule-body {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 14px;
|
||||
color: rgba(240, 180, 80, 0.6);
|
||||
font-size: var(--text-base);
|
||||
color: var(--color-gold-dim);
|
||||
line-height: 1.55;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ── Demo card ── */
|
||||
.demo-card {
|
||||
width: 300px;
|
||||
border-radius: 12px;
|
||||
padding: 7px;
|
||||
background: #111;
|
||||
border: 2px solid #111;
|
||||
box-shadow: 0 4px 24px rgba(0,0,0,0.5);
|
||||
font-family: 'Crimson Text', serif;
|
||||
position: relative;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.demo-inner {
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: var(--bg);
|
||||
border: 2px solid #000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.demo-header {
|
||||
padding: 9px 12px 7px;
|
||||
background: var(--header);
|
||||
border-bottom: 2px solid #000;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.demo-name {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
text-shadow: 0 1px 3px rgba(0,0,0,0.6);
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.demo-type-badge {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 9px;
|
||||
color: rgba(255,255,255,0.95);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
background: rgba(0,0,0,0.25);
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.demo-image-wrap {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 4/3;
|
||||
overflow: hidden;
|
||||
border-bottom: 2px solid #000;
|
||||
}
|
||||
|
||||
.demo-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
object-position: top;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.demo-rarity {
|
||||
position: absolute;
|
||||
top: 7px;
|
||||
left: 7px;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 50%;
|
||||
border: 2.5px solid #000;
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.demo-wiki {
|
||||
position: absolute;
|
||||
top: 7px;
|
||||
right: 7px;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255,255,255,0.92);
|
||||
border: 1.5px solid #000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.demo-cost-bubbles {
|
||||
position: absolute;
|
||||
bottom: 6px;
|
||||
left: 8px;
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
flex-wrap: wrap;
|
||||
max-width: calc(100% - 16px);
|
||||
}
|
||||
|
||||
.demo-cost-bubble {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: #6ea0ec;
|
||||
border: 2.5px solid #000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #08152c;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
font-family: 'Cinzel', serif;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.demo-divider {
|
||||
height: 2px;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.demo-text {
|
||||
padding: 10px 12px;
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
color: #1a1208;
|
||||
font-style: italic;
|
||||
background: #f0e6cc;
|
||||
border-bottom: 2px solid #000;
|
||||
height: 110px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.demo-footer {
|
||||
padding: 7px 12px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.demo-stat {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 11px;
|
||||
color: #2a2010;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.demo-stat strong {
|
||||
color: #000;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.demo-date {
|
||||
font-size: 10px;
|
||||
color: rgba(0,0,0,0.5);
|
||||
font-style: italic;
|
||||
font-family: 'Crimson Text', serif;
|
||||
}
|
||||
</style>
|
||||
@@ -1,24 +1,30 @@
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import { API_URL, WS_URL } from '$lib/api.js';
|
||||
import { apiFetch } from '$lib/api.js';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import { get } from 'svelte/store';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import Card from '$lib/Card.svelte';
|
||||
import DeckTypeBadge from '$lib/DeckTypeBadge.svelte';
|
||||
import { play } from '$lib/audio.js';
|
||||
|
||||
const token = () => localStorage.getItem('token');
|
||||
|
||||
let queueWs = null;
|
||||
let gameWs = null;
|
||||
let queueWs: WebSocket | null = null;
|
||||
let gameWs: WebSocket | null = null;
|
||||
let phase = $state('idle');
|
||||
let error = $state('');
|
||||
let reconnecting = $state(false);
|
||||
let gameReconnectDelay = 1000;
|
||||
let gameReconnectTimer: number | undefined = undefined;
|
||||
|
||||
let decks = $state([]);
|
||||
let decks: any[] = $state([]);
|
||||
let selectedDeckId = $state('');
|
||||
let selectedDeck = $derived(decks.find(d => d.id === selectedDeckId));
|
||||
|
||||
let gameId = $state('');
|
||||
let gameState = $state(null);
|
||||
let gameState: any = $state(null);
|
||||
let myId = $state('');
|
||||
|
||||
let viewingBoard = $state(false);
|
||||
@@ -34,7 +40,7 @@
|
||||
'Expert'
|
||||
);
|
||||
|
||||
let selectedHandIndex = $state(null);
|
||||
let selectedHandIndex: number | null = $state(null);
|
||||
let combatAnimating = $state(false);
|
||||
let lunging = $state(new Set());
|
||||
let lungingDown = $state(new Set());
|
||||
@@ -46,24 +52,25 @@
|
||||
let gameOver = $derived(!!gameState?.result);
|
||||
let sacrificeMode = $state(false);
|
||||
|
||||
let displayedDefense = $state({});
|
||||
let displayedDefense: Record<string, number> = $state({});
|
||||
let destroying = $state(new Set());
|
||||
let destroyed = $state(new Set());
|
||||
let displayedLife = $state({});
|
||||
let displayedLife: Record<string, number> = $state({});
|
||||
|
||||
const TURN_TIME_LIMIT = 120; // seconds
|
||||
const TIMER_WARNING = 30; // show timer when this many seconds remain
|
||||
|
||||
let turnStartedAt = $state(null);
|
||||
let turnStartedAt: Date | null = $state(null);
|
||||
let secondsRemaining = $state(TURN_TIME_LIMIT);
|
||||
let timerInterval = null
|
||||
let timerInterval: number | undefined = undefined;
|
||||
|
||||
$effect(() => {
|
||||
if (!gameState?.turn_started_at) return;
|
||||
turnStartedAt = new Date(gameState.turn_started_at);
|
||||
const ts = new Date(gameState.turn_started_at);
|
||||
turnStartedAt = ts;
|
||||
clearInterval(timerInterval);
|
||||
timerInterval = setInterval(async () => {
|
||||
const elapsed = (Date.now() - turnStartedAt) / 1000;
|
||||
const elapsed = (Date.now() - ts.getTime()) / 1000;
|
||||
secondsRemaining = Math.max(0, TURN_TIME_LIMIT - elapsed);
|
||||
|
||||
if (secondsRemaining <= 0 && !isMyTurn && gameState && !gameState.result) {
|
||||
@@ -75,6 +82,7 @@
|
||||
|
||||
onDestroy(() => {
|
||||
clearInterval(timerInterval);
|
||||
clearTimeout(gameReconnectTimer);
|
||||
});
|
||||
|
||||
async function claimTimeoutWin() {
|
||||
@@ -103,7 +111,7 @@
|
||||
...(gameState.you.board.filter(Boolean) || []),
|
||||
...(gameState.opponent.board.filter(Boolean) || []),
|
||||
];
|
||||
const next = {};
|
||||
const next: Record<string, number> = {};
|
||||
for (const card of all) next[card.instance_id] = card.defense;
|
||||
displayedDefense = next;
|
||||
});
|
||||
@@ -115,6 +123,21 @@
|
||||
|
||||
onMount(async () => {
|
||||
if (!token()) { goto('/auth'); return; }
|
||||
|
||||
// Support joining a direct challenge game via ?game_id=... query param
|
||||
const challengeGameId = get(page).url.searchParams.get('game_id');
|
||||
if (challengeGameId) {
|
||||
gameId = challengeGameId;
|
||||
phase = 'playing';
|
||||
connectToGame();
|
||||
// Load decks in the background so the lobby is ready if the connection fails
|
||||
apiFetch(`${API_URL}/decks`).then(r => r.json()).then(data => {
|
||||
decks = data;
|
||||
if (decks.length > 0) selectedDeckId = decks[0].id;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await apiFetch(`${API_URL}/decks`);
|
||||
decks = await res.json();
|
||||
if (decks.length > 0) selectedDeckId = decks[0].id;
|
||||
@@ -130,12 +153,12 @@
|
||||
error = '';
|
||||
phase = 'queuing';
|
||||
queueWs = new WebSocket(`${WS_URL}/ws/queue?deck_id=${selectedDeckId}`);
|
||||
queueWs.onopen = () => queueWs.send(token());
|
||||
queueWs.onopen = () => queueWs!.send(token()!);
|
||||
queueWs.onmessage = (e) => {
|
||||
const msg = JSON.parse(e.data);
|
||||
if (msg.type === 'game_start') {
|
||||
gameId = msg.game_id;
|
||||
queueWs.close();
|
||||
queueWs!.close();
|
||||
connectToGame();
|
||||
} else if (msg.type === 'error') {
|
||||
error = msg.message;
|
||||
@@ -147,7 +170,11 @@
|
||||
|
||||
function connectToGame() {
|
||||
gameWs = new WebSocket(`${WS_URL}/ws/game/${gameId}`);
|
||||
gameWs.onopen = () => gameWs.send(token());
|
||||
gameWs.onopen = () => {
|
||||
gameWs!.send(token()!);
|
||||
reconnecting = false;
|
||||
gameReconnectDelay = 1000;
|
||||
};
|
||||
gameWs.onmessage = async (e) => {
|
||||
const msg = JSON.parse(e.data);
|
||||
if (msg.type === 'state') {
|
||||
@@ -165,6 +192,7 @@
|
||||
phase = newState.result ? 'ended' : 'playing';
|
||||
} else if (msg.type === 'sacrifice_animation') {
|
||||
const id = msg.instance_id;
|
||||
play('cardShatter');
|
||||
destroying = new Set([...destroying, id]);
|
||||
await delay(600);
|
||||
destroying = new Set([...destroying].filter(i => i !== id));
|
||||
@@ -174,10 +202,26 @@
|
||||
setTimeout(() => error = '', 3000);
|
||||
}
|
||||
};
|
||||
gameWs.onclose = (e) => {
|
||||
// 1008 = Policy Violation — server rejects unknown/expired game_id.
|
||||
// Fall back to the normal lobby and strip the stale query param.
|
||||
if (e.code === 1008 && phase !== 'ended') {
|
||||
phase = 'idle';
|
||||
history.replaceState({}, '', '/play');
|
||||
return;
|
||||
}
|
||||
if (phase === 'playing') {
|
||||
reconnecting = true;
|
||||
gameReconnectTimer = setTimeout(() => {
|
||||
gameReconnectDelay = Math.min(gameReconnectDelay * 2, 30000);
|
||||
connectToGame();
|
||||
}, gameReconnectDelay);
|
||||
}
|
||||
};
|
||||
gameWs.onerror = () => { error = 'Connection lost'; };
|
||||
}
|
||||
|
||||
async function animateCombat(newState) {
|
||||
async function animateCombat(newState: any) {
|
||||
combatAnimating = true;
|
||||
|
||||
// The attacker is whoever was active when end_turn was called.
|
||||
@@ -187,7 +231,7 @@
|
||||
// active_player_id hasn't switched yet.
|
||||
const attackerId = newState.result
|
||||
? newState.active_player_id
|
||||
: newState.player_order.find(id => id !== newState.active_player_id);
|
||||
: newState.player_order.find((id: string) => id !== newState.active_player_id);
|
||||
|
||||
const attackerIsMe = attackerId === myId;
|
||||
|
||||
@@ -206,7 +250,11 @@
|
||||
} else {
|
||||
lungingDown = new Set([...lungingDown, attacker.instance_id]);
|
||||
}
|
||||
if (defender) shaking = new Set([...shaking, defender.instance_id]);
|
||||
play('attack');
|
||||
if (defender) {
|
||||
shaking = new Set([...shaking, defender.instance_id]);
|
||||
play('defend');
|
||||
}
|
||||
await delay(220);
|
||||
if (defender) {
|
||||
const newDefense = Math.max(0, (displayedDefense[defender.instance_id] ?? defender.defense) - attacker.attack);
|
||||
@@ -222,6 +270,7 @@
|
||||
lungingDown = new Set([...lungingDown].filter(id => id !== attacker.instance_id));
|
||||
if (defender) shaking = new Set([...shaking].filter(id => id !== defender.instance_id));
|
||||
if (defender && (displayedDefense[defender.instance_id] ?? defender.defense) <= 0) {
|
||||
play('cardShatter');
|
||||
destroying = new Set([...destroying, defender.instance_id]);
|
||||
await delay(600);
|
||||
destroying = new Set([...destroying].filter(id => id !== defender.instance_id));
|
||||
@@ -234,25 +283,27 @@
|
||||
combatAnimating = false;
|
||||
}
|
||||
|
||||
function delay(ms) { return new Promise(r => setTimeout(r, ms)); }
|
||||
function delay(ms: number) { return new Promise(r => setTimeout(r, ms)); }
|
||||
|
||||
function send(msg) { gameWs?.send(JSON.stringify(msg)); }
|
||||
function send(msg: unknown) { gameWs?.send(JSON.stringify(msg)); }
|
||||
|
||||
function selectHandCard(index) {
|
||||
function selectHandCard(index: number) {
|
||||
if (!isMyTurn || combatAnimating) return;
|
||||
selectedHandIndex = selectedHandIndex === index ? null : index;
|
||||
}
|
||||
|
||||
function clickSlot(slot) {
|
||||
function clickSlot(slot: number) {
|
||||
if (!isMyTurn || combatAnimating || selectedHandIndex === null) return;
|
||||
play('cardPlay');
|
||||
send({ type: 'play_card', hand_index: selectedHandIndex, slot });
|
||||
selectedHandIndex = null;
|
||||
}
|
||||
|
||||
async function sacrifice(slot) {
|
||||
async function sacrifice(slot: number) {
|
||||
if (!isMyTurn || combatAnimating) return;
|
||||
const card = me.board[slot];
|
||||
if (!card) return;
|
||||
play('cardShatter');
|
||||
destroying = new Set([...destroying, card.instance_id]);
|
||||
await delay(600);
|
||||
destroying = new Set([...destroying].filter(id => id !== card.instance_id));
|
||||
@@ -267,7 +318,7 @@
|
||||
send({ type: 'end_turn' });
|
||||
}
|
||||
|
||||
function handleHandCardMouseMove(e, node) {
|
||||
function handleHandCardMouseMove(e: MouseEvent, node: HTMLElement) {
|
||||
const rect = node.getBoundingClientRect();
|
||||
const cy = rect.top + rect.height / 2;
|
||||
const dy = (e.clientY - cy) / (rect.height / 2);
|
||||
@@ -275,7 +326,7 @@
|
||||
node.style.setProperty('--peek-y', `${ty}px`);
|
||||
}
|
||||
|
||||
function handleHandCardMouseLeave(node) {
|
||||
function handleHandCardMouseLeave(node: HTMLElement) {
|
||||
node.style.setProperty('--peek-y', '0px');
|
||||
}
|
||||
|
||||
@@ -337,10 +388,17 @@
|
||||
|
||||
{:else if (phase === 'playing' || (phase === 'ended' && viewingBoard)) && gameState}
|
||||
<div class="game">
|
||||
{#if reconnecting}
|
||||
<div class="reconnecting-banner">Reconnecting...</div>
|
||||
{/if}
|
||||
|
||||
<div class="sidebar left-sidebar">
|
||||
<div class="sidebar-section top-section">
|
||||
<div class="sidebar-name opp-name">{opp.username}</div>
|
||||
{#if opp.user_id === 'ai'}
|
||||
<span class="sidebar-name opp-name">{opp.username}</span>
|
||||
{:else}
|
||||
<a href="/profile/{opp.username}" target="_blank" class="sidebar-name opp-name opp-profile-link">{opp.username}</a>
|
||||
{/if}
|
||||
<DeckTypeBadge deckType={opp.deck_type} />
|
||||
<div class="sidebar-life">♥ {displayedLife[opp.user_id] ?? opp.life}</div>
|
||||
<div class="sidebar-deck">Deck: {opp.deck_size}</div>
|
||||
@@ -389,7 +447,7 @@
|
||||
|
||||
<div class="divider">
|
||||
<span class="turn-indicator" class:my-turn={isMyTurn}>
|
||||
{phase === 'ended' ? 'Game Ended' : isMyTurn ? 'Your turn' : `${opp.username}'s turn`}
|
||||
{#if phase === 'ended'}Game Ended{:else if isMyTurn}Your turn{:else}{opp.username}'s turn{/if}
|
||||
</span>
|
||||
{#if secondsRemaining <= TIMER_WARNING}
|
||||
<span class="turn-timer" class:urgent={secondsRemaining <= 10}>
|
||||
@@ -436,7 +494,7 @@
|
||||
|
||||
<div class="sidebar right-sidebar">
|
||||
{#if phase === 'ended'}
|
||||
<button class="end-turn-btn" onclick={() => viewingBoard = false}>Go Back</button>
|
||||
<button class="end-turn-btn" onclick={() => { viewingBoard = false; history.replaceState({}, '', '/play'); }}>Go Back</button>
|
||||
{:else if isMyTurn && !combatAnimating}
|
||||
<button class="end-turn-btn" onclick={endTurn}>End Turn</button>
|
||||
{/if}
|
||||
@@ -472,7 +530,7 @@
|
||||
<p class="lobby-hint">{gameState.result.reason}</p>
|
||||
<div class="lobby-buttons">
|
||||
<button class="play-btn" onclick={() => viewingBoard = true}>View Board</button>
|
||||
<button class="play-btn" onclick={() => { gameState = null; phase = 'idle'; }}>Go Back</button>
|
||||
<button class="play-btn" onclick={() => { gameState = null; phase = 'idle'; history.replaceState({}, '', '/play'); }}>Go Back</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -506,12 +564,10 @@
|
||||
</main>
|
||||
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;700;900&family=Crimson+Text:ital,wght@0,400;0,600;1,400&display=swap');
|
||||
|
||||
main {
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
overflow: hidden;
|
||||
background: #0d0a04;
|
||||
background: var(--color-bg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
@@ -529,21 +585,21 @@
|
||||
|
||||
.lobby-title {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 32px;
|
||||
font-size: var(--text-3xl);
|
||||
font-weight: 900;
|
||||
color: #f0d080;
|
||||
color: var(--color-gold);
|
||||
margin: 0;
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
|
||||
.lobby-title.win { color: #6aaa6a; }
|
||||
.lobby-title.lose { color: #c85050; }
|
||||
.lobby-title.win { color: var(--color-success); }
|
||||
.lobby-title.lose { color: var(--color-error); }
|
||||
|
||||
.how-to-play-link {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 14px;
|
||||
font-size: var(--text-base);
|
||||
font-style: italic;
|
||||
color: rgba(240, 180, 80, 0.4);
|
||||
color: var(--color-gold-faint);
|
||||
text-decoration: underline;
|
||||
transition: color 0.15s;
|
||||
margin-top: -1rem;
|
||||
@@ -553,24 +609,24 @@
|
||||
|
||||
.lobby-hint {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 16px;
|
||||
font-size: var(--text-md);
|
||||
font-style: italic;
|
||||
color: rgba(240, 180, 80, 0.6);
|
||||
color: var(--color-gold-dim);
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.lobby-hint a { color: #f0d080; }
|
||||
.lobby-hint a { color: var(--color-gold); }
|
||||
|
||||
.final-life {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 13px;
|
||||
font-size: var(--text-base);
|
||||
color: rgba(240, 180, 80, 0.5);
|
||||
}
|
||||
|
||||
.deck-label {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 11px;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
@@ -583,11 +639,11 @@
|
||||
|
||||
select {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 15px;
|
||||
color: #f0d080;
|
||||
background: #1a1008;
|
||||
border: 1.5px solid #6b4c1e;
|
||||
border-radius: 6px;
|
||||
font-size: var(--text-md);
|
||||
color: var(--color-gold);
|
||||
background: var(--color-surface);
|
||||
border: 1.5px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 8px 12px;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
@@ -596,27 +652,28 @@
|
||||
|
||||
.play-btn, .cancel-btn {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 13px;
|
||||
font-size: var(--btn-font-lg);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
padding: 10px 32px;
|
||||
border-radius: 6px;
|
||||
padding: var(--btn-padding-lg);
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.play-btn {
|
||||
background: #c8861a;
|
||||
color: #fff8e0;
|
||||
border: none;
|
||||
background: var(--color-surface-raised);
|
||||
color: var(--color-gold);
|
||||
border: 1.5px solid var(--color-bronze);
|
||||
}
|
||||
|
||||
.play-btn:hover:not(:disabled) { background: #e09820; }
|
||||
.play-btn:hover:not(:disabled) { background: #5a3510; }
|
||||
|
||||
.play-btn:disabled {
|
||||
background: #6b4c1e;
|
||||
color: rgba(255, 248, 224, 0.4);
|
||||
background: var(--color-surface-raised);
|
||||
color: rgba(240, 180, 80, 0.3);
|
||||
border-color: rgba(200, 134, 26, 0.3);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@@ -626,34 +683,35 @@
|
||||
}
|
||||
|
||||
.solo-btn {
|
||||
background: #2a3d20;
|
||||
border: 1px solid #5a8a40;
|
||||
color: #a8d880;
|
||||
background: none;
|
||||
border: 1px solid rgba(107, 76, 30, 0.5);
|
||||
color: rgba(240, 180, 80, 0.7);
|
||||
}
|
||||
|
||||
.solo-btn:hover:not(:disabled) {
|
||||
background: #3a5a2a;
|
||||
border-color: var(--color-bronze);
|
||||
color: var(--color-gold);
|
||||
}
|
||||
|
||||
.solo-btn:disabled {
|
||||
background: #1a2510;
|
||||
color: rgba(168, 216, 128, 0.3);
|
||||
background: none;
|
||||
color: rgba(240, 180, 80, 0.25);
|
||||
cursor: not-allowed;
|
||||
border-color: #3a5a2a;
|
||||
border-color: rgba(107, 76, 30, 0.2);
|
||||
}
|
||||
|
||||
.cancel-btn {
|
||||
background: none;
|
||||
color: rgba(240, 180, 80, 0.6);
|
||||
border: 1px solid rgba(107, 76, 30, 0.4);
|
||||
color: var(--color-gold-dim);
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
}
|
||||
|
||||
.cancel-btn:hover { border-color: #c8861a; color: #f0d080; }
|
||||
.cancel-btn:hover { border-color: var(--color-bronze); color: var(--color-gold); }
|
||||
|
||||
.error {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 14px;
|
||||
color: #c85050;
|
||||
font-size: var(--text-base);
|
||||
color: var(--color-error);
|
||||
margin: 0;
|
||||
height: 1.4em;
|
||||
margin-top: -1rem;
|
||||
@@ -664,8 +722,8 @@
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid rgba(200, 134, 26, 0.2);
|
||||
border-top-color: #c8861a;
|
||||
border-radius: 50%;
|
||||
border-top-color: var(--color-bronze);
|
||||
border-radius: var(--radius-full);
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@@ -673,6 +731,7 @@
|
||||
|
||||
/* ── Game layout ── */
|
||||
.game {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
@@ -717,7 +776,7 @@
|
||||
|
||||
.sidebar-name {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 14px;
|
||||
font-size: var(--text-base);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
@@ -727,18 +786,20 @@
|
||||
}
|
||||
|
||||
.opp-name { color: rgba(200, 80, 80, 0.8); }
|
||||
.you-name { color: #c8861a; }
|
||||
.opp-profile-link { text-decoration: none; transition: color 0.15s; }
|
||||
.opp-profile-link:hover { color: #e05050; }
|
||||
.you-name { color: var(--color-bronze); }
|
||||
|
||||
.sidebar-life {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 30px;
|
||||
font-size: var(--text-2xl);
|
||||
font-weight: 700;
|
||||
color: #f0d080;
|
||||
color: var(--color-gold);
|
||||
}
|
||||
|
||||
.sidebar-deck, .sidebar-hand {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 16px;
|
||||
font-size: var(--text-md);
|
||||
font-style: italic;
|
||||
color: rgba(240, 180, 80, 0.45);
|
||||
}
|
||||
@@ -752,14 +813,14 @@
|
||||
.cost-bubble-display {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
background: #6ea0ec;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--color-energy);
|
||||
border: 2.5px solid #000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #08152c;
|
||||
font-size: 13px;
|
||||
font-size: var(--text-base);
|
||||
font-weight: 700;
|
||||
font-family: 'Cinzel', serif;
|
||||
flex-shrink: 0;
|
||||
@@ -767,9 +828,9 @@
|
||||
|
||||
.energy-count {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 18px;
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 700;
|
||||
color: #f0d080;
|
||||
color: var(--color-gold);
|
||||
}
|
||||
|
||||
.sacrifice-mode-btn {
|
||||
@@ -777,7 +838,7 @@
|
||||
color: rgba(107, 76, 30, 1);
|
||||
border: 2px solid rgba(107, 76, 30, 1);
|
||||
border-radius: 15px;
|
||||
font-size: 16px;
|
||||
font-size: var(--text-md);
|
||||
cursor: pointer;
|
||||
padding: 6px 5.5px;
|
||||
line-height: 1;
|
||||
@@ -786,32 +847,46 @@
|
||||
}
|
||||
|
||||
.sacrifice-mode-btn:hover {
|
||||
border-color: #c8861a;
|
||||
border-color: var(--color-bronze);
|
||||
background: rgba(200, 134, 26, 0.1);
|
||||
}
|
||||
|
||||
.sacrifice-mode-btn.active {
|
||||
background: rgba(180, 40, 40, 0.3);
|
||||
border-color: #c84040;
|
||||
border-color: var(--color-error);
|
||||
}
|
||||
|
||||
.end-turn-btn {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 13px;
|
||||
font-size: var(--btn-font-lg);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
background: #c8861a;
|
||||
background: var(--color-bronze);
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: #fff8e0;
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-btn-text);
|
||||
padding: 10px 8px;
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
margin-top: auto;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
.end-turn-btn:hover { background: #e09820; }
|
||||
.end-turn-btn:hover { background: var(--color-bronze-hover); }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.hand {
|
||||
height: calc(400px * 0.55 + 1rem); /* ~221px instead of ~370px on mobile */
|
||||
}
|
||||
|
||||
.end-turn-btn {
|
||||
min-height: 44px;
|
||||
font-size: var(--text-sm);
|
||||
padding: 8px 6px;
|
||||
}
|
||||
}
|
||||
|
||||
.sacrifice-overlay {
|
||||
position: absolute;
|
||||
@@ -821,9 +896,9 @@
|
||||
justify-content: center;
|
||||
font-size: 48px;
|
||||
background: rgba(180, 40, 40, 0.35);
|
||||
border-radius: 10px;
|
||||
border-radius: var(--radius-lg);
|
||||
cursor: pointer;
|
||||
z-index: 10;
|
||||
z-index: var(--z-card);
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
@@ -937,23 +1012,23 @@
|
||||
width: calc(300px * 0.55);
|
||||
height: calc(400px * 0.55);
|
||||
border: 1.5px dashed rgba(107, 76, 30, 0.25);
|
||||
border-radius: 10px;
|
||||
border-radius: var(--radius-lg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
z-index: 1;
|
||||
z-index: var(--z-base);
|
||||
}
|
||||
|
||||
.empty-slot.highlight {
|
||||
border-color: #c8861a;
|
||||
border-color: var(--color-bronze);
|
||||
background: rgba(200, 134, 26, 0.08);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.slot-hint {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 9px;
|
||||
font-size: var(--text-xs);
|
||||
color: rgba(200, 134, 26, 0.7);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
@@ -1010,14 +1085,14 @@
|
||||
|
||||
.turn-timer {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 11px;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 700;
|
||||
color: rgba(240, 180, 80, 0.6);
|
||||
color: var(--color-gold-dim);
|
||||
transition: color 0.3s;
|
||||
}
|
||||
|
||||
.turn-timer.urgent {
|
||||
color: #c85050;
|
||||
color: var(--color-error);
|
||||
animation: pulse 0.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@@ -1028,7 +1103,7 @@
|
||||
|
||||
.turn-indicator {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 13px;
|
||||
font-size: var(--text-base);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
@@ -1036,7 +1111,7 @@
|
||||
transition: color 0.3s;
|
||||
}
|
||||
|
||||
.turn-indicator.my-turn { color: #c8861a; }
|
||||
.turn-indicator.my-turn { color: var(--color-bronze); }
|
||||
|
||||
/* ── Hand ── */
|
||||
.hand {
|
||||
@@ -1048,7 +1123,7 @@
|
||||
padding: 0.5rem;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
border-top: 1px solid rgba(107, 76, 30, 0.3);
|
||||
border-top: 1px solid var(--color-border-dim);
|
||||
background: rgba(0,0,0,0.3);
|
||||
height: calc(400px * 0.9 + 1rem);
|
||||
}
|
||||
@@ -1081,19 +1156,19 @@
|
||||
.hand-card:hover:not(:disabled) :global(.card) {
|
||||
transform: scale(1.1) translate(-50px, calc(var(--peek-y) - 80px)) !important;
|
||||
transform-origin: top left !important;
|
||||
z-index: 50 !important;
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.hand-card.selected {
|
||||
/* transform: translateY(-16px); */
|
||||
filter: drop-shadow(0 0 8px rgba(200, 134, 26, 0.9));
|
||||
z-index: 25 !important;
|
||||
z-index: 25;
|
||||
}
|
||||
|
||||
.hand-card.selected :global(.card) {
|
||||
/* transform: scale(1.) translate(-30px, calc(var(--peek-y) - 80px)) !important; */
|
||||
/* transform-origin: top left !important; */
|
||||
z-index: 50 !important;
|
||||
z-index: 50;
|
||||
filter: drop-shadow(0 0 8px rgba(200, 134, 26, 0.9));
|
||||
}
|
||||
|
||||
@@ -1108,13 +1183,13 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 200;
|
||||
z-index: var(--z-modal);
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: #110d04;
|
||||
border: 1.5px solid #6b4c1e;
|
||||
border-radius: 10px;
|
||||
border: 1.5px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 2rem 2.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -1125,9 +1200,9 @@
|
||||
|
||||
.modal-title {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 20px;
|
||||
font-size: var(--text-xl);
|
||||
font-weight: 700;
|
||||
color: #f0d080;
|
||||
color: var(--color-gold);
|
||||
margin: 0;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
@@ -1143,20 +1218,20 @@
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 48px;
|
||||
font-weight: 900;
|
||||
color: #f0d080;
|
||||
color: var(--color-gold);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.difficulty-label {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 16px;
|
||||
font-size: var(--text-md);
|
||||
font-style: italic;
|
||||
color: rgba(240, 180, 80, 0.6);
|
||||
color: var(--color-gold-dim);
|
||||
}
|
||||
|
||||
.difficulty-slider {
|
||||
width: 100%;
|
||||
accent-color: #c8861a;
|
||||
accent-color: var(--color-bronze);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -1165,8 +1240,8 @@
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 10px;
|
||||
color: rgba(240, 180, 80, 0.4);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-gold-faint);
|
||||
margin-top: -0.75rem;
|
||||
}
|
||||
|
||||
@@ -1185,10 +1260,34 @@
|
||||
background: rgba(180, 40, 40, 0.9);
|
||||
color: #fff;
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 12px;
|
||||
font-size: var(--text-sm);
|
||||
padding: 8px 20px;
|
||||
border-radius: 6px;
|
||||
border-radius: var(--radius-md);
|
||||
pointer-events: none;
|
||||
z-index: 100;
|
||||
z-index: var(--z-toast);
|
||||
}
|
||||
|
||||
.reconnecting-banner {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background: rgba(13, 10, 4, 0.85);
|
||||
border: 1px solid rgba(200, 134, 26, 0.5);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 10px 24px;
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: var(--text-base);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--color-gold-muted);
|
||||
z-index: 50;
|
||||
pointer-events: none;
|
||||
animation: fade-pulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes fade-pulse {
|
||||
0%, 100% { opacity: 0.5; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
</style>
|
||||
@@ -1,11 +1,25 @@
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import { API_URL, WS_URL } from '$lib/api.js';
|
||||
import { apiFetch } from '$lib/api.js';
|
||||
import { goto } from '$app/navigation';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
let profile = $state(null);
|
||||
let profile: any = $state(null);
|
||||
let loading = $state(true);
|
||||
let wishlistText = $state('');
|
||||
let wishlistSaving = $state(false);
|
||||
let wishlistSaved = $state(false);
|
||||
let wishlistError = $state('');
|
||||
|
||||
let friends: any[] = $state([]);
|
||||
let proposals: any[] = $state([]);
|
||||
let challenges: any[] = $state([]);
|
||||
|
||||
let confirmingRemove: Set<string> = $state(new Set());
|
||||
let confirmingWithdraw: Set<string> = $state(new Set());
|
||||
|
||||
// Increments every second — passed to formatChallengeExpiry to force re-evaluation
|
||||
let tick = $state(0);
|
||||
|
||||
const token = () => localStorage.getItem('token');
|
||||
|
||||
@@ -14,9 +28,81 @@
|
||||
const res = await apiFetch(`${API_URL}/profile`);
|
||||
if (res.status === 401) { goto('/auth'); return; }
|
||||
profile = await res.json();
|
||||
wishlistText = profile.trade_wishlist || '';
|
||||
|
||||
const friendsRes = await apiFetch(`${API_URL}/friends`);
|
||||
if (friendsRes.ok) friends = await friendsRes.json();
|
||||
|
||||
const proposalsRes = await apiFetch(`${API_URL}/trade-proposals`);
|
||||
if (proposalsRes.ok) proposals = await proposalsRes.json();
|
||||
|
||||
const challengesRes = await apiFetch(`${API_URL}/challenges`);
|
||||
if (challengesRes.ok) challenges = await challengesRes.json();
|
||||
|
||||
loading = false;
|
||||
|
||||
const tickInterval = setInterval(() => { tick++; }, 1000);
|
||||
const pollInterval = setInterval(async () => {
|
||||
const [pRes, cRes] = await Promise.all([
|
||||
apiFetch(`${API_URL}/trade-proposals`),
|
||||
apiFetch(`${API_URL}/challenges`),
|
||||
]);
|
||||
if (pRes.ok) proposals = await pRes.json();
|
||||
if (cRes.ok) challenges = await cRes.json();
|
||||
}, 30_000);
|
||||
|
||||
return () => {
|
||||
clearInterval(tickInterval);
|
||||
clearInterval(pollInterval);
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
function formatExpiry(isoString: string) {
|
||||
const d = new Date(isoString);
|
||||
const diff = d.getTime() - Date.now();
|
||||
if (diff < 0) return 'expired';
|
||||
const hrs = Math.floor(diff / 3600000);
|
||||
if (hrs < 1) return 'in < 1h';
|
||||
if (hrs < 24) return `in ${hrs}h`;
|
||||
return `in ${Math.floor(hrs / 24)}d`;
|
||||
}
|
||||
|
||||
async function saveWishlist() {
|
||||
wishlistSaving = true;
|
||||
wishlistError = '';
|
||||
wishlistSaved = false;
|
||||
const res = await apiFetch(`${API_URL}/profile`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ trade_wishlist: wishlistText }),
|
||||
});
|
||||
wishlistSaving = false;
|
||||
if (res.ok) {
|
||||
wishlistSaved = true;
|
||||
setTimeout(() => { wishlistSaved = false; }, 2500);
|
||||
} else {
|
||||
wishlistError = 'Failed to save.';
|
||||
}
|
||||
}
|
||||
|
||||
function formatChallengeExpiry(isoString: string, _tick?: number) {
|
||||
const secs = Math.max(0, Math.floor((new Date(isoString).getTime() - Date.now()) / 1000));
|
||||
if (secs <= 0) return 'expired';
|
||||
if (secs < 60) return `${secs}s remaining`;
|
||||
return `${Math.floor(secs / 60)}m ${secs % 60}s remaining`;
|
||||
}
|
||||
|
||||
async function withdrawChallenge(challengeId: string) {
|
||||
const res = await apiFetch(`${API_URL}/challenges/${challengeId}/decline`, { method: 'POST' });
|
||||
if (res.ok) challenges = challenges.filter((c: any) => c.id !== challengeId);
|
||||
}
|
||||
|
||||
async function removeFriend(friendshipId: string) {
|
||||
await apiFetch(`${API_URL}/friendships/${friendshipId}`, { method: 'DELETE' });
|
||||
friends = friends.filter((f: any) => f.friendship_id !== friendshipId);
|
||||
}
|
||||
|
||||
function logout() {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('refresh_token');
|
||||
@@ -69,7 +155,7 @@
|
||||
<span class="shards-icon">◈</span>
|
||||
<span class="shards-value">{profile.shards}</span>
|
||||
<span class="shards-label">Shards</span>
|
||||
<a href="/shards" class="shards-link">shatter cards</a>
|
||||
<a href="/shatter" class="shards-link">shatter cards</a>
|
||||
</div>
|
||||
|
||||
<div class="section-divider"></div>
|
||||
@@ -98,6 +184,28 @@
|
||||
|
||||
<div class="section-divider"></div>
|
||||
|
||||
<div class="wishlist-group">
|
||||
<h2 class="section-title">Trade Wishlist</h2>
|
||||
<div class="wishlist-section">
|
||||
<p class="wishlist-hint">Cards or types you're looking to trade for. Visible on your public profile.</p>
|
||||
<textarea
|
||||
class="wishlist-textarea"
|
||||
bind:value={wishlistText}
|
||||
placeholder="e.g. Looking for legendary locations, rare scientists..."
|
||||
rows="3"
|
||||
></textarea>
|
||||
<div class="wishlist-actions">
|
||||
<button class="save-btn" onclick={saveWishlist} disabled={wishlistSaving}>
|
||||
{wishlistSaving ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
{#if wishlistSaved}<span class="wishlist-ok">Saved ✓</span>{/if}
|
||||
<p class="wishlist-error" style="min-height: 1.2em">{wishlistError}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-divider"></div>
|
||||
|
||||
<h2 class="section-title">Highlights</h2>
|
||||
<div class="highlights">
|
||||
<div class="highlight-card">
|
||||
@@ -129,17 +237,161 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-divider"></div>
|
||||
|
||||
<h2 class="section-title">Friends</h2>
|
||||
{#if friends.length === 0}
|
||||
<p class="no-friends">No friends yet.</p>
|
||||
{:else}
|
||||
<ul class="friends-list">
|
||||
{#each friends as f (f.friendship_id)}
|
||||
<li class="friend-item">
|
||||
<a href="/profile/{f.username}" class="friend-name">{f.username}</a>
|
||||
{#if confirmingRemove.has(f.friendship_id)}
|
||||
<span class="confirm-label">Remove friend?</span>
|
||||
<button class="confirm-yes-btn" onclick={() => removeFriend(f.friendship_id)}>Confirm</button>
|
||||
<button class="confirm-no-btn" onclick={() => { confirmingRemove.delete(f.friendship_id); confirmingRemove = confirmingRemove; }}>Cancel</button>
|
||||
{:else}
|
||||
<button class="unfriend-btn" onclick={() => { confirmingRemove.add(f.friendship_id); confirmingRemove = confirmingRemove; }}>Remove</button>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
|
||||
<div class="section-divider"></div>
|
||||
|
||||
<h2 class="section-title">Trade Proposals</h2>
|
||||
|
||||
{#if proposals.length === 0}
|
||||
<p class="no-friends">No trade proposals.</p>
|
||||
{:else}
|
||||
{@const incoming = proposals.filter((p: any) => p.direction === 'incoming' && p.status === 'pending')}
|
||||
{@const outgoing = proposals.filter((p: any) => p.direction === 'outgoing' && p.status === 'pending')}
|
||||
{@const resolved = proposals.filter((p: any) => p.status !== 'pending')}
|
||||
|
||||
{#if incoming.length > 0}
|
||||
<p class="proposal-subhead">Incoming</p>
|
||||
{#each incoming as p (p.id)}
|
||||
<div class="proposal-card">
|
||||
<div class="proposal-meta">
|
||||
<a href="/profile/{p.proposer_username}" target="_blank" class="friend-name">{p.proposer_username}</a>
|
||||
<span class="proposal-status pending">Pending</span>
|
||||
<span class="proposal-expires">{formatExpiry(p.expires_at)}</span>
|
||||
</div>
|
||||
<p class="proposal-desc">
|
||||
{#if p.requested_cards.length > 0}Wants: <strong>{p.requested_cards.map((c: any) => c.name).join(', ')}</strong><br/>{/if}
|
||||
{#if p.offered_cards.length > 0}Offering: {p.offered_cards.map((c: any) => c.name).join(', ')}{/if}
|
||||
</p>
|
||||
<a href="/trade/proposal/{p.id}" class="see-proposal-btn">See Proposal</a>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
{#if outgoing.length > 0}
|
||||
<p class="proposal-subhead">Outgoing</p>
|
||||
{#each outgoing as p (p.id)}
|
||||
<div class="proposal-card">
|
||||
<div class="proposal-meta">
|
||||
<span class="proposal-to">To: <a href="/profile/{p.recipient_username}" target="_blank" class="friend-name">{p.recipient_username}</a></span>
|
||||
<span class="proposal-status pending">Pending</span>
|
||||
<span class="proposal-expires">{formatExpiry(p.expires_at)}</span>
|
||||
</div>
|
||||
<p class="proposal-desc">
|
||||
{#if p.requested_cards.length > 0}Requesting: <strong>{p.requested_cards.map((c: any) => c.name).join(', ')}</strong><br/>{/if}
|
||||
{#if p.offered_cards.length > 0}Offering: {p.offered_cards.map((c: any) => c.name).join(', ')}{/if}
|
||||
</p>
|
||||
<a href="/trade/proposal/{p.id}" class="see-proposal-btn">See Proposal</a>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
{#if resolved.length > 0}
|
||||
<p class="proposal-subhead">History</p>
|
||||
{#each resolved as p (p.id)}
|
||||
<div class="proposal-card resolved">
|
||||
<div class="proposal-meta">
|
||||
<span class="proposal-to">{p.direction === 'incoming' ? `From: ${p.proposer_username}` : `To: ${p.recipient_username}`}</span>
|
||||
<span class="proposal-status {p.status}">{p.status}</span>
|
||||
</div>
|
||||
<p class="proposal-desc">{p.requested_cards.length} requested · {p.offered_cards.length} offered</p>
|
||||
<a href="/trade/proposal/{p.id}" class="see-proposal-btn">See Proposal</a>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
|
||||
{/if}
|
||||
|
||||
{#if challenges.length > 0}
|
||||
<div class="section-divider"></div>
|
||||
|
||||
<h2 class="section-title">Game Challenges</h2>
|
||||
|
||||
{@const pendingOut = challenges.filter((c: any) => c.direction === 'outgoing' && c.status === 'pending')}
|
||||
{@const pendingIn = challenges.filter((c: any) => c.direction === 'incoming' && c.status === 'pending')}
|
||||
{@const resolvedC = challenges.filter((c: any) => c.status !== 'pending')}
|
||||
|
||||
{#if pendingOut.length > 0}
|
||||
<p class="proposal-subhead">Sent</p>
|
||||
{#each pendingOut as c (c.id)}
|
||||
<div class="proposal-card">
|
||||
<div class="proposal-meta">
|
||||
<span class="proposal-to">To: <a href="/profile/{c.challenged_username}" target="_blank" class="friend-name">{c.challenged_username}</a></span>
|
||||
<span class="proposal-status pending">Awaiting response</span>
|
||||
<span class="proposal-expires">{formatChallengeExpiry(c.expires_at, tick)}</span>
|
||||
</div>
|
||||
<p class="proposal-desc">Deck: <strong>{c.deck_name}</strong></p>
|
||||
{#if confirmingWithdraw.has(c.id)}
|
||||
<span class="confirm-label">Withdraw challenge?</span>
|
||||
<button class="confirm-yes-btn" onclick={() => withdrawChallenge(c.id)}>Confirm</button>
|
||||
<button class="confirm-no-btn" onclick={() => { confirmingWithdraw.delete(c.id); confirmingWithdraw = confirmingWithdraw; }}>Cancel</button>
|
||||
{:else}
|
||||
<button class="withdraw-btn" onclick={() => { confirmingWithdraw.add(c.id); confirmingWithdraw = confirmingWithdraw; }}>Withdraw</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
{#if pendingIn.length > 0}
|
||||
<p class="proposal-subhead">Incoming</p>
|
||||
{#each pendingIn as c (c.id)}
|
||||
<div class="proposal-card">
|
||||
<div class="proposal-meta">
|
||||
<a href="/profile/{c.challenger_username}" target="_blank" class="friend-name">{c.challenger_username}</a>
|
||||
<span class="proposal-status pending">Pending</span>
|
||||
<span class="proposal-expires">{formatChallengeExpiry(c.expires_at, tick)}</span>
|
||||
</div>
|
||||
<p class="proposal-desc">Their deck: <strong>{c.deck_name}</strong></p>
|
||||
<p class="proposal-desc">Check your notification bell to accept.</p>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
{#if resolvedC.length > 0}
|
||||
<p class="proposal-subhead">History</p>
|
||||
{#each resolvedC as c (c.id)}
|
||||
<div class="proposal-card resolved">
|
||||
<div class="proposal-meta">
|
||||
<span class="proposal-to">
|
||||
{c.direction === 'outgoing' ? `To: ${c.challenged_username}` : `From: ${c.challenger_username}`}
|
||||
</span>
|
||||
<span class="proposal-status {c.status}">{c.status}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
</div>
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;700&family=Crimson+Text:ital,wght@0,400;0,600;1,400&display=swap');
|
||||
|
||||
main {
|
||||
height: 100vh;
|
||||
overflow-y: auto;
|
||||
background: #0d0a04;
|
||||
background: var(--color-bg);
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
@@ -149,6 +401,7 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
padding-bottom: 5rem;
|
||||
}
|
||||
|
||||
.profile-header {
|
||||
@@ -160,16 +413,16 @@
|
||||
.avatar {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 50%;
|
||||
background: #3d2507;
|
||||
border: 2px solid #c8861a;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--color-surface-raised);
|
||||
border: 2px solid var(--color-bronze);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 28px;
|
||||
font-size: var(--text-2xl);
|
||||
font-weight: 700;
|
||||
color: #f0d080;
|
||||
color: var(--color-gold);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -182,22 +435,22 @@
|
||||
|
||||
.username {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 24px;
|
||||
font-size: var(--text-xl);
|
||||
font-weight: 700;
|
||||
color: #f0d080;
|
||||
color: var(--color-gold);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.email {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 15px;
|
||||
font-size: var(--text-md);
|
||||
color: rgba(240, 180, 80, 0.5);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.unverified-badge {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 9px;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
@@ -211,7 +464,7 @@
|
||||
|
||||
.resend-btn {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 13px;
|
||||
font-size: var(--text-base);
|
||||
font-style: italic;
|
||||
color: rgba(240, 180, 80, 0.5);
|
||||
background: none;
|
||||
@@ -228,7 +481,7 @@
|
||||
|
||||
.joined {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 13px;
|
||||
font-size: var(--text-base);
|
||||
font-style: italic;
|
||||
color: rgba(240, 180, 80, 0.35);
|
||||
margin: 0;
|
||||
@@ -236,29 +489,29 @@
|
||||
|
||||
.logout-btn {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 11px;
|
||||
font-size: var(--btn-font-md);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
background: none;
|
||||
border: 1px solid rgba(180, 60, 60, 0.4);
|
||||
border-radius: 4px;
|
||||
border-radius: var(--radius-sm);
|
||||
color: rgba(200, 80, 80, 0.7);
|
||||
padding: 8px 16px;
|
||||
padding: var(--btn-padding-md);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.logout-btn:hover {
|
||||
border-color: #c84040;
|
||||
border-color: var(--color-error);
|
||||
color: #e05050;
|
||||
background: rgba(180, 40, 40, 0.1);
|
||||
}
|
||||
|
||||
.reset-link {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 14px;
|
||||
font-size: var(--text-base);
|
||||
font-style: italic;
|
||||
color: rgba(240, 180, 80, 0.4);
|
||||
text-decoration: underline;
|
||||
@@ -276,38 +529,39 @@
|
||||
|
||||
.shards-link {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 10px;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(126, 207, 207, 0.6);
|
||||
border: 1px solid rgba(126, 207, 207, 0.3);
|
||||
border-radius: 4px;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 3px 8px;
|
||||
text-decoration: none;
|
||||
margin-top: 4px;
|
||||
margin-left: 0.5rem;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
}
|
||||
.shards-link:hover { color: #7ecfcf; border-color: rgba(126, 207, 207, 0.7); }
|
||||
.shards-link:hover { color: var(--color-cyan); border-color: rgba(126, 207, 207, 0.7); }
|
||||
|
||||
.shards-icon {
|
||||
font-size: 22px;
|
||||
color: #7ecfcf;
|
||||
font-size: var(--text-xl);
|
||||
color: var(--color-cyan);
|
||||
position: relative;
|
||||
top: -0.1em;
|
||||
animation: shard-pulse 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.shards-value {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 28px;
|
||||
font-size: var(--text-2xl);
|
||||
font-weight: 700;
|
||||
color: #7ecfcf;
|
||||
color: var(--color-cyan);
|
||||
}
|
||||
|
||||
.shards-label {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 11px;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
@@ -317,16 +571,16 @@
|
||||
|
||||
.section-divider {
|
||||
height: 1px;
|
||||
background: rgba(107, 76, 30, 0.3);
|
||||
background: var(--color-border-dim);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 13px;
|
||||
font-size: var(--text-base);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(240, 180, 80, 0.4);
|
||||
color: var(--color-gold-faint);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@@ -337,9 +591,9 @@
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: #1a1008;
|
||||
border: 1px solid rgba(107, 76, 30, 0.3);
|
||||
border-radius: 8px;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border-dim);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -348,24 +602,24 @@
|
||||
|
||||
.stat-label {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 9px;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(240, 180, 80, 0.4);
|
||||
color: var(--color-gold-faint);
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 28px;
|
||||
font-size: var(--text-2xl);
|
||||
font-weight: 700;
|
||||
color: #f0d080;
|
||||
color: var(--color-gold);
|
||||
}
|
||||
|
||||
.wins { color: #6aaa6a; }
|
||||
.losses { color: #c85050; }
|
||||
.good-wr { color: #6aaa6a; }
|
||||
.bad-wr { color: #c85050; }
|
||||
.wins { color: var(--color-success); }
|
||||
.losses { color: var(--color-error); }
|
||||
.good-wr { color: var(--color-success); }
|
||||
.bad-wr { color: var(--color-error); }
|
||||
|
||||
.highlights {
|
||||
display: grid;
|
||||
@@ -374,9 +628,9 @@
|
||||
}
|
||||
|
||||
.highlight-card {
|
||||
background: #1a1008;
|
||||
border: 1px solid rgba(107, 76, 30, 0.3);
|
||||
border-radius: 8px;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border-dim);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -385,23 +639,23 @@
|
||||
|
||||
.highlight-label {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 9px;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(240, 180, 80, 0.4);
|
||||
color: var(--color-gold-faint);
|
||||
}
|
||||
|
||||
.highlight-value {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 16px;
|
||||
font-size: var(--text-md);
|
||||
font-weight: 700;
|
||||
color: #f0d080;
|
||||
color: var(--color-gold);
|
||||
}
|
||||
|
||||
.highlight-sub {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 13px;
|
||||
font-size: var(--text-base);
|
||||
font-style: italic;
|
||||
color: rgba(240, 180, 80, 0.45);
|
||||
}
|
||||
@@ -418,8 +672,8 @@
|
||||
height: 48px;
|
||||
object-fit: cover;
|
||||
object-position: top;
|
||||
border-radius: 4px;
|
||||
border: 1px solid rgba(107, 76, 30, 0.4);
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -429,22 +683,303 @@
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.wishlist-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.wishlist-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.wishlist-hint {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: var(--text-base);
|
||||
font-style: italic;
|
||||
color: rgba(240, 180, 80, 0.35);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.wishlist-textarea {
|
||||
width: 100%;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-gold);
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: var(--text-md);
|
||||
padding: 0.6rem 0.75rem;
|
||||
resize: vertical;
|
||||
box-sizing: border-box;
|
||||
transition: border-color 0.15s;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.wishlist-textarea:focus { border-color: var(--color-bronze); }
|
||||
.wishlist-textarea::placeholder { color: rgba(240, 180, 80, 0.25); }
|
||||
|
||||
.wishlist-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.save-btn {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: var(--btn-font-md);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
background: var(--color-surface-raised);
|
||||
border: 1px solid var(--color-bronze);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-btn-text);
|
||||
padding: var(--btn-padding-md);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.save-btn:hover:not(:disabled) { background: #4d3010; }
|
||||
.save-btn:disabled { opacity: 0.5; cursor: default; }
|
||||
|
||||
.wishlist-ok {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: var(--text-base);
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.wishlist-error {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: var(--text-base);
|
||||
color: var(--color-error);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.no-friends {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: var(--text-base);
|
||||
font-style: italic;
|
||||
color: rgba(240, 180, 80, 0.25);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.friends-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.friend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border-dim);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.friend-name {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: var(--text-base);
|
||||
font-weight: 700;
|
||||
color: var(--color-gold);
|
||||
text-decoration: none;
|
||||
flex: 1;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.friend-name:hover { color: var(--color-bronze); }
|
||||
|
||||
.unfriend-btn {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: var(--btn-font-md);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
background: rgba(180, 40, 40, 0.8);
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
color: #fff;
|
||||
padding: var(--btn-padding-md);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.unfriend-btn:hover { background: rgba(210, 50, 50, 0.9); }
|
||||
|
||||
.no-data {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 14px;
|
||||
font-size: var(--text-base);
|
||||
font-style: italic;
|
||||
color: rgba(240, 180, 80, 0.25);
|
||||
}
|
||||
|
||||
/* ── Trade Proposals ── */
|
||||
.proposal-subhead {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(240, 180, 80, 0.3);
|
||||
margin: 0.5rem 0 0;
|
||||
}
|
||||
|
||||
.proposal-card {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border-dim);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 0.85rem 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.proposal-card.resolved { opacity: 0.5; }
|
||||
|
||||
.proposal-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.proposal-to {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: var(--text-base);
|
||||
color: rgba(240, 180, 80, 0.5);
|
||||
}
|
||||
|
||||
.proposal-status {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
border-radius: 3px;
|
||||
padding: 1px 5px;
|
||||
border: 1px solid;
|
||||
}
|
||||
|
||||
.proposal-status.pending { color: var(--color-bronze); border-color: rgba(200, 134, 26, 0.4); }
|
||||
.proposal-status.accepted { color: var(--color-success); border-color: rgba(106, 170, 106, 0.4); }
|
||||
.proposal-status.declined { color: var(--color-error); border-color: rgba(200, 64, 64, 0.4); }
|
||||
.proposal-status.expired { color: rgba(240, 180, 80, 0.3); border-color: var(--color-border-dim); }
|
||||
.proposal-status.withdrawn { color: rgba(240, 180, 80, 0.3); border-color: var(--color-border-dim); }
|
||||
|
||||
.proposal-expires {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: var(--text-sm);
|
||||
font-style: italic;
|
||||
color: rgba(240, 180, 80, 0.3);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.proposal-desc {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: var(--text-base);
|
||||
color: rgba(240, 180, 80, 0.65);
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.proposal-desc strong { color: var(--color-gold); font-style: normal; font-weight: 600; }
|
||||
|
||||
.see-proposal-btn {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: var(--btn-font-md);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.07em;
|
||||
text-transform: uppercase;
|
||||
background: var(--color-surface-raised);
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-gold);
|
||||
padding: var(--btn-padding-md);
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
transition: all 0.15s;
|
||||
margin-top: 0.25rem;
|
||||
align-self: flex-start;
|
||||
}
|
||||
.see-proposal-btn:hover { border-color: var(--color-bronze); background: #4d3010; }
|
||||
|
||||
.withdraw-btn {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: var(--btn-font-sm);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
background: none;
|
||||
border: 1px solid rgba(180, 60, 60, 0.4);
|
||||
border-radius: var(--radius-sm);
|
||||
color: rgba(200, 80, 80, 0.6);
|
||||
padding: var(--btn-padding-sm);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.withdraw-btn:hover { border-color: var(--color-error); color: #e05050; }
|
||||
|
||||
.status {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 16px;
|
||||
font-size: var(--text-md);
|
||||
font-style: italic;
|
||||
color: rgba(240, 180, 80, 0.5);
|
||||
text-align: center;
|
||||
margin-top: 4rem;
|
||||
}
|
||||
|
||||
.confirm-label {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: var(--text-base);
|
||||
font-style: italic;
|
||||
color: rgba(200, 80, 80, 0.8);
|
||||
}
|
||||
|
||||
.confirm-yes-btn {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: var(--btn-font-sm);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
background: rgba(180, 40, 40, 0.8);
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
color: #fff;
|
||||
padding: var(--btn-padding-sm);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.confirm-yes-btn:hover { background: rgba(210, 50, 50, 0.9); }
|
||||
|
||||
.confirm-no-btn {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: var(--btn-font-sm);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
background: none;
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-gold-faint);
|
||||
padding: var(--btn-padding-sm);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.confirm-no-btn:hover { border-color: rgba(107, 76, 30, 0.7); color: rgba(240, 180, 80, 0.7); }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.stats-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
.highlights { grid-template-columns: 1fr; }
|
||||
|
||||
@@ -0,0 +1,832 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { onMount } from 'svelte';
|
||||
import { get } from 'svelte/store';
|
||||
import { API_URL, apiFetch } from '$lib/api.js';
|
||||
import Card from '$lib/Card.svelte';
|
||||
|
||||
// Cards shown when collapsed (1 visual row at scale 0.62)
|
||||
const ROW_SIZE = 4;
|
||||
|
||||
function formatLastActive(iso: string | null): string {
|
||||
if (!iso) return '';
|
||||
const diff = Math.floor((Date.now() - new Date(iso + 'Z').getTime()) / 1000);
|
||||
if (diff < 60) return 'Active just now';
|
||||
if (diff < 3600) return `Active ${Math.floor(diff / 60)}m ago`;
|
||||
if (diff < 86400) return `Active ${Math.floor(diff / 3600)}h ago`;
|
||||
const days = Math.floor(diff / 86400);
|
||||
if (days < 30) return `Active ${days}d ago`;
|
||||
if (days < 365) return `Active ${Math.floor(days / 30)}mo ago`;
|
||||
return `Active ${Math.floor(days / 365)}y ago`;
|
||||
}
|
||||
|
||||
let profile: any = $state(null);
|
||||
let loading = $state(true);
|
||||
let notFound = $state(false);
|
||||
let favExpanded = $state(false);
|
||||
let wttExpanded = $state(false);
|
||||
|
||||
// 'idle' | 'pending' | 'pending_received' | 'friends' — friendship status with this user
|
||||
let friendStatus: 'idle' | 'pending' | 'pending_received' | 'friends' = $state('idle');
|
||||
let friendshipId: string | null = $state(null);
|
||||
let isLoggedIn = $state(false);
|
||||
let sendingFriendRequest = $state(false);
|
||||
|
||||
// Challenge state
|
||||
let showChallengeModal = $state(false);
|
||||
let challengeDecks: any[] = $state([]);
|
||||
let selectedDeckId = $state('');
|
||||
let challengeStatus: 'idle' | 'sending' | 'sent' | 'error' = $state('idle');
|
||||
let challengeError = $state('');
|
||||
|
||||
let favSectionEl: HTMLElement | null = $state(null);
|
||||
let wttSectionEl: HTMLElement | null = $state(null);
|
||||
|
||||
const visibleFav = $derived(
|
||||
profile ? (favExpanded ? profile.favorite_cards : profile.favorite_cards.slice(0, ROW_SIZE)) : []
|
||||
);
|
||||
const visibleWtt = $derived(
|
||||
profile ? (wttExpanded ? profile.wtt_cards : profile.wtt_cards.slice(0, ROW_SIZE)) : []
|
||||
);
|
||||
|
||||
// Collapse without the page jumping: lock the section's viewport position before and after
|
||||
function collapseSection(sectionEl: HTMLElement | null, setter: () => void) {
|
||||
if (!sectionEl) { setter(); return; }
|
||||
const beforeTop = sectionEl.getBoundingClientRect().top;
|
||||
setter();
|
||||
requestAnimationFrame(() => {
|
||||
const afterTop = sectionEl.getBoundingClientRect().top;
|
||||
window.scrollBy(0, afterTop - beforeTop);
|
||||
});
|
||||
}
|
||||
|
||||
async function sendFriendRequest() {
|
||||
sendingFriendRequest = true;
|
||||
try {
|
||||
const res = await apiFetch(`${API_URL}/users/${profile.username}/friend-request`, { method: 'POST' });
|
||||
if (res.ok) friendStatus = 'pending';
|
||||
} finally {
|
||||
sendingFriendRequest = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function openChallengeModal() {
|
||||
if (!challengeDecks.length) {
|
||||
const res = await apiFetch(`${API_URL}/decks`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
challengeDecks = data.filter((d: any) => !d.deleted);
|
||||
if (challengeDecks.length) selectedDeckId = challengeDecks[0].id;
|
||||
}
|
||||
}
|
||||
challengeStatus = 'idle';
|
||||
challengeError = '';
|
||||
showChallengeModal = true;
|
||||
}
|
||||
|
||||
async function sendChallenge() {
|
||||
if (!selectedDeckId) return;
|
||||
challengeStatus = 'sending';
|
||||
challengeError = '';
|
||||
const res = await apiFetch(`${API_URL}/users/${profile.username}/challenge`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ deck_id: selectedDeckId }),
|
||||
});
|
||||
if (res.ok) {
|
||||
challengeStatus = 'sent';
|
||||
} else {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
challengeError = data.detail || 'Failed to send challenge';
|
||||
challengeStatus = 'error';
|
||||
}
|
||||
}
|
||||
|
||||
async function removeFriendFromProfile() {
|
||||
if (!friendshipId) return;
|
||||
await apiFetch(`${API_URL}/friendships/${friendshipId}`, { method: 'DELETE' });
|
||||
friendStatus = 'idle';
|
||||
friendshipId = null;
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
const username = get(page).params.username;
|
||||
const token = localStorage.getItem('token');
|
||||
|
||||
const res = await fetch(`${API_URL}/users/${username}`);
|
||||
if (res.status === 404) { notFound = true; loading = false; return; }
|
||||
const data = await res.json();
|
||||
|
||||
// Redirect to own profile if logged-in user visits their own public page
|
||||
if (token) {
|
||||
try {
|
||||
const meRes = await fetch(`${API_URL}/profile`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
if (meRes.ok) {
|
||||
const me = await meRes.json();
|
||||
if (me.username === username) { goto('/profile'); return; }
|
||||
isLoggedIn = true;
|
||||
|
||||
// Check existing friendship status
|
||||
const statusRes = await apiFetch(`${API_URL}/friendship-status/${username}`);
|
||||
if (statusRes.ok) {
|
||||
const s = await statusRes.json();
|
||||
if (s.status === 'friends') { friendStatus = 'friends'; friendshipId = s.friendship_id; }
|
||||
else if (s.status === 'pending_sent') { friendStatus = 'pending'; }
|
||||
else if (s.status === 'pending_received') { friendStatus = 'pending_received'; friendshipId = s.friendship_id; }
|
||||
}
|
||||
}
|
||||
} catch { /* non-critical */ }
|
||||
}
|
||||
|
||||
profile = data;
|
||||
loading = false;
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if loading}
|
||||
<main class="page">
|
||||
<p class="status-text">Loading...</p>
|
||||
</main>
|
||||
|
||||
{:else if notFound}
|
||||
<main class="page">
|
||||
<div class="not-found">
|
||||
<div class="not-found-sigil">✦</div>
|
||||
<h1 class="not-found-title">Unknown Adventurer</h1>
|
||||
<p class="not-found-sub">No record found for this username.</p>
|
||||
<a href="/" class="btn-secondary">Return Home</a>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{:else if profile}
|
||||
<main class="page">
|
||||
<div class="profile-wrap">
|
||||
|
||||
<!-- ═══ HEADER ═══ -->
|
||||
<header class="profile-header">
|
||||
<div class="avatar-col">
|
||||
<div class="avatar">{profile.username[0].toUpperCase()}</div>
|
||||
</div>
|
||||
<div class="header-body">
|
||||
<div class="ornament-line"></div>
|
||||
<h1 class="username">{profile.username}</h1>
|
||||
<div class="ornament-line"></div>
|
||||
{#if profile.last_active_at}
|
||||
<p class="last-active">{formatLastActive(profile.last_active_at)}</p>
|
||||
{/if}
|
||||
<div class="stats-row">
|
||||
<div class="stat">
|
||||
<span class="stat-num wins">{profile.wins}</span>
|
||||
<span class="stat-label">Wins</span>
|
||||
</div>
|
||||
<div class="stat-sep">·</div>
|
||||
<div class="stat">
|
||||
<span class="stat-num losses">{profile.losses}</span>
|
||||
<span class="stat-label">Losses</span>
|
||||
</div>
|
||||
<div class="stat-sep">·</div>
|
||||
<div class="stat">
|
||||
<span class="stat-num" class:good-wr={profile.win_rate !== null && profile.win_rate >= 50} class:bad-wr={profile.win_rate !== null && profile.win_rate < 50}>
|
||||
{profile.win_rate !== null ? `${profile.win_rate}%` : '—'}
|
||||
</span>
|
||||
<span class="stat-label">Win Rate</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if isLoggedIn}
|
||||
<div class="friend-actions">
|
||||
{#if friendStatus === 'idle'}
|
||||
<button class="btn-friend" onclick={sendFriendRequest} disabled={sendingFriendRequest}>{sendingFriendRequest ? 'Adding...' : '+ Add Friend'}</button>
|
||||
{:else if friendStatus === 'pending'}
|
||||
<span class="friend-pending">Request Sent</span>
|
||||
{:else if friendStatus === 'pending_received'}
|
||||
<span class="friend-pending">Sent you a request</span>
|
||||
{:else if friendStatus === 'friends'}
|
||||
<span class="friend-status">Friends</span>
|
||||
<button class="btn-unfriend" onclick={removeFriendFromProfile}>Remove</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- ═══ TRADE WISHLIST ═══ -->
|
||||
{#if profile.trade_wishlist}
|
||||
<section class="section">
|
||||
<h2 class="section-title">Looking For</h2>
|
||||
<blockquote class="wishlist-block">
|
||||
{profile.trade_wishlist}
|
||||
</blockquote>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- ═══ FAVORITES ═══ -->
|
||||
{#if profile.favorite_cards?.length > 0}
|
||||
{@const hasFavMore = profile.favorite_cards.length > ROW_SIZE}
|
||||
<section class="section" bind:this={favSectionEl}>
|
||||
<!-- Header always same height: collapse button invisible when not needed -->
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">Favorites</h2>
|
||||
<button
|
||||
class="expand-btn"
|
||||
style:visibility={hasFavMore && favExpanded ? 'visible' : 'hidden'}
|
||||
onclick={() => collapseSection(favSectionEl, () => { favExpanded = false; })}
|
||||
>Collapse ▲</button>
|
||||
</div>
|
||||
<div class="card-grid">
|
||||
{#each visibleFav as card (card.id)}
|
||||
<div class="card-wrap">
|
||||
<div class="card-inner"><Card {card} /></div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{#if hasFavMore}
|
||||
{#if !favExpanded}
|
||||
<button class="expand-btn" onclick={() => { favExpanded = true; }}>
|
||||
Show all {profile.favorite_cards.length} cards ▼
|
||||
</button>
|
||||
{:else}
|
||||
<button class="expand-btn" onclick={() => collapseSection(favSectionEl, () => { favExpanded = false; })}>
|
||||
Collapse ▲
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- ═══ WILLING TO TRADE ═══ -->
|
||||
{#if profile.wtt_cards?.length > 0}
|
||||
{@const hasWttMore = profile.wtt_cards.length > ROW_SIZE}
|
||||
<section class="section" bind:this={wttSectionEl}>
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">Willing to Trade</h2>
|
||||
<button
|
||||
class="expand-btn"
|
||||
style:visibility={hasWttMore && wttExpanded ? 'visible' : 'hidden'}
|
||||
onclick={() => collapseSection(wttSectionEl, () => { wttExpanded = false; })}
|
||||
>Collapse ▲</button>
|
||||
</div>
|
||||
<div class="card-grid">
|
||||
{#each visibleWtt as card (card.id)}
|
||||
<div class="card-wrap">
|
||||
<div class="card-inner"><Card {card} /></div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{#if hasWttMore}
|
||||
{#if !wttExpanded}
|
||||
<button class="expand-btn" onclick={() => { wttExpanded = true; }}>
|
||||
Show all {profile.wtt_cards.length} cards ▼
|
||||
</button>
|
||||
{:else}
|
||||
<button class="expand-btn" onclick={() => collapseSection(wttSectionEl, () => { wttExpanded = false; })}>
|
||||
Collapse ▲
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if isLoggedIn}
|
||||
<div class="offer-cta">
|
||||
<a href="/trade/offer/{profile.username}" class="btn-primary">⇄ Offer a Trade</a>
|
||||
{#if challengeStatus === 'sent'}
|
||||
<span class="challenge-sent">Challenge Sent ✦</span>
|
||||
{:else}
|
||||
<button class="btn-secondary" onclick={openChallengeModal}>⚔ Challenge to Play</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if showChallengeModal}
|
||||
<div class="modal-backdrop" onclick={() => { showChallengeModal = false; }}>
|
||||
<div class="modal" onclick={(e) => e.stopPropagation()}>
|
||||
<div class="modal-icon">⚔</div>
|
||||
<h2 class="modal-title">Challenge {profile.username}</h2>
|
||||
{#if challengeDecks.length === 0}
|
||||
<p class="modal-body">You have no decks. Build a deck first.</p>
|
||||
<button class="btn-secondary" onclick={() => { showChallengeModal = false; }}>Close</button>
|
||||
{:else if challengeStatus === 'sent'}
|
||||
<p class="modal-body">Your challenge has been sent. {profile.username} has 5 minutes to accept.</p>
|
||||
<button class="btn-primary" onclick={() => { showChallengeModal = false; }}>Done</button>
|
||||
{:else}
|
||||
<p class="modal-body">Select a deck to battle with:</p>
|
||||
<select class="deck-select" bind:value={selectedDeckId}>
|
||||
{#each challengeDecks as deck}
|
||||
<option value={deck.id}>{deck.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{#if challengeError}
|
||||
<p class="challenge-error">{challengeError}</p>
|
||||
{/if}
|
||||
<div class="modal-actions">
|
||||
<button class="btn-primary" onclick={sendChallenge} disabled={challengeStatus === 'sending'}>
|
||||
{challengeStatus === 'sending' ? 'Sending...' : 'Send Challenge'}
|
||||
</button>
|
||||
<button class="btn-secondary" onclick={() => { showChallengeModal = false; }}>Cancel</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.page {
|
||||
height: 100vh;
|
||||
background: var(--color-bg);
|
||||
padding: 2rem;
|
||||
overflow-y: auto;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.profile-wrap {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2.5rem;
|
||||
padding-top: 1rem;
|
||||
padding-bottom: 3rem;
|
||||
}
|
||||
|
||||
/* ── Header ── */
|
||||
.profile-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.avatar-col { flex-shrink: 0; }
|
||||
|
||||
.avatar {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--color-surface-raised);
|
||||
border: 2px solid var(--color-bronze);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: var(--text-3xl);
|
||||
font-weight: 700;
|
||||
color: var(--color-gold);
|
||||
box-shadow: 0 0 20px rgba(200, 134, 26, 0.2), var(--shadow-card);
|
||||
}
|
||||
|
||||
.header-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.ornament-line {
|
||||
height: 1px;
|
||||
background: var(--color-border-subtle);
|
||||
}
|
||||
|
||||
.username {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: var(--text-2xl);
|
||||
font-weight: 700;
|
||||
color: var(--color-gold);
|
||||
margin: 0;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.last-active {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: var(--text-base);
|
||||
font-style: italic;
|
||||
color: rgba(240, 180, 80, 0.35);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.stats-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding-top: 0.25rem;
|
||||
}
|
||||
|
||||
.stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
.stat-num {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: var(--text-xl);
|
||||
font-weight: 700;
|
||||
color: var(--color-gold);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-gold-faint);
|
||||
}
|
||||
|
||||
.stat-sep {
|
||||
font-size: var(--text-lg);
|
||||
color: var(--color-border-subtle);
|
||||
align-self: center;
|
||||
margin-top: -0.5rem;
|
||||
}
|
||||
|
||||
.wins { color: var(--color-success); }
|
||||
.losses { color: var(--color-error); }
|
||||
.good-wr { color: var(--color-success); }
|
||||
.bad-wr { color: var(--color-error); }
|
||||
|
||||
/* ── Friend actions ── */
|
||||
.friend-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.btn-friend {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: var(--btn-font-md);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.07em;
|
||||
text-transform: uppercase;
|
||||
background: var(--color-surface-raised);
|
||||
border: 1px solid var(--color-bronze);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-gold);
|
||||
padding: var(--btn-padding-md);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.btn-friend:hover { background: #4d3010; }
|
||||
|
||||
.friend-pending {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.07em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-gold-faint);
|
||||
}
|
||||
|
||||
.friend-status {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.07em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.btn-unfriend {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: var(--btn-font-sm);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
background: none;
|
||||
border: 1px solid rgba(180, 60, 60, 0.4);
|
||||
border-radius: var(--radius-sm);
|
||||
color: rgba(200, 80, 80, 0.6);
|
||||
padding: var(--btn-padding-sm);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.btn-unfriend:hover { border-color: #c84040; color: #e05050; }
|
||||
|
||||
/* ── Sections ── */
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
/* Header row: always the same height regardless of which buttons are visible */
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: var(--text-base);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-gold-dim);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ── Wishlist ── */
|
||||
.wishlist-block {
|
||||
margin: 0;
|
||||
padding: 1rem 1.5rem;
|
||||
background: rgba(61, 37, 7, 0.25);
|
||||
border-left: 3px solid rgba(200, 134, 26, 0.4);
|
||||
border-radius: 0 var(--radius-lg) var(--radius-lg) 0;
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: var(--text-lg);
|
||||
font-style: italic;
|
||||
color: var(--color-gold-muted);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* ── Card grid ── */
|
||||
.card-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
justify-content: flex-start;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
/*
|
||||
* Two-level card sizing:
|
||||
* .card-wrap — controls layout footprint (width/height + margin compensation)
|
||||
* .card-inner — controls visual scale from center (so hover grows symmetrically)
|
||||
*
|
||||
* card dimensions assumed: 300×420px at natural scale
|
||||
* at scale 0.62: visual 186×260px
|
||||
* margin-right = -(300 - 186) = -114px → next card starts 186+gap from here
|
||||
* margin-bottom = -(420 - 260) = -160px → row height becomes 260px
|
||||
*/
|
||||
.card-wrap {
|
||||
width: 300px;
|
||||
height: 420px;
|
||||
margin-right: -114px;
|
||||
margin-bottom: -160px;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
/* Pointer events on the wrapper would cover the full 300×420px layout box,
|
||||
most of which is empty space at scale 0.62. Delegate to card-inner only. */
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Elevate wrapper when the visible card inside is hovered */
|
||||
.card-wrap:has(.card-inner:hover) {
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.card-inner {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
transform: scale(0.62);
|
||||
transform-origin: top left;
|
||||
transition: transform 0.2s ease;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/*
|
||||
* Hover grows from the visual center, not the top-left corner.
|
||||
* At scale 0.62, visual center = (300*0.62/2, 420*0.62/2) = (93px, 130px).
|
||||
* At scale 0.78 from top-left, center would shift to (117px, 163px) — delta (+24, +33).
|
||||
* Pre-translate by (-24px, -34px) to cancel that shift, keeping center fixed.
|
||||
*/
|
||||
.card-inner:hover {
|
||||
transform: translate(-24px, -34px) scale(0.78);
|
||||
}
|
||||
|
||||
/* ── Expand / Collapse buttons ── */
|
||||
.expand-btn {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: var(--btn-font-sm);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(240, 180, 80, 0.5);
|
||||
background: none;
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: var(--btn-padding-sm);
|
||||
cursor: pointer;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
align-self: flex-start;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.expand-btn:hover {
|
||||
color: var(--color-gold);
|
||||
border-color: rgba(200, 134, 26, 0.6);
|
||||
}
|
||||
|
||||
/* ── Offer CTA ── */
|
||||
.offer-cta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
padding-top: 0.25rem;
|
||||
}
|
||||
|
||||
.challenge-sent {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.07em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.deck-select {
|
||||
width: 100%;
|
||||
background: var(--color-bg);
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-gold);
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: var(--text-md);
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.deck-select:focus { outline: none; border-color: var(--color-bronze); }
|
||||
|
||||
.challenge-error {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: var(--text-base);
|
||||
color: var(--color-error);
|
||||
margin: 0;
|
||||
min-height: 1.2em;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* ── Buttons ── */
|
||||
.btn-primary {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: var(--btn-font-lg);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
background: var(--color-bronze);
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-btn-text);
|
||||
padding: var(--btn-padding-lg);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, transform 0.1s;
|
||||
}
|
||||
|
||||
.btn-primary:hover { background: var(--color-bronze-hover); transform: translateY(-1px); }
|
||||
.btn-primary:active { transform: translateY(0); }
|
||||
a.btn-primary { text-decoration: none; }
|
||||
|
||||
.btn-secondary {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: var(--btn-font-md);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.07em;
|
||||
text-transform: uppercase;
|
||||
background: var(--color-surface-raised);
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-gold);
|
||||
padding: var(--btn-padding-md);
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.btn-secondary:hover { border-color: var(--color-bronze); color: var(--color-btn-text); }
|
||||
|
||||
/* ── States ── */
|
||||
.status-text {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: var(--text-md);
|
||||
font-style: italic;
|
||||
color: var(--color-gold-faint);
|
||||
text-align: center;
|
||||
margin-top: 4rem;
|
||||
}
|
||||
|
||||
.not-found {
|
||||
max-width: 400px;
|
||||
margin: 6rem auto 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.not-found-sigil { font-size: var(--text-3xl); color: rgba(107, 76, 30, 0.4); }
|
||||
|
||||
.not-found-title {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: var(--text-xl);
|
||||
font-weight: 700;
|
||||
color: rgba(240, 180, 80, 0.5);
|
||||
margin: 0;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.not-found-sub {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: var(--text-md);
|
||||
font-style: italic;
|
||||
color: rgba(240, 180, 80, 0.3);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.empty-profile { padding: 2rem 0; text-align: center; }
|
||||
|
||||
.empty-text {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: var(--text-md);
|
||||
font-style: italic;
|
||||
color: rgba(240, 180, 80, 0.25);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ── Modal ── */
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(5, 3, 0, 0.8);
|
||||
backdrop-filter: blur(4px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 100;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: 2rem 2.5rem;
|
||||
max-width: 400px;
|
||||
width: 100%;
|
||||
box-shadow: var(--shadow-elevated);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.modal-icon { font-size: var(--text-3xl); color: rgba(200, 134, 26, 0.6); }
|
||||
|
||||
.modal-title {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--color-gold);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: var(--text-md);
|
||||
font-style: italic;
|
||||
color: var(--color-gold-dim);
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ── Responsive ── */
|
||||
@media (max-width: 600px) {
|
||||
.profile-header { flex-direction: column; align-items: flex-start; gap: 1rem; }
|
||||
.username { font-size: var(--text-xl); }
|
||||
/* Smaller scale on narrow screens (same translate trick: delta = 300*(0.68-0.55)/2 = 19.5px, 420*(0.68-0.55)/2 = 27.3px) */
|
||||
.card-inner { transform: scale(0.55); }
|
||||
.card-inner:hover { transform: translate(-20px, -27px) scale(0.68); }
|
||||
.card-wrap {
|
||||
margin-right: calc(-(300px - 300px * 0.55));
|
||||
margin-bottom: calc(-(420px - 420px * 0.55));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -80,11 +80,9 @@
|
||||
</main>
|
||||
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;700&family=Crimson+Text:ital,wght@0,400;0,600;1,400&display=swap');
|
||||
|
||||
main {
|
||||
min-height: 100vh;
|
||||
background: #0d0a04;
|
||||
background: var(--color-bg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -93,9 +91,9 @@
|
||||
|
||||
.card {
|
||||
width: 380px;
|
||||
background: #3d2507;
|
||||
border: 2px solid #c8861a;
|
||||
border-radius: 12px;
|
||||
background: var(--color-surface);
|
||||
border: 2px solid var(--color-bronze);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: 2rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -104,17 +102,17 @@
|
||||
|
||||
.title {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 20px;
|
||||
font-size: var(--text-xl);
|
||||
font-weight: 700;
|
||||
color: #f5d060;
|
||||
color: var(--color-gold);
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 15px;
|
||||
color: rgba(245, 208, 96, 0.7);
|
||||
font-size: var(--text-md);
|
||||
color: var(--color-gold-dim);
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
}
|
||||
@@ -127,63 +125,65 @@
|
||||
|
||||
.field-label {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 10px;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(245, 208, 96, 0.5);
|
||||
color: var(--color-gold-faint);
|
||||
}
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
padding: 9px 12px;
|
||||
background: #221508;
|
||||
border: 1.5px solid #c8861a;
|
||||
border-radius: 6px;
|
||||
color: #f5d060;
|
||||
background: var(--color-surface);
|
||||
border: 1.5px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-gold);
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 15px;
|
||||
font-size: var(--text-md);
|
||||
box-sizing: border-box;
|
||||
outline: none;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
input:focus { border-color: #f5d060; }
|
||||
input::placeholder { color: rgba(245, 208, 96, 0.35); }
|
||||
input:focus { border-color: var(--color-bronze); }
|
||||
input::placeholder { color: var(--color-gold-faint); }
|
||||
|
||||
.btn {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
background: #c8861a;
|
||||
color: #fff8e0;
|
||||
padding: var(--btn-padding-lg);
|
||||
background: var(--color-bronze);
|
||||
color: var(--color-btn-text);
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
border-radius: var(--radius-md);
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 13px;
|
||||
font-size: var(--btn-font-lg);
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.btn:hover:not(:disabled) { background: #e09820; }
|
||||
.btn:hover:not(:disabled) { background: var(--color-bronze-hover); }
|
||||
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
.back-link {
|
||||
all: unset;
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 14px;
|
||||
color: rgba(245, 208, 96, 0.5);
|
||||
font-size: var(--text-base);
|
||||
color: var(--color-gold-faint);
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.back-link:hover { color: #f5d060; }
|
||||
.back-link:hover { color: var(--color-gold); }
|
||||
|
||||
.error {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 14px;
|
||||
color: #f06060;
|
||||
font-size: var(--text-base);
|
||||
color: var(--color-error);
|
||||
margin: 0;
|
||||
min-height: 1.4em;
|
||||
text-align: center;
|
||||
|
||||
+48
-51
@@ -1,30 +1,27 @@
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import { API_URL } from '$lib/api.js';
|
||||
import { apiFetch } from '$lib/api.js';
|
||||
import { goto } from '$app/navigation';
|
||||
import { onMount } from 'svelte';
|
||||
import CardSelector from '$lib/CardSelector.svelte';
|
||||
|
||||
let allCards = $state([]);
|
||||
let shards = $state(null);
|
||||
let selectedIds = $state(new Set());
|
||||
let selectedCards: any[] = $state([]); // bound from CardSelector
|
||||
let selectorOpen = $state(false);
|
||||
let shattering = $state(false);
|
||||
let result = $state(null); // { gained, shards }
|
||||
let result: { gained: number } | null = $state(null);
|
||||
let inDeckIds = $state(new Set());
|
||||
let selectorRef: any;
|
||||
|
||||
const selectedCards = $derived(allCards.filter(c => selectedIds.has(c.id)));
|
||||
const totalYield = $derived(selectedCards.reduce((sum, c) => sum + c.cost, 0));
|
||||
const totalYield = $derived(selectedCards.reduce((sum: number, c: any) => sum + c.cost, 0));
|
||||
|
||||
onMount(async () => {
|
||||
if (!localStorage.getItem('token')) { goto('/auth'); return; }
|
||||
const [cardsRes, profileRes, inDecksRes] = await Promise.all([
|
||||
apiFetch(`${API_URL}/cards`),
|
||||
const [profileRes, inDecksRes] = await Promise.all([
|
||||
apiFetch(`${API_URL}/profile`),
|
||||
apiFetch(`${API_URL}/cards/in-decks`),
|
||||
]);
|
||||
if (cardsRes.status === 401) { goto('/auth'); return; }
|
||||
allCards = await cardsRes.json();
|
||||
const profile = await profileRes.json();
|
||||
shards = profile.shards;
|
||||
if (inDecksRes.ok) inDeckIds = new Set(await inDecksRes.json());
|
||||
@@ -42,8 +39,8 @@
|
||||
const data = await res.json();
|
||||
shards = data.shards;
|
||||
result = { gained: data.gained };
|
||||
allCards = allCards.filter(c => !selectedIds.has(c.id));
|
||||
selectedIds = new Set();
|
||||
selectorRef?.refresh(); // refetch so shattered cards disappear
|
||||
}
|
||||
shattering = false;
|
||||
}
|
||||
@@ -53,7 +50,7 @@
|
||||
|
||||
<main>
|
||||
<div class="top">
|
||||
<h1 class="page-title">Shards</h1>
|
||||
<h1 class="page-title">Shatter</h1>
|
||||
{#if shards !== null}
|
||||
<div class="shards-display">
|
||||
<span class="shards-icon">◈</span>
|
||||
@@ -96,8 +93,9 @@
|
||||
{#if selectorOpen}
|
||||
<div class="selector-overlay">
|
||||
<CardSelector
|
||||
allCards={allCards}
|
||||
bind:this={selectorRef}
|
||||
bind:selectedIds={selectedIds}
|
||||
bind:selectedCards={selectedCards}
|
||||
{inDeckIds}
|
||||
onclose={() => { selectorOpen = false; }}
|
||||
/>
|
||||
@@ -105,11 +103,9 @@
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;700;900&family=Crimson+Text:ital,wght@0,400;0,600;1,400&display=swap');
|
||||
|
||||
main {
|
||||
min-height: 100vh;
|
||||
background: #0d0a04;
|
||||
background: var(--color-bg);
|
||||
padding: 2.5rem 2rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -130,7 +126,7 @@
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: clamp(22px, 4vw, 32px);
|
||||
font-weight: 900;
|
||||
color: #f0d080;
|
||||
color: var(--color-gold);
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
margin: 0;
|
||||
@@ -143,22 +139,23 @@
|
||||
}
|
||||
|
||||
.shards-icon {
|
||||
font-size: 20px;
|
||||
color: #7ecfcf;
|
||||
font-size: var(--text-xl);
|
||||
color: var(--color-cyan);
|
||||
position: relative;
|
||||
top: -0.1em;
|
||||
animation: shard-pulse 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.shards-amount {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 24px;
|
||||
font-size: var(--text-xl);
|
||||
font-weight: 700;
|
||||
color: #7ecfcf;
|
||||
color: var(--color-cyan);
|
||||
}
|
||||
|
||||
.shards-label {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 11px;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
@@ -168,23 +165,23 @@
|
||||
|
||||
.explainer {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 16px;
|
||||
font-size: var(--text-md);
|
||||
font-style: italic;
|
||||
color: rgba(240, 180, 80, 0.5);
|
||||
color: var(--color-gold-faint);
|
||||
margin: 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.store-hint {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 15px;
|
||||
font-size: var(--text-md);
|
||||
font-style: italic;
|
||||
color: rgba(240, 180, 80, 0.35);
|
||||
color: var(--color-gold-faint);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.store-link {
|
||||
color: #7ecfcf;
|
||||
color: var(--color-cyan);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
transition: color 0.15s;
|
||||
@@ -197,16 +194,16 @@
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
background: #0d2a0d;
|
||||
border: 1.5px solid #6aaa6a;
|
||||
border-radius: 8px;
|
||||
border: 1.5px solid var(--color-success);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 0.75rem 1.25rem;
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 15px;
|
||||
font-size: var(--text-md);
|
||||
font-weight: 700;
|
||||
color: #6aaa6a;
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.result-icon { color: #7ecfcf; position: relative; top: -0.1em; }
|
||||
.result-icon { color: var(--color-cyan); position: relative; top: -0.1em; animation: shard-pulse 3s ease-in-out infinite; }
|
||||
|
||||
.dismiss {
|
||||
margin-left: auto;
|
||||
@@ -214,11 +211,11 @@
|
||||
border: none;
|
||||
color: rgba(106, 170, 106, 0.5);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-size: var(--text-base);
|
||||
padding: 0 0 0 0.75rem;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
.dismiss:hover { color: #6aaa6a; }
|
||||
.dismiss:hover { color: var(--color-success); }
|
||||
|
||||
/* ── Action area ── */
|
||||
.action-area {
|
||||
@@ -232,15 +229,15 @@
|
||||
|
||||
.select-btn {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 13px;
|
||||
font-size: var(--btn-font-lg);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
background: #3d2507;
|
||||
border: 1.5px solid #c8861a;
|
||||
border-radius: 6px;
|
||||
color: #f0d080;
|
||||
padding: 10px 24px;
|
||||
background: var(--color-surface-raised);
|
||||
border: 1.5px solid var(--color-bronze);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-gold);
|
||||
padding: var(--btn-padding-lg);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
width: 100%;
|
||||
@@ -254,26 +251,26 @@
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 15px;
|
||||
font-size: var(--text-md);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.summary-count { color: #f0d080; }
|
||||
.summary-arrow { color: rgba(240, 180, 80, 0.35); }
|
||||
.summary-yield { color: #7ecfcf; display: flex; align-items: center; gap: 0.3rem; }
|
||||
.shards-icon-sm { font-size: 14px; color: #7ecfcf; position: relative; top: -0.1em; }
|
||||
.summary-count { color: var(--color-gold); }
|
||||
.summary-arrow { color: var(--color-gold-faint); }
|
||||
.summary-yield { color: var(--color-cyan); display: flex; align-items: center; gap: 0.3rem; }
|
||||
.shards-icon-sm { font-size: var(--text-base); color: var(--color-cyan); position: relative; top: -0.1em; }
|
||||
|
||||
.shatter-btn {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 13px;
|
||||
font-size: var(--btn-font-lg);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
background: #1a1008;
|
||||
border: 1.5px solid #7ecfcf;
|
||||
border-radius: 6px;
|
||||
color: #7ecfcf;
|
||||
padding: 10px 24px;
|
||||
background: var(--color-surface);
|
||||
border: 1.5px solid var(--color-cyan);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-cyan);
|
||||
padding: var(--btn-padding-lg);
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
transition: background 0.15s;
|
||||
@@ -286,7 +283,7 @@
|
||||
.selector-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 200;
|
||||
z-index: var(--z-dropdown);
|
||||
background: rgba(0, 0, 0, 0.85);
|
||||
}
|
||||
</style>
|
||||
@@ -1,4 +1,4 @@
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import { API_URL } from '$lib/api.js';
|
||||
import { apiFetch } from '$lib/api.js';
|
||||
import { goto } from '$app/navigation';
|
||||
@@ -7,11 +7,11 @@
|
||||
import { page } from '$app/stores';
|
||||
import Card from '$lib/Card.svelte';
|
||||
|
||||
let shards = $state(null);
|
||||
let buying = $state(null); // which quantity is being bought
|
||||
let flash = $state(null); // { quantity, ok }
|
||||
let shardPackages = $state([]);
|
||||
let buyingShards = $state(null);
|
||||
let shards: number | null = $state(null);
|
||||
let buying: number | null = $state(null);
|
||||
let flash: { quantity: number; ok: boolean } | null = $state(null);
|
||||
let shardPackages: any[] = $state([]);
|
||||
let buyingShards: string | null = $state(null);
|
||||
let paymentSuccess = $state(false);
|
||||
|
||||
const packages = [
|
||||
@@ -31,7 +31,7 @@
|
||||
const profile = await profileRes.json();
|
||||
shards = profile.shards;
|
||||
const config = await configRes.json();
|
||||
shardPackages = Object.entries(config.shard_packages).map(([id, pkg]) => ({ id, ...pkg }));
|
||||
shardPackages = Object.entries(config.shard_packages).map(([id, pkg]) => ({ id, ...(pkg as object) }));
|
||||
|
||||
if ($page.url.searchParams.get('payment') === 'success') {
|
||||
paymentSuccess = true;
|
||||
@@ -41,7 +41,7 @@
|
||||
}
|
||||
});
|
||||
|
||||
async function buyWithStripe(packageId) {
|
||||
async function buyWithStripe(packageId: string) {
|
||||
if (buyingShards) return;
|
||||
buyingShards = packageId;
|
||||
const res = await apiFetch(`${API_URL}/store/stripe/checkout`, {
|
||||
@@ -56,8 +56,8 @@
|
||||
buyingShards = null;
|
||||
}
|
||||
|
||||
async function buy(quantity, cost) {
|
||||
if (buying !== null || shards < cost) return;
|
||||
async function buy(quantity: number, cost: number) {
|
||||
if (buying !== null || shards === null || shards < cost) return;
|
||||
buying = quantity;
|
||||
const res = await apiFetch(`${API_URL}/store/buy`, {
|
||||
method: 'POST',
|
||||
@@ -79,9 +79,10 @@
|
||||
const SPECIFIC_CARD_COST = 1000;
|
||||
let specificPhase = $state('idle'); // idle | input | generating | revealing | done
|
||||
let wikiTitle = $state('');
|
||||
let specificCard = $state(null);
|
||||
let specificCard: any = $state(null);
|
||||
let specificFlipped = $state(false);
|
||||
let specificError = $state('');
|
||||
let specificAction = $state({ favorited: false, tradeListed: false, shattered: false, shardGain: 0 });
|
||||
|
||||
function openSpecificModal() {
|
||||
wikiTitle = '';
|
||||
@@ -93,6 +94,36 @@
|
||||
specificPhase = 'idle';
|
||||
specificCard = null;
|
||||
specificFlipped = false;
|
||||
specificAction = { favorited: false, tradeListed: false, shattered: false, shardGain: 0 };
|
||||
}
|
||||
|
||||
async function specificToggleFavorite() {
|
||||
const res = await apiFetch(`${API_URL}/cards/${specificCard.id}/favorite`, { method: 'POST' });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
specificAction = { ...specificAction, favorited: data.is_favorite };
|
||||
}
|
||||
}
|
||||
|
||||
async function specificToggleTrade() {
|
||||
const res = await apiFetch(`${API_URL}/cards/${specificCard.id}/willing-to-trade`, { method: 'POST' });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
specificAction = { ...specificAction, tradeListed: data.willing_to_trade };
|
||||
}
|
||||
}
|
||||
|
||||
async function specificShatter() {
|
||||
const res = await apiFetch(`${API_URL}/shards/shatter`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ card_ids: [specificCard.id] }),
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
shards = data.shards;
|
||||
specificAction = { ...specificAction, shattered: true, shardGain: data.gained };
|
||||
}
|
||||
}
|
||||
|
||||
async function buySpecificCard() {
|
||||
@@ -122,7 +153,7 @@
|
||||
}
|
||||
|
||||
// How many mini-packs to fan out per package
|
||||
function fanCount(quantity) {
|
||||
function fanCount(quantity: number) {
|
||||
if (quantity === 1) return 1;
|
||||
if (quantity === 5) return 2;
|
||||
if (quantity === 10) return 3;
|
||||
@@ -130,7 +161,7 @@
|
||||
}
|
||||
|
||||
// Rotation and offset for each pack in the fan
|
||||
function packTransform(index, total) {
|
||||
function packTransform(index: number, total: number) {
|
||||
if (total === 1) return 'rotate(0deg) translateY(0px)';
|
||||
const spread = 10; // degrees between each pack
|
||||
const mid = (total - 1) / 2;
|
||||
@@ -148,7 +179,7 @@
|
||||
<span class="shards-icon">◈</span>
|
||||
<span class="shards-amount">{shards}</span>
|
||||
<span class="shards-label">Shards</span>
|
||||
<a href="/shards" class="shards-link">shatter cards</a>
|
||||
<a href="/shatter" class="shards-link">shatter cards</a>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -179,12 +210,12 @@
|
||||
|
||||
<button
|
||||
class="buy-btn"
|
||||
class:flash-ok={isFlashing && flash.ok}
|
||||
class:flash-err={isFlashing && !flash.ok}
|
||||
class:flash-ok={isFlashing && flash?.ok}
|
||||
class:flash-err={isFlashing && !flash?.ok}
|
||||
onclick={() => buy(pkg.quantity, pkg.cost)}
|
||||
disabled={!canAfford || buying !== null}
|
||||
>
|
||||
{#if isFlashing && flash.ok}
|
||||
{#if isFlashing && flash?.ok}
|
||||
Purchased!
|
||||
{:else if buying === pkg.quantity}
|
||||
...
|
||||
@@ -236,7 +267,7 @@
|
||||
{#each shardPackages as pkg}
|
||||
<div class="shard-card">
|
||||
{#if pkg.bonus > 0}
|
||||
<div class="shard-sticker"><span class="sticker-icon">◈</span>{pkg.bonus}<br/>BONUS</div>
|
||||
<div class="shard-sticker"><span class="sticker-icon">◈</span>{pkg.bonus}<br/>BONUS*</div>
|
||||
{/if}
|
||||
<span class="shard-amount"><span class="shard-icon">◈</span> {pkg.shards}</span>
|
||||
<span class="shard-price">{pkg.price_label}</span>
|
||||
@@ -250,6 +281,9 @@
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{#if shardPackages.some(p => p.bonus > 0)}
|
||||
<p class="bonus-footnote">* compared to the per-shard price of the 100 shards pack</p>
|
||||
{/if}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -300,6 +334,30 @@
|
||||
Your card is being generated…
|
||||
</p>
|
||||
|
||||
<div class="pack-card-actions" class:actions-visible={specificPhase === 'done'}>
|
||||
{#if specificAction.shattered}
|
||||
<span class="shard-gained">+{specificAction.shardGain} ◈</span>
|
||||
{:else}
|
||||
<button
|
||||
class="pack-action-btn fav"
|
||||
class:active={specificAction.favorited}
|
||||
onclick={specificToggleFavorite}
|
||||
title="Favorite"
|
||||
>{specificAction.favorited ? '★' : '☆'}</button>
|
||||
<button
|
||||
class="pack-action-btn trade"
|
||||
class:active={specificAction.tradeListed}
|
||||
onclick={specificToggleTrade}
|
||||
title="Mark for Trade"
|
||||
>⇄</button>
|
||||
<button
|
||||
class="pack-action-btn shatter"
|
||||
onclick={specificShatter}
|
||||
title="Shatter for shards"
|
||||
>◈</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="close-reveal-btn"
|
||||
class:hidden={specificPhase !== 'done'}
|
||||
@@ -312,12 +370,10 @@
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;700;900&family=Crimson+Text:ital,wght@0,400;0,600;1,400&display=swap');
|
||||
|
||||
main {
|
||||
height: 100vh;
|
||||
overflow-y: auto;
|
||||
background: #0d0a04;
|
||||
background: var(--color-bg);
|
||||
padding: 2.5rem 2rem 5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -336,7 +392,7 @@
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: clamp(22px, 4vw, 32px);
|
||||
font-weight: 900;
|
||||
color: #f0d080;
|
||||
color: var(--color-gold);
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
margin: 0;
|
||||
@@ -350,38 +406,39 @@
|
||||
|
||||
.shards-link {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 10px;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(126, 207, 207, 0.6);
|
||||
border: 1px solid rgba(126, 207, 207, 0.3);
|
||||
border-radius: 4px;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 3px 8px;
|
||||
text-decoration: none;
|
||||
margin-top: 4px;
|
||||
margin-left: 0.5rem;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
}
|
||||
.shards-link:hover { color: #7ecfcf; border-color: rgba(126, 207, 207, 0.7); }
|
||||
.shards-link:hover { color: var(--color-cyan); border-color: rgba(126, 207, 207, 0.7); }
|
||||
|
||||
.shards-icon {
|
||||
font-size: 20px;
|
||||
color: #7ecfcf;
|
||||
font-size: var(--text-xl);
|
||||
color: var(--color-cyan);
|
||||
position: relative;
|
||||
top: -0.1em;
|
||||
animation: shard-pulse 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.shards-amount {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 24px;
|
||||
font-size: var(--text-xl);
|
||||
font-weight: 700;
|
||||
color: #7ecfcf;
|
||||
color: var(--color-cyan);
|
||||
}
|
||||
|
||||
.shards-label {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 11px;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
@@ -399,8 +456,8 @@
|
||||
|
||||
.pkg-card {
|
||||
position: relative;
|
||||
background: #1a1008;
|
||||
border: 1.5px solid rgba(107, 76, 30, 0.5);
|
||||
background: var(--color-surface);
|
||||
border: 1.5px solid var(--color-border-subtle);
|
||||
border-radius: 14px;
|
||||
padding: 2rem 1.5rem 1.5rem;
|
||||
display: flex;
|
||||
@@ -412,7 +469,7 @@
|
||||
}
|
||||
|
||||
.pkg-card:not(.cannot-afford):hover {
|
||||
border-color: #c8861a;
|
||||
border-color: var(--color-bronze);
|
||||
background: #211408;
|
||||
}
|
||||
|
||||
@@ -431,7 +488,7 @@
|
||||
background: #f5d800;
|
||||
color: #c0000a;
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 11px;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 900;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -528,9 +585,9 @@
|
||||
/* ── Labels ── */
|
||||
.qty-label {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 15px;
|
||||
font-size: var(--text-md);
|
||||
font-weight: 700;
|
||||
color: #f0d080;
|
||||
color: var(--color-gold);
|
||||
margin: 0;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
@@ -539,12 +596,12 @@
|
||||
.buy-btn {
|
||||
width: 100%;
|
||||
padding: 8px 0;
|
||||
background: #3d2507;
|
||||
border: 1.5px solid #c8861a;
|
||||
border-radius: 6px;
|
||||
color: #f0d080;
|
||||
background: var(--color-surface-raised);
|
||||
border: 1.5px solid var(--color-bronze);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-gold);
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 13px;
|
||||
font-size: var(--btn-font-lg);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
cursor: pointer;
|
||||
@@ -572,13 +629,13 @@
|
||||
|
||||
.buy-btn.flash-err {
|
||||
background: #4a1a1a;
|
||||
border-color: #c85050;
|
||||
color: #c85050;
|
||||
border-color: #c84040;
|
||||
color: #c84040;
|
||||
}
|
||||
|
||||
.cost-icon {
|
||||
color: #7ecfcf;
|
||||
font-size: 12px;
|
||||
color: var(--color-cyan);
|
||||
font-size: var(--text-sm);
|
||||
position: relative;
|
||||
top: -0.1em;
|
||||
}
|
||||
@@ -587,7 +644,7 @@
|
||||
width: 100%;
|
||||
max-width: 800px;
|
||||
height: 1px;
|
||||
background: rgba(107, 76, 30, 0.3);
|
||||
background: var(--color-border-dim);
|
||||
}
|
||||
|
||||
.shard-section {
|
||||
@@ -597,33 +654,44 @@
|
||||
gap: 1rem;
|
||||
width: 100%;
|
||||
max-width: 800px;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 16px;
|
||||
font-size: var(--text-base);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(240, 180, 80, 0.5);
|
||||
color: var(--color-gold-faint);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.section-hint {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 15px;
|
||||
font-size: var(--text-md);
|
||||
font-style: italic;
|
||||
color: rgba(240, 180, 80, 0.35);
|
||||
color: var(--color-gold-faint);
|
||||
margin: -0.5rem 0 0;
|
||||
}
|
||||
|
||||
.bonus-footnote {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: var(--text-md);
|
||||
font-style: italic;
|
||||
color: var(--color-gold-faint);
|
||||
opacity: 0.6;
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.payment-success {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 15px;
|
||||
color: #6aaa6a;
|
||||
font-size: var(--text-md);
|
||||
color: var(--color-success);
|
||||
background: #0d2a0d;
|
||||
border: 1px solid #6aaa6a;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--color-success);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 0.6rem 1.2rem;
|
||||
}
|
||||
|
||||
@@ -631,12 +699,13 @@
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 1.25rem;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.shard-card {
|
||||
position: relative;
|
||||
background: #1a1008;
|
||||
border: 1.5px solid rgba(107, 76, 30, 0.4);
|
||||
background: var(--color-surface);
|
||||
border: 1.5px solid var(--color-border-subtle);
|
||||
border-radius: 14px;
|
||||
padding: 2rem 2rem 1.75rem;
|
||||
display: flex;
|
||||
@@ -651,14 +720,14 @@
|
||||
position: absolute;
|
||||
top: -14px;
|
||||
right: -14px;
|
||||
background: #7ecfcf;
|
||||
color: #0d0a04;
|
||||
background: var(--color-cyan);
|
||||
color: var(--color-bg);
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 12px;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 900;
|
||||
line-height: 1.3;
|
||||
padding: 8px 11px;
|
||||
border-radius: 5px;
|
||||
border-radius: var(--radius-md);
|
||||
text-align: center;
|
||||
transform: rotate(8deg);
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.6);
|
||||
@@ -670,13 +739,13 @@
|
||||
top: -0.1em;
|
||||
}
|
||||
|
||||
.shard-card:hover { border-color: #7ecfcf; }
|
||||
.shard-card:hover { border-color: var(--color-cyan); }
|
||||
|
||||
.shard-amount {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 26px;
|
||||
font-size: var(--text-2xl);
|
||||
font-weight: 700;
|
||||
color: #7ecfcf;
|
||||
color: var(--color-cyan);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
@@ -685,12 +754,13 @@
|
||||
.shard-icon {
|
||||
position: relative;
|
||||
top: -0.1em;
|
||||
animation: shard-pulse 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.shard-price {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 19px;
|
||||
color: rgba(240, 180, 80, 0.6);
|
||||
font-size: var(--text-lg);
|
||||
color: var(--color-gold-dim);
|
||||
}
|
||||
|
||||
.stripe-btn {
|
||||
@@ -698,10 +768,10 @@
|
||||
padding: 10px 0;
|
||||
background: #1a3a4a;
|
||||
border: 1.5px solid #4a9aba;
|
||||
border-radius: 6px;
|
||||
border-radius: var(--radius-md);
|
||||
color: #a0d8ef;
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 13px;
|
||||
font-size: var(--btn-font-lg);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
@@ -723,8 +793,8 @@
|
||||
}
|
||||
|
||||
.specific-card-preview {
|
||||
background: #1a1008;
|
||||
border: 1.5px solid rgba(107, 76, 30, 0.4);
|
||||
background: var(--color-surface);
|
||||
border: 1.5px solid var(--color-border-subtle);
|
||||
border-radius: 14px;
|
||||
padding: 2rem 2.5rem;
|
||||
width: 100%;
|
||||
@@ -732,11 +802,11 @@
|
||||
|
||||
.specific-card-title {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 14px;
|
||||
font-size: var(--text-base);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(240, 180, 80, 0.6);
|
||||
color: var(--color-gold-dim);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@@ -775,7 +845,7 @@
|
||||
|
||||
.specific-desc {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 16px;
|
||||
font-size: var(--text-md);
|
||||
font-style: italic;
|
||||
color: rgba(240, 180, 80, 0.55);
|
||||
line-height: 1.5;
|
||||
@@ -784,15 +854,15 @@
|
||||
|
||||
.specific-buy-btn {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 13px;
|
||||
font-size: var(--btn-font-lg);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
background: #3d2507;
|
||||
border: 1.5px solid #c8861a;
|
||||
border-radius: 6px;
|
||||
color: #f0d080;
|
||||
padding: 10px 20px;
|
||||
background: var(--color-surface-raised);
|
||||
border: 1.5px solid var(--color-bronze);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-gold);
|
||||
padding: var(--btn-padding-lg);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -805,7 +875,7 @@
|
||||
|
||||
.specific-cant-afford {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 13px;
|
||||
font-size: var(--text-base);
|
||||
font-style: italic;
|
||||
color: rgba(200, 100, 80, 0.7);
|
||||
margin: 0;
|
||||
@@ -815,7 +885,7 @@
|
||||
.specific-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 300;
|
||||
z-index: var(--z-modal);
|
||||
background: rgba(0,0,0,0.92);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -824,8 +894,8 @@
|
||||
|
||||
/* Input modal */
|
||||
.specific-modal {
|
||||
background: #1a1008;
|
||||
border: 1.5px solid rgba(107, 76, 30, 0.6);
|
||||
background: var(--color-surface);
|
||||
border: 1.5px solid var(--color-border-subtle);
|
||||
border-radius: 14px;
|
||||
padding: 2.5rem 2.5rem 2rem;
|
||||
display: flex;
|
||||
@@ -836,40 +906,40 @@
|
||||
|
||||
.modal-title {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 18px;
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: #f0d080;
|
||||
color: var(--color-gold);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.modal-hint {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 15px;
|
||||
font-size: var(--text-md);
|
||||
font-style: italic;
|
||||
color: rgba(240, 180, 80, 0.5);
|
||||
color: var(--color-gold-faint);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.wiki-input {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 17px;
|
||||
background: #0d0a04;
|
||||
border: 1.5px solid rgba(107, 76, 30, 0.6);
|
||||
border-radius: 6px;
|
||||
color: #f0d080;
|
||||
font-size: var(--text-lg);
|
||||
background: var(--color-bg);
|
||||
border: 1.5px solid var(--color-border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-gold);
|
||||
padding: 10px 14px;
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.wiki-input:focus { border-color: #c8861a; }
|
||||
.wiki-input::placeholder { color: rgba(240, 180, 80, 0.25); }
|
||||
.wiki-input:focus { border-color: var(--color-bronze); }
|
||||
.wiki-input::placeholder { color: var(--color-gold-faint); }
|
||||
|
||||
.modal-error {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 14px;
|
||||
color: #c85050;
|
||||
font-size: var(--text-base);
|
||||
color: var(--color-error);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@@ -882,30 +952,30 @@
|
||||
|
||||
.modal-cancel {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 11px;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
background: none;
|
||||
border: 1px solid rgba(107, 76, 30, 0.5);
|
||||
border-radius: 5px;
|
||||
color: rgba(240, 180, 80, 0.5);
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-gold-faint);
|
||||
padding: 8px 18px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, color 0.15s;
|
||||
}
|
||||
.modal-cancel:hover { border-color: #c8861a; color: #f0d080; }
|
||||
.modal-cancel:hover { border-color: var(--color-bronze); color: var(--color-gold); }
|
||||
|
||||
.modal-confirm {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 11px;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
background: #3d2507;
|
||||
border: 1.5px solid #c8861a;
|
||||
border-radius: 5px;
|
||||
color: #f0d080;
|
||||
background: var(--color-surface-raised);
|
||||
border: 1.5px solid var(--color-bronze);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-gold);
|
||||
padding: 8px 18px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
@@ -936,7 +1006,7 @@
|
||||
inset: 0;
|
||||
background: white;
|
||||
opacity: 0;
|
||||
z-index: 9999;
|
||||
z-index: 300;
|
||||
pointer-events: none;
|
||||
border-radius: 16px;
|
||||
}
|
||||
@@ -1000,26 +1070,98 @@
|
||||
|
||||
.reveal-label {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 17px;
|
||||
font-size: var(--text-lg);
|
||||
font-style: italic;
|
||||
color: rgba(240, 180, 80, 0.6);
|
||||
color: var(--color-gold-dim);
|
||||
margin: 0;
|
||||
transition: opacity 0.6s ease;
|
||||
}
|
||||
.reveal-label.hidden { opacity: 0; }
|
||||
.close-reveal-btn.hidden { opacity: 0; pointer-events: none; }
|
||||
|
||||
.pack-card-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
justify-content: center;
|
||||
height: 34px;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.pack-card-actions.actions-visible {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.pack-action-btn {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: var(--text-base);
|
||||
font-weight: 700;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: var(--radius-full);
|
||||
border: 1.5px solid;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.pack-action-btn.fav {
|
||||
background: rgba(30, 20, 0, 0.7);
|
||||
border-color: rgba(200, 160, 0, 0.5);
|
||||
color: rgba(240, 200, 0, 0.6);
|
||||
}
|
||||
.pack-action-btn.fav:hover, .pack-action-btn.fav.active {
|
||||
background: rgba(60, 45, 0, 0.9);
|
||||
border-color: #c8a000;
|
||||
color: #f0c800;
|
||||
}
|
||||
|
||||
.pack-action-btn.trade {
|
||||
background: rgba(0, 25, 25, 0.7);
|
||||
border-color: rgba(0, 150, 150, 0.5);
|
||||
color: rgba(0, 190, 190, 0.6);
|
||||
}
|
||||
.pack-action-btn.trade:hover, .pack-action-btn.trade.active {
|
||||
background: rgba(0, 50, 50, 0.9);
|
||||
border-color: #00a0a0;
|
||||
color: var(--color-cyan);
|
||||
}
|
||||
|
||||
.pack-action-btn.shatter {
|
||||
background: rgba(0, 20, 30, 0.7);
|
||||
border-color: rgba(100, 200, 200, 0.4);
|
||||
color: rgba(126, 207, 207, 0.6);
|
||||
}
|
||||
.pack-action-btn.shatter:hover {
|
||||
background: rgba(0, 40, 50, 0.9);
|
||||
border-color: var(--color-cyan);
|
||||
color: var(--color-cyan);
|
||||
}
|
||||
|
||||
.shard-gained {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: var(--text-base);
|
||||
font-weight: 700;
|
||||
color: var(--color-cyan);
|
||||
padding: 6px 12px;
|
||||
background: rgba(0, 40, 50, 0.8);
|
||||
border: 1.5px solid rgba(126, 207, 207, 0.5);
|
||||
border-radius: 16px;
|
||||
animation: shard-pulse 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.close-reveal-btn {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 13px;
|
||||
font-size: var(--btn-font-lg);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
background: rgba(60,30,5,0.85);
|
||||
border: 1.5px solid #c8861a;
|
||||
border-radius: 6px;
|
||||
color: #f0d080;
|
||||
padding: 10px 32px;
|
||||
border: 1.5px solid var(--color-bronze);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-gold);
|
||||
padding: var(--btn-padding-lg);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import { API_URL, WS_URL, apiFetch } from '$lib/api.js';
|
||||
import { goto } from '$app/navigation';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
@@ -9,13 +9,17 @@
|
||||
|
||||
let phase = $state('idle'); // idle | queuing | trading | complete
|
||||
let error = $state('');
|
||||
let reconnecting = $state(false);
|
||||
let queueReconnectDelay = 1000;
|
||||
let queueReconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let tradeReconnectDelay = 1000;
|
||||
let tradeReconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
let queueWs = null;
|
||||
let tradeWs = null;
|
||||
let queueWs: WebSocket | null = null;
|
||||
let tradeWs: WebSocket | null = null;
|
||||
let tradeId = $state('');
|
||||
|
||||
let allCards = $state([]); // user's full card collection (for selector)
|
||||
let tradeState = $state(null); // latest trade state from server
|
||||
let tradeState: any = $state(null); // latest trade state from server
|
||||
|
||||
let selectorOpen = $state(false);
|
||||
let selectorIds = $state(new Set());
|
||||
@@ -35,12 +39,11 @@
|
||||
|
||||
onMount(async () => {
|
||||
if (!token()) { goto('/auth'); return; }
|
||||
const res = await apiFetch(`${API_URL}/cards`);
|
||||
if (!res.ok) { goto('/auth'); return; }
|
||||
allCards = await res.json();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
clearTimeout(queueReconnectTimer);
|
||||
clearTimeout(tradeReconnectTimer);
|
||||
queueWs?.close();
|
||||
tradeWs?.close();
|
||||
});
|
||||
@@ -49,12 +52,18 @@
|
||||
error = '';
|
||||
phase = 'queuing';
|
||||
queueWs = new WebSocket(`${WS_URL}/ws/trade/queue`);
|
||||
queueWs.onopen = () => queueWs.send(token());
|
||||
queueWs.onopen = () => {
|
||||
queueWs!.send(token()!);
|
||||
reconnecting = false;
|
||||
queueReconnectDelay = 1000;
|
||||
};
|
||||
queueWs.onmessage = (e) => {
|
||||
const msg = JSON.parse(e.data);
|
||||
if (msg.type === 'trade_start') {
|
||||
tradeId = msg.trade_id;
|
||||
queueWs.close();
|
||||
// Set phase before close so onclose doesn't trigger a reconnect
|
||||
phase = 'trading';
|
||||
queueWs!.close();
|
||||
connectToTrade();
|
||||
} else if (msg.type === 'error') {
|
||||
error = msg.message;
|
||||
@@ -62,22 +71,37 @@
|
||||
}
|
||||
};
|
||||
queueWs.onerror = () => { error = 'Connection failed'; phase = 'idle'; };
|
||||
queueWs.onclose = () => {
|
||||
if (phase === 'queuing') {
|
||||
reconnecting = true;
|
||||
queueReconnectTimer = setTimeout(() => {
|
||||
queueReconnectDelay = Math.min(queueReconnectDelay * 2, 30000);
|
||||
joinQueue();
|
||||
}, queueReconnectDelay);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function cancelQueue() {
|
||||
queueWs?.close();
|
||||
clearTimeout(queueReconnectTimer);
|
||||
phase = 'idle';
|
||||
reconnecting = false;
|
||||
queueWs?.close();
|
||||
}
|
||||
|
||||
function connectToTrade() {
|
||||
phase = 'trading';
|
||||
tradeWs = new WebSocket(`${WS_URL}/ws/trade/${tradeId}`);
|
||||
tradeWs.onopen = () => tradeWs.send(token());
|
||||
tradeWs.onopen = () => {
|
||||
tradeWs!.send(token()!);
|
||||
reconnecting = false;
|
||||
tradeReconnectDelay = 1000;
|
||||
};
|
||||
tradeWs.onmessage = (e) => {
|
||||
const msg = JSON.parse(e.data);
|
||||
if (msg.type === 'state') {
|
||||
tradeState = msg.state;
|
||||
} else if (msg.type === 'trade_complete') {
|
||||
// Set phase before close so onclose doesn't trigger a reconnect
|
||||
phase = 'complete';
|
||||
tradeWs?.close();
|
||||
} else if (msg.type === 'error') {
|
||||
@@ -90,9 +114,15 @@
|
||||
}
|
||||
}
|
||||
};
|
||||
tradeWs.onerror = () => { error = 'Connection lost'; phase = 'idle'; };
|
||||
tradeWs.onclose = (e) => {
|
||||
if (phase === 'trading') { error = 'Connection lost'; phase = 'idle'; }
|
||||
tradeWs.onerror = () => { error = 'Connection lost'; reconnecting = false; phase = 'idle'; };
|
||||
tradeWs.onclose = () => {
|
||||
if (phase === 'trading') {
|
||||
reconnecting = true;
|
||||
tradeReconnectTimer = setTimeout(() => {
|
||||
tradeReconnectDelay = Math.min(tradeReconnectDelay * 2, 30000);
|
||||
connectToTrade();
|
||||
}, tradeReconnectDelay);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -100,7 +130,7 @@
|
||||
if (myOffer.accepted) {
|
||||
tradeWs?.send(JSON.stringify({ type: 'unaccept' }));
|
||||
}
|
||||
selectorIds = new Set(myOffer.cards.map(c => c.id));
|
||||
selectorIds = new Set(myOffer.cards.map((c: any) => c.id));
|
||||
selectorOpen = true;
|
||||
}
|
||||
|
||||
@@ -118,7 +148,9 @@
|
||||
}
|
||||
|
||||
function reset() {
|
||||
clearTimeout(tradeReconnectTimer);
|
||||
phase = 'idle';
|
||||
reconnecting = false;
|
||||
tradeState = null;
|
||||
tradeId = '';
|
||||
error = '';
|
||||
@@ -140,7 +172,7 @@
|
||||
{:else if phase === 'queuing'}
|
||||
<div class="center-screen">
|
||||
<div class="spinner"></div>
|
||||
<p class="searching-text">Searching for a trade partner...</p>
|
||||
<p class="searching-text">{reconnecting ? 'Reconnecting...' : 'Searching for a trade partner...'}</p>
|
||||
<button class="cancel-btn" onclick={cancelQueue}>Cancel</button>
|
||||
</div>
|
||||
|
||||
@@ -149,7 +181,6 @@
|
||||
{#if selectorOpen}
|
||||
<div class="selector-overlay">
|
||||
<CardSelector
|
||||
allCards={allCards}
|
||||
bind:selectedIds={selectorIds}
|
||||
onclose={closeSelector}
|
||||
/>
|
||||
@@ -185,7 +216,7 @@
|
||||
|
||||
<div class="panel their-panel">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">{partnerUsername || 'Partner'}'s Offer</span>
|
||||
<span class="panel-title">{#if partnerUsername}<a href="/profile/{partnerUsername}" target="_blank" class="partner-link">{partnerUsername}</a>{:else}Partner{/if}'s Offer</span>
|
||||
{#if theirOffer.accepted}
|
||||
<span class="accepted-badge">Accepted ✓</span>
|
||||
{/if}
|
||||
@@ -211,7 +242,9 @@
|
||||
<div class="action-bar">
|
||||
<button class="choose-btn" onclick={openSelector}>Choose Cards</button>
|
||||
|
||||
{#if error}
|
||||
{#if reconnecting}
|
||||
<span class="reconnecting-text">Reconnecting...</span>
|
||||
{:else if error}
|
||||
<span class="error-inline">{error}</span>
|
||||
{/if}
|
||||
|
||||
@@ -246,11 +279,10 @@
|
||||
</main>
|
||||
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;700&family=Crimson+Text:ital,wght@0,400;0,600;1,400&display=swap');
|
||||
|
||||
main {
|
||||
height: calc(100vh - 56px);
|
||||
background: #0d0a04;
|
||||
background: var(--color-bg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
@@ -270,40 +302,40 @@
|
||||
|
||||
.title {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 36px;
|
||||
font-size: var(--text-3xl);
|
||||
font-weight: 700;
|
||||
color: #f0d080;
|
||||
color: var(--color-gold);
|
||||
margin: 0;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 18px;
|
||||
font-size: var(--text-lg);
|
||||
font-style: italic;
|
||||
color: rgba(240, 180, 80, 0.6);
|
||||
color: var(--color-gold-dim);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.error {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 15px;
|
||||
color: #c85050;
|
||||
font-size: var(--text-md);
|
||||
color: var(--color-error);
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.primary-btn {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 13px;
|
||||
font-size: var(--btn-font-lg);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
background: #3d2507;
|
||||
border: 1px solid #c8861a;
|
||||
border-radius: 4px;
|
||||
color: #f0d080;
|
||||
padding: 10px 28px;
|
||||
background: var(--color-surface-raised);
|
||||
border: 1px solid var(--color-bronze);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-gold);
|
||||
padding: var(--btn-padding-lg);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
margin-top: 0.5rem;
|
||||
@@ -313,24 +345,24 @@
|
||||
|
||||
.cancel-btn {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 11px;
|
||||
font-size: var(--btn-font-md);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
background: none;
|
||||
border: 1px solid rgba(107, 76, 30, 0.5);
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
border-radius: var(--radius-sm);
|
||||
color: rgba(240, 180, 80, 0.5);
|
||||
padding: 6px 18px;
|
||||
padding: var(--btn-padding-md);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.cancel-btn:hover { border-color: #c8861a; color: #f0d080; }
|
||||
.cancel-btn:hover { border-color: var(--color-bronze); color: var(--color-gold); }
|
||||
|
||||
.searching-text {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 18px;
|
||||
font-size: var(--text-lg);
|
||||
font-style: italic;
|
||||
color: rgba(240, 180, 80, 0.7);
|
||||
margin: 0;
|
||||
@@ -340,8 +372,8 @@
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid rgba(200, 134, 26, 0.2);
|
||||
border-top-color: #c8861a;
|
||||
border-radius: 50%;
|
||||
border-top-color: var(--color-bronze);
|
||||
border-radius: var(--radius-full);
|
||||
animation: spin 0.9s linear infinite;
|
||||
}
|
||||
|
||||
@@ -349,20 +381,20 @@
|
||||
|
||||
.complete-icon {
|
||||
font-size: 56px;
|
||||
color: #6aaa6a;
|
||||
color: var(--color-success);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.secondary-link {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 15px;
|
||||
font-size: var(--text-md);
|
||||
font-style: italic;
|
||||
color: rgba(240, 180, 80, 0.5);
|
||||
text-decoration: underline;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.secondary-link:hover { color: #f0d080; }
|
||||
.secondary-link:hover { color: var(--color-gold); }
|
||||
|
||||
/* ── Trade layout ── */
|
||||
|
||||
@@ -394,27 +426,30 @@
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1rem 1.5rem 0.75rem;
|
||||
border-bottom: 1px solid rgba(107, 76, 30, 0.3);
|
||||
border-bottom: 1px solid var(--color-border-dim);
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 14px;
|
||||
font-size: var(--text-base);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(240, 180, 80, 0.7);
|
||||
}
|
||||
|
||||
.partner-link { text-decoration: none; transition: color 0.15s; }
|
||||
.partner-link:hover { color: var(--color-gold); text-decoration: underline; }
|
||||
|
||||
.accepted-badge {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 11px;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
color: #6aaa6a;
|
||||
color: var(--color-success);
|
||||
background: rgba(106, 170, 106, 0.12);
|
||||
border: 1px solid rgba(106, 170, 106, 0.4);
|
||||
border-radius: 3px;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 2px 7px;
|
||||
}
|
||||
|
||||
@@ -433,7 +468,7 @@
|
||||
|
||||
.empty-offer p {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 15px;
|
||||
font-size: var(--text-md);
|
||||
font-style: italic;
|
||||
color: rgba(240, 180, 80, 0.25);
|
||||
margin: 0;
|
||||
@@ -456,7 +491,7 @@
|
||||
.divider {
|
||||
flex-shrink: 0;
|
||||
width: 1px;
|
||||
background: rgba(107, 76, 30, 0.35);
|
||||
background: var(--color-border-dim);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@@ -468,43 +503,58 @@
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1rem 1.5rem;
|
||||
border-top: 1px solid rgba(107, 76, 30, 0.35);
|
||||
background: #0d0a04;
|
||||
border-top: 1px solid var(--color-border-dim);
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
.choose-btn {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 11px;
|
||||
font-size: var(--btn-font-md);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
background: #1e1208;
|
||||
border: 1px solid rgba(107, 76, 30, 0.6);
|
||||
border-radius: 4px;
|
||||
border-radius: var(--radius-sm);
|
||||
color: rgba(240, 180, 80, 0.8);
|
||||
padding: 8px 18px;
|
||||
padding: var(--btn-padding-md);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.choose-btn:hover { background: #2e1c0c; border-color: #c8861a; color: #f0d080; }
|
||||
.choose-btn:hover { background: #2e1c0c; border-color: var(--color-bronze); color: var(--color-gold); }
|
||||
|
||||
.error-inline {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 14px;
|
||||
color: #c85050;
|
||||
font-size: var(--text-base);
|
||||
color: var(--color-error);
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.reconnecting-text {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: var(--text-base);
|
||||
font-style: italic;
|
||||
color: rgba(240, 180, 80, 0.5);
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
animation: fade-pulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes fade-pulse {
|
||||
0%, 100% { opacity: 0.5; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
|
||||
.accept-btn {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 13px;
|
||||
font-size: var(--btn-font-lg);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
border-radius: 4px;
|
||||
padding: 10px 28px;
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--btn-padding-lg);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
margin-left: auto;
|
||||
@@ -520,22 +570,22 @@
|
||||
|
||||
/* Ready: gold, inviting click */
|
||||
.accept-btn.accept-ready {
|
||||
background: #3d2507;
|
||||
border: 2px solid #c8861a;
|
||||
color: #f0d080;
|
||||
background: var(--color-surface-raised);
|
||||
border: 2px solid var(--color-bronze);
|
||||
color: var(--color-gold);
|
||||
box-shadow: 0 0 12px rgba(200, 134, 26, 0.2);
|
||||
}
|
||||
|
||||
.accept-btn.accept-ready:hover {
|
||||
background: #5a3510;
|
||||
box-shadow: 0 0 20px rgba(200, 134, 26, 0.4);
|
||||
box-shadow: var(--shadow-glow);
|
||||
}
|
||||
|
||||
/* Accepted: bright green, pulsing, waiting */
|
||||
.accept-btn.accept-accepted {
|
||||
background: rgba(40, 90, 40, 0.4);
|
||||
border: 2px solid #6aaa6a;
|
||||
color: #6aaa6a;
|
||||
border: 2px solid var(--color-success);
|
||||
color: var(--color-success);
|
||||
cursor: default;
|
||||
animation: pulse-green 1.8s ease-in-out infinite;
|
||||
}
|
||||
@@ -548,7 +598,7 @@
|
||||
.selector-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 200;
|
||||
z-index: var(--z-dropdown);
|
||||
background: rgba(0, 0, 0, 0.9);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,450 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { onMount } from 'svelte';
|
||||
import { get } from 'svelte/store';
|
||||
import { API_URL, apiFetch } from '$lib/api.js';
|
||||
import Card from '$lib/Card.svelte';
|
||||
import CardSelector from '$lib/CardSelector.svelte';
|
||||
|
||||
const token = () => localStorage.getItem('token');
|
||||
|
||||
let recipientUsername = $derived(get(page).params.username);
|
||||
|
||||
let theirCards: any[] = $state([]); // recipient's WTT cards (static list from profile)
|
||||
let loading = $state(true);
|
||||
let notFound = $state(false);
|
||||
|
||||
// Which panel is the selector picking for: 'mine' | 'theirs' | null
|
||||
let selectorMode: 'mine' | 'theirs' | null = $state(null);
|
||||
|
||||
let mySelectedIds: Set<string> = $state(new Set());
|
||||
let mySelectedCards: any[] = $state([]); // bound from CardSelector
|
||||
let theirSelectedIds: Set<string> = $state(new Set());
|
||||
let theirSelectedCards = $derived(theirCards.filter((c: any) => theirSelectedIds.has(c.id)));
|
||||
|
||||
let submitting = $state(false);
|
||||
let error = $state('');
|
||||
let done = $state(false);
|
||||
|
||||
|
||||
onMount(async () => {
|
||||
if (!token()) { goto('/auth'); return; }
|
||||
|
||||
const username = get(page).params.username;
|
||||
const profileRes = await fetch(`${API_URL}/users/${username}`);
|
||||
|
||||
if (profileRes.status === 404) { notFound = true; loading = false; return; }
|
||||
|
||||
const profile = await profileRes.json();
|
||||
theirCards = profile.wtt_cards ?? [];
|
||||
loading = false;
|
||||
});
|
||||
|
||||
function openSelector(mode: 'mine' | 'theirs') {
|
||||
selectorMode = mode;
|
||||
}
|
||||
|
||||
function closeSelector() {
|
||||
selectorMode = null;
|
||||
}
|
||||
|
||||
async function sendOffer() {
|
||||
if (mySelectedIds.size === 0 && theirSelectedIds.size === 0) {
|
||||
error = 'At least one side must include cards.';
|
||||
return;
|
||||
}
|
||||
submitting = true;
|
||||
error = '';
|
||||
const res = await apiFetch(`${API_URL}/trade-proposals`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
recipient_username: get(page).params.username,
|
||||
offered_card_ids: [...mySelectedIds],
|
||||
requested_card_ids: [...theirSelectedIds],
|
||||
}),
|
||||
});
|
||||
submitting = false;
|
||||
if (res.ok) {
|
||||
done = true;
|
||||
} else {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
error = data.detail ?? 'Failed to send offer.';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<main>
|
||||
{#if loading}
|
||||
<div class="center-screen">
|
||||
<p class="status-text">Loading...</p>
|
||||
</div>
|
||||
|
||||
{:else if notFound}
|
||||
<div class="center-screen">
|
||||
<p class="status-text">User not found.</p>
|
||||
<a href="/" class="back-link">← Home</a>
|
||||
</div>
|
||||
|
||||
{:else if done}
|
||||
<div class="center-screen">
|
||||
<div class="done-icon">⇄</div>
|
||||
<h2 class="done-title">Offer Sent</h2>
|
||||
<p class="done-body">Your trade offer has been sent to <strong>{recipientUsername}</strong>. They have 72 hours to respond.</p>
|
||||
<div class="done-actions">
|
||||
<a href="/profile/{recipientUsername}" class="choose-btn">View their profile</a>
|
||||
<a href="/profile" class="choose-btn">Your proposals</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{:else}
|
||||
<!-- Full-screen card selector overlay — rendered conditionally per mode so bind works cleanly -->
|
||||
{#if selectorMode === 'mine'}
|
||||
<div class="selector-overlay">
|
||||
<CardSelector bind:selectedIds={mySelectedIds} bind:selectedCards={mySelectedCards} onclose={closeSelector} />
|
||||
</div>
|
||||
{:else if selectorMode === 'theirs'}
|
||||
<div class="selector-overlay">
|
||||
<CardSelector staticCards={theirCards} bind:selectedIds={theirSelectedIds} onclose={closeSelector} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="trade-layout">
|
||||
<div class="trade-header">
|
||||
<h1 class="trade-title">Propose a Trade <span class="trade-with">with</span> <strong class="trade-username">{recipientUsername}</strong></h1>
|
||||
<p class="action-error" style="min-height: 1.2em">{error}</p>
|
||||
<button
|
||||
class="send-btn"
|
||||
class:disabled={mySelectedIds.size === 0 && theirSelectedIds.size === 0}
|
||||
disabled={submitting || (mySelectedIds.size === 0 && theirSelectedIds.size === 0)}
|
||||
onclick={sendOffer}
|
||||
>
|
||||
{submitting ? 'Sending...' : 'Send Offer'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="trade-panels">
|
||||
<!-- YOUR OFFER panel -->
|
||||
<div class="panel your-panel">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">Your Offer</span>
|
||||
<span class="panel-count">{mySelectedIds.size} card{mySelectedIds.size === 1 ? '' : 's'}</span>
|
||||
<button class="choose-btn" onclick={() => openSelector('mine')}>
|
||||
{mySelectedIds.size > 0 ? 'Change' : 'Choose Cards'}
|
||||
</button>
|
||||
</div>
|
||||
<div class="panel-cards">
|
||||
{#if mySelectedCards.length === 0}
|
||||
<div class="empty-offer">
|
||||
<p>No cards offered</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="card-scroll">
|
||||
{#each mySelectedCards as card (card.id)}
|
||||
<div class="card-wrap">
|
||||
<Card {card} noHover={true} />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- THEIR WTT panel -->
|
||||
<div class="panel their-panel">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">You Want</span>
|
||||
<span class="panel-count">{theirSelectedIds.size} card{theirSelectedIds.size === 1 ? '' : 's'}</span>
|
||||
{#if theirCards.length > 0}
|
||||
<button class="choose-btn" onclick={() => openSelector('theirs')}>
|
||||
{theirSelectedIds.size > 0 ? 'Change' : 'Choose Cards'}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="panel-cards">
|
||||
{#if theirSelectedCards.length === 0}
|
||||
<div class="empty-offer">
|
||||
{#if theirCards.length === 0}
|
||||
<p>{recipientUsername} has no cards marked as willing to trade.</p>
|
||||
{:else}
|
||||
<p>No cards requested</p>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="card-scroll">
|
||||
{#each theirSelectedCards as card (card.id)}
|
||||
<div class="card-wrap">
|
||||
<Card {card} noHover={true} />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
<style>
|
||||
main {
|
||||
height: 100vh;
|
||||
background: var(--color-bg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── Overlay ── */
|
||||
.selector-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: var(--z-dropdown);
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
/* ── Trade layout ── */
|
||||
.trade-layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.trade-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-bottom: 1px solid var(--color-border-dim);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.trade-title {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--color-gold);
|
||||
margin: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.trade-with {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: var(--text-lg);
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
color: rgba(240, 180, 80, 0.5);
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.trade-username {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 700;
|
||||
color: var(--color-bronze);
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
/* ── Panels ── */
|
||||
.trade-panels {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 1.25rem 1.5rem;
|
||||
gap: 1rem;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.your-panel { border-right: 1px solid var(--color-border-dim); }
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: var(--text-base);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(240, 180, 80, 0.5);
|
||||
}
|
||||
|
||||
.panel-count {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: var(--text-base);
|
||||
font-style: italic;
|
||||
color: rgba(240, 180, 80, 0.3);
|
||||
}
|
||||
|
||||
.panel-cards {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.empty-offer {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.empty-offer p {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: var(--text-md);
|
||||
font-style: italic;
|
||||
color: rgba(240, 180, 80, 0.2);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.card-scroll {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-content: flex-start;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
/*
|
||||
* Cards displayed at scale 0.62 inside a 300×420 layout box.
|
||||
* Negative margins compensate so the visual footprint is 186×260px.
|
||||
*/
|
||||
.card-wrap {
|
||||
width: 300px;
|
||||
height: 420px;
|
||||
margin-right: -114px;
|
||||
margin-bottom: -160px;
|
||||
flex-shrink: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.card-wrap :global(.card) {
|
||||
transform: scale(0.62);
|
||||
transform-origin: top left;
|
||||
}
|
||||
|
||||
/* ── Choose button ── */
|
||||
.choose-btn {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: var(--btn-font-md);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.07em;
|
||||
text-transform: uppercase;
|
||||
background: var(--color-surface-raised);
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-gold);
|
||||
padding: var(--btn-padding-md);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
align-self: flex-start;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.choose-btn:hover { border-color: var(--color-bronze); background: #4d3010; }
|
||||
a.choose-btn { text-decoration: none; }
|
||||
|
||||
|
||||
.action-error {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: var(--text-base);
|
||||
color: var(--color-error);
|
||||
margin: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.send-btn {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: var(--btn-font-lg);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
background: var(--color-bronze);
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-btn-text);
|
||||
padding: var(--btn-padding-lg);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, transform 0.1s, box-shadow 0.15s;
|
||||
box-shadow: var(--shadow-glow);
|
||||
}
|
||||
|
||||
.send-btn:hover:not(:disabled) { background: var(--color-bronze-hover); transform: translateY(-1px); box-shadow: 0 0 30px rgba(200, 134, 26, 0.5); }
|
||||
.send-btn:active:not(:disabled) { transform: translateY(0); }
|
||||
.send-btn:disabled, .send-btn.disabled { background: #2a1a05; color: rgba(240, 180, 80, 0.2); cursor: default; box-shadow: none; }
|
||||
|
||||
/* ── Center screen states ── */
|
||||
.center-screen {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1.5rem;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.status-text {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: var(--text-md);
|
||||
font-style: italic;
|
||||
color: var(--color-gold-faint);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.done-icon { font-size: var(--text-3xl); color: rgba(200, 134, 26, 0.5); }
|
||||
|
||||
.done-title {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: var(--text-xl);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--color-gold);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.done-body {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: var(--text-lg);
|
||||
font-style: italic;
|
||||
color: var(--color-gold-dim);
|
||||
text-align: center;
|
||||
margin: 0;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.done-body strong { color: var(--color-gold-muted); font-style: normal; }
|
||||
|
||||
.done-actions {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
/* ── Responsive ── */
|
||||
@media (max-width: 640px) {
|
||||
.trade-panels {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: 1fr 1fr;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.your-panel { border-right: none; border-bottom: 1px solid var(--color-border-dim); }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,439 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { onMount } from 'svelte';
|
||||
import { get } from 'svelte/store';
|
||||
import { API_URL, apiFetch } from '$lib/api.js';
|
||||
import Card from '$lib/Card.svelte';
|
||||
|
||||
const token = () => localStorage.getItem('token');
|
||||
|
||||
let proposal: any = $state(null);
|
||||
let loading = $state(true);
|
||||
let notFound = $state(false);
|
||||
let submitting = $state(false);
|
||||
let error = $state('');
|
||||
let done = $state(false);
|
||||
let doneStatus: 'accepted' | 'declined' | 'withdrawn' = $state('accepted');
|
||||
let confirmingAccept = $state(false);
|
||||
|
||||
onMount(async () => {
|
||||
if (!token()) { goto('/auth'); return; }
|
||||
const id = get(page).params.id;
|
||||
const res = await apiFetch(`${API_URL}/trade-proposals/${id}`);
|
||||
if (res.status === 404 || res.status === 403) { notFound = true; loading = false; return; }
|
||||
if (res.status === 401) { goto('/auth'); return; }
|
||||
proposal = await res.json();
|
||||
loading = false;
|
||||
});
|
||||
|
||||
async function accept() {
|
||||
submitting = true;
|
||||
error = '';
|
||||
const res = await apiFetch(`${API_URL}/trade-proposals/${proposal.id}/accept`, { method: 'POST' });
|
||||
submitting = false;
|
||||
if (res.ok) {
|
||||
doneStatus = 'accepted';
|
||||
done = true;
|
||||
} else {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
error = data.detail ?? 'Failed to accept proposal.';
|
||||
}
|
||||
}
|
||||
|
||||
async function decline() {
|
||||
submitting = true;
|
||||
error = '';
|
||||
const res = await apiFetch(`${API_URL}/trade-proposals/${proposal.id}/decline`, { method: 'POST' });
|
||||
submitting = false;
|
||||
if (res.ok) {
|
||||
doneStatus = proposal.direction === 'outgoing' ? 'withdrawn' : 'declined';
|
||||
done = true;
|
||||
} else {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
error = data.detail ?? 'Failed to decline proposal.';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<main>
|
||||
{#if loading}
|
||||
<div class="center-screen">
|
||||
<p class="status-text">Loading...</p>
|
||||
</div>
|
||||
|
||||
{:else if notFound}
|
||||
<div class="center-screen">
|
||||
<p class="status-text">Proposal not found.</p>
|
||||
<a href="/profile" class="action-btn secondary">← Back to Profile</a>
|
||||
</div>
|
||||
|
||||
{:else if done}
|
||||
<div class="center-screen">
|
||||
<div class="done-icon">⇄</div>
|
||||
{#if doneStatus === 'accepted'}
|
||||
<h2 class="done-title">Trade Accepted</h2>
|
||||
<p class="done-body">The cards have been exchanged. Check your collection.</p>
|
||||
{:else if doneStatus === 'declined'}
|
||||
<h2 class="done-title">Proposal Declined</h2>
|
||||
<p class="done-body">The trade offer from <strong>{proposal.proposer_username}</strong> has been declined.</p>
|
||||
{:else}
|
||||
<h2 class="done-title">Proposal Withdrawn</h2>
|
||||
<p class="done-body">Your trade offer to <strong>{proposal.recipient_username}</strong> has been withdrawn.</p>
|
||||
{/if}
|
||||
<a href="/profile" class="action-btn secondary">← Back to Profile</a>
|
||||
</div>
|
||||
|
||||
{:else}
|
||||
<div class="trade-layout">
|
||||
<div class="trade-header">
|
||||
<h1 class="trade-title">
|
||||
{#if proposal.direction === 'incoming'}
|
||||
Trade offer <span class="trade-with">from</span> <strong class="trade-username">{proposal.proposer_username}</strong>
|
||||
{:else}
|
||||
Your offer <span class="trade-with">to</span> <strong class="trade-username">{proposal.recipient_username}</strong>
|
||||
{/if}
|
||||
</h1>
|
||||
|
||||
<p class="action-error" style="min-height: 1.2em">{error}</p>
|
||||
|
||||
{#if proposal.status !== 'pending'}
|
||||
<span class="status-badge {proposal.status}">{proposal.status}</span>
|
||||
{:else if proposal.direction === 'incoming'}
|
||||
{#if confirmingAccept}
|
||||
<span class="confirm-label">Accept and exchange cards?</span>
|
||||
<button class="action-btn primary" disabled={submitting} onclick={accept}>
|
||||
{submitting ? 'Accepting...' : 'Confirm'}
|
||||
</button>
|
||||
<button class="action-btn secondary" disabled={submitting} onclick={() => confirmingAccept = false}>
|
||||
Cancel
|
||||
</button>
|
||||
{:else}
|
||||
<button class="action-btn destructive" disabled={submitting} onclick={decline}>
|
||||
{submitting ? '...' : 'Decline'}
|
||||
</button>
|
||||
<button class="action-btn primary" disabled={submitting} onclick={() => confirmingAccept = true}>
|
||||
Accept
|
||||
</button>
|
||||
{/if}
|
||||
{:else}
|
||||
<button class="action-btn destructive" disabled={submitting} onclick={decline}>
|
||||
{submitting ? '...' : 'Withdraw'}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="trade-panels">
|
||||
<!-- THEIR OFFER panel (what the proposer is giving) -->
|
||||
<div class="panel your-panel">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">
|
||||
{proposal.direction === 'incoming' ? `${proposal.proposer_username}'s Offer` : 'Your Offer'}
|
||||
</span>
|
||||
<span class="panel-count">{proposal.offered_cards.length} card{proposal.offered_cards.length === 1 ? '' : 's'}</span>
|
||||
</div>
|
||||
<div class="panel-cards">
|
||||
{#if proposal.offered_cards.length === 0}
|
||||
<div class="empty-offer"><p>No cards offered</p></div>
|
||||
{:else}
|
||||
<div class="card-scroll">
|
||||
{#each proposal.offered_cards as card (card.id)}
|
||||
<div class="card-wrap">
|
||||
<Card {card} noHover={true} />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- REQUESTED panel (what the proposer wants) -->
|
||||
<div class="panel their-panel">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">
|
||||
{proposal.direction === 'incoming' ? 'They Want' : `${proposal.recipient_username}'s Cards`}
|
||||
</span>
|
||||
<span class="panel-count">{proposal.requested_cards.length} card{proposal.requested_cards.length === 1 ? '' : 's'}</span>
|
||||
</div>
|
||||
<div class="panel-cards">
|
||||
{#if proposal.requested_cards.length === 0}
|
||||
<div class="empty-offer"><p>No cards requested</p></div>
|
||||
{:else}
|
||||
<div class="card-scroll">
|
||||
{#each proposal.requested_cards as card (card.id)}
|
||||
<div class="card-wrap">
|
||||
<Card {card} noHover={true} />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
<style>
|
||||
main {
|
||||
height: 100vh;
|
||||
background: var(--color-bg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── Trade layout ── */
|
||||
.trade-layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.trade-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-bottom: 1px solid var(--color-border-dim);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.trade-title {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--color-gold);
|
||||
margin: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.trade-with {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: var(--text-lg);
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
color: rgba(240, 180, 80, 0.5);
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.trade-username {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 700;
|
||||
color: var(--color-bronze);
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
/* ── Panels ── */
|
||||
.trade-panels {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 1.25rem 1.5rem;
|
||||
gap: 1rem;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.your-panel { border-right: 1px solid var(--color-border-dim); }
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: var(--text-base);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(240, 180, 80, 0.5);
|
||||
}
|
||||
|
||||
.panel-count {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: var(--text-base);
|
||||
font-style: italic;
|
||||
color: rgba(240, 180, 80, 0.3);
|
||||
}
|
||||
|
||||
.panel-cards {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.empty-offer {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.empty-offer p {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: var(--text-md);
|
||||
font-style: italic;
|
||||
color: rgba(240, 180, 80, 0.2);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.card-scroll {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-content: flex-start;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.card-wrap {
|
||||
width: 300px;
|
||||
height: 420px;
|
||||
margin-right: -114px;
|
||||
margin-bottom: -160px;
|
||||
flex-shrink: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.card-wrap :global(.card) {
|
||||
transform: scale(0.62);
|
||||
transform-origin: top left;
|
||||
}
|
||||
|
||||
/* ── Buttons ── */
|
||||
.action-btn {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: var(--btn-font-lg);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.07em;
|
||||
text-transform: uppercase;
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--btn-padding-lg);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, transform 0.1s;
|
||||
border: none;
|
||||
flex-shrink: 0;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.action-btn.primary {
|
||||
background: var(--color-bronze);
|
||||
color: var(--color-btn-text);
|
||||
box-shadow: var(--shadow-glow);
|
||||
}
|
||||
.action-btn.primary:hover:not(:disabled) { background: var(--color-bronze-hover); transform: translateY(-1px); }
|
||||
|
||||
.action-btn.destructive {
|
||||
background: rgba(180, 40, 40, 0.8);
|
||||
color: #fff;
|
||||
}
|
||||
.action-btn.destructive:hover:not(:disabled) { background: rgba(210, 50, 50, 0.9); }
|
||||
|
||||
.action-btn.secondary {
|
||||
background: var(--color-surface-raised);
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
color: var(--color-gold);
|
||||
}
|
||||
.action-btn.secondary:hover { border-color: var(--color-bronze); background: #4d3010; }
|
||||
|
||||
.action-btn:disabled { opacity: 0.5; cursor: default; transform: none; }
|
||||
|
||||
.confirm-label {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: var(--text-md);
|
||||
font-style: italic;
|
||||
color: rgba(240, 180, 80, 0.7);
|
||||
}
|
||||
|
||||
.action-error {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: var(--text-base);
|
||||
color: var(--color-error);
|
||||
margin: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
padding: 5px 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.status-badge.accepted { color: var(--color-success); border-color: rgba(106, 170, 106, 0.4); }
|
||||
.status-badge.declined { color: var(--color-error); border-color: rgba(200, 64, 64, 0.4); }
|
||||
.status-badge.expired { color: rgba(240, 180, 80, 0.3); border-color: var(--color-border-dim); }
|
||||
.status-badge.withdrawn { color: rgba(240, 180, 80, 0.3); border-color: var(--color-border-dim); }
|
||||
|
||||
/* ── Center screen states ── */
|
||||
.center-screen {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1.5rem;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.status-text {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: var(--text-md);
|
||||
font-style: italic;
|
||||
color: var(--color-gold-faint);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.done-icon { font-size: var(--text-3xl); color: rgba(200, 134, 26, 0.5); }
|
||||
|
||||
.done-title {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: var(--text-xl);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--color-gold);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.done-body {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: var(--text-lg);
|
||||
font-style: italic;
|
||||
color: var(--color-gold-dim);
|
||||
text-align: center;
|
||||
margin: 0;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.done-body strong { color: var(--color-gold-muted); font-style: normal; }
|
||||
|
||||
/* ── Responsive ── */
|
||||
@media (max-width: 640px) {
|
||||
.trade-panels {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: 1fr 1fr;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.your-panel { border-right: none; border-bottom: 1px solid var(--color-border-dim); }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,290 @@
|
||||
<script lang="ts">
|
||||
import { API_URL, apiFetch } from '$lib/api.js';
|
||||
import { goto } from '$app/navigation';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
let query = $state('');
|
||||
let results: any[] = $state([]);
|
||||
let loading = $state(false);
|
||||
let searched = $state(false);
|
||||
|
||||
onMount(() => {
|
||||
if (!localStorage.getItem('token')) goto('/auth');
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const q = query;
|
||||
if (q.trim().length === 0) {
|
||||
results = [];
|
||||
searched = false;
|
||||
return;
|
||||
}
|
||||
if (q.trim().length < 2) {
|
||||
results = [];
|
||||
searched = false;
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(async () => {
|
||||
loading = true;
|
||||
const res = await apiFetch(`${API_URL}/users?q=${encodeURIComponent(q.trim())}`);
|
||||
if (res.ok) {
|
||||
results = await res.json();
|
||||
}
|
||||
loading = false;
|
||||
searched = true;
|
||||
}, 300);
|
||||
return () => clearTimeout(timer);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="page">
|
||||
<header class="page-header">
|
||||
<h1>Players</h1>
|
||||
<p class="subtitle">Search for other players by username</p>
|
||||
</header>
|
||||
|
||||
<div class="search-wrap">
|
||||
<div class="search-field">
|
||||
<svg class="search-icon" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="8.5" cy="8.5" r="5.5" stroke="currentColor" stroke-width="1.5"/>
|
||||
<path d="M13 13l3.5 3.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={query}
|
||||
placeholder="Search by username..."
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="results-area">
|
||||
{#if query.trim() === ''}
|
||||
<p class="hint">Type a username to search</p>
|
||||
{:else if loading}
|
||||
<p class="hint">Searching...</p>
|
||||
{:else if searched && results.length === 0}
|
||||
<p class="hint">No players found</p>
|
||||
{:else if results.length > 0}
|
||||
<ul class="results">
|
||||
{#each results as user}
|
||||
<li class="result-row">
|
||||
<a
|
||||
class="username"
|
||||
href="/profile/{user.username}"
|
||||
>{user.username}</a>
|
||||
|
||||
<span class="stats">
|
||||
<span class="stat win">W: {user.wins}</span>
|
||||
<span class="sep">·</span>
|
||||
<span class="stat loss">L: {user.losses}</span>
|
||||
<span class="sep">·</span>
|
||||
<span class="stat rate">{user.win_rate}% win rate</span>
|
||||
</span>
|
||||
|
||||
<div class="actions">
|
||||
<a class="btn-primary" href="/trade/offer/{user.username}">Propose Trade</a>
|
||||
<button class="btn-disabled" disabled title="Coming soon">Challenge</button>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.page {
|
||||
max-width: 760px;
|
||||
margin: 0 auto;
|
||||
padding: 3rem 2rem 4rem;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 2.5rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: var(--text-3xl);
|
||||
font-weight: 900;
|
||||
color: var(--color-gold);
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
margin: 0 0 0.4rem;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: var(--text-lg);
|
||||
color: var(--color-gold-dim);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Search */
|
||||
.search-wrap {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.search-field {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
position: absolute;
|
||||
left: 14px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: var(--color-gold-faint);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
background: var(--color-surface);
|
||||
border: 1.5px solid var(--color-bronze);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 13px 16px 13px 42px;
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: var(--text-lg);
|
||||
color: var(--color-gold);
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
input::placeholder {
|
||||
color: var(--color-gold-faint);
|
||||
}
|
||||
|
||||
input:focus {
|
||||
border-color: var(--color-gold);
|
||||
}
|
||||
|
||||
/* States */
|
||||
.hint {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-style: italic;
|
||||
font-size: var(--text-lg);
|
||||
color: var(--color-gold-faint);
|
||||
text-align: center;
|
||||
margin: 3rem 0;
|
||||
}
|
||||
|
||||
/* Results */
|
||||
.results {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.result-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 0.9rem 1.2rem;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.result-row:hover {
|
||||
border-color: var(--color-bronze);
|
||||
box-shadow: var(--shadow-subtle);
|
||||
}
|
||||
|
||||
.username {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: var(--text-md);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--color-gold);
|
||||
text-decoration: none;
|
||||
min-width: 120px;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.username:hover {
|
||||
color: var(--color-bronze-hover);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.stats {
|
||||
flex: 1;
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: var(--text-md);
|
||||
color: var(--color-gold-dim);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.stat.win { color: var(--color-success); }
|
||||
.stat.loss { color: var(--color-error); }
|
||||
.stat.rate { color: var(--color-gold-dim); }
|
||||
|
||||
.sep {
|
||||
color: var(--color-border);
|
||||
font-size: var(--text-base);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: var(--btn-font-md);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
background: var(--color-bronze);
|
||||
color: var(--color-btn-text);
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--btn-padding-md);
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--color-bronze-hover);
|
||||
}
|
||||
|
||||
.btn-disabled {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: var(--btn-font-md);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
background: rgba(200, 134, 26, 0.15);
|
||||
color: rgba(240, 208, 128, 0.3);
|
||||
border: 1px solid rgba(107, 76, 30, 0.25);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--btn-padding-md);
|
||||
cursor: not-allowed;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.result-row {
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.username { min-width: unset; }
|
||||
.stats { width: 100%; }
|
||||
.actions { width: 100%; }
|
||||
}
|
||||
</style>
|
||||
@@ -50,11 +50,9 @@
|
||||
</main>
|
||||
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;700&family=Crimson+Text:ital,wght@0,400;0,600;1,400&display=swap');
|
||||
|
||||
main {
|
||||
min-height: 100vh;
|
||||
background: #0d0a04;
|
||||
background: var(--color-bg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -63,9 +61,9 @@
|
||||
|
||||
.card {
|
||||
width: 380px;
|
||||
background: #3d2507;
|
||||
border: 2px solid #c8861a;
|
||||
border-radius: 12px;
|
||||
background: var(--color-surface-raised);
|
||||
border: 2px solid var(--color-bronze);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: 2rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -74,17 +72,17 @@
|
||||
|
||||
.title {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 20px;
|
||||
font-size: var(--text-xl);
|
||||
font-weight: 700;
|
||||
color: #f5d060;
|
||||
color: var(--color-gold);
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 15px;
|
||||
color: rgba(245, 208, 96, 0.7);
|
||||
font-size: var(--text-md);
|
||||
color: var(--color-gold-dim);
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
line-height: 1.6;
|
||||
@@ -92,17 +90,17 @@
|
||||
|
||||
.btn {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
background: #c8861a;
|
||||
color: #fff8e0;
|
||||
padding: var(--btn-padding-lg);
|
||||
background: var(--color-bronze);
|
||||
color: var(--color-btn-text);
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
border-radius: var(--radius-md);
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 13px;
|
||||
font-size: var(--btn-font-lg);
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.btn:hover { background: #e09820; }
|
||||
.btn:hover { background: var(--color-bronze-hover); }
|
||||
</style>
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user