This commit is contained in:
2026-04-01 18:31:33 +02:00
parent 6e23e32bb0
commit b5c7c5305a
95 changed files with 9609 additions and 2374 deletions
+6 -2
View File
@@ -3,6 +3,10 @@ __pycache__/
.svelte-kit/
.env
backend/simulation_cards.json
backend/ai/simulation_cards.json
backend/ai/tournament_grid.png
backend/tournament_grid.png
backend/tournament_results.json
backend/ai/tournament_results.json
CLAUDE.md
/.claude
View File
+176
View File
@@ -0,0 +1,176 @@
import os
import numpy as np
from ai.nn import NeuralNet, _softmax
# Separate weights file so this NN trains independently from the plan NN.
CARD_PICK_WEIGHTS_PATH = os.path.join(os.path.dirname(__file__), "card_pick_weights.json")
N_CARD_FEATURES = 15
# Normalization constants — chosen to cover the realistic stat range for generated cards.
_MAX_ATK = 50.0
_MAX_DEF = 100.0
def _precompute_static_features(allowed: list) -> np.ndarray:
"""
Vectorized precomputation of the 7 per-card static features for the whole pool.
Returns (n, 7) float32. Called once per choose_cards() invocation.
"""
n = len(allowed)
atk = np.array([c.attack for c in allowed], dtype=np.float32)
defn = np.array([c.defense for c in allowed], dtype=np.float32)
cost = np.array([c.cost for c in allowed], dtype=np.float32)
rar = np.array([c.card_rarity.value for c in allowed], dtype=np.float32)
typ = np.array([c.card_type.value for c in allowed], dtype=np.float32)
exact_cost = np.minimum(10.0, np.maximum(1.0, ((atk**2 + defn**2)**0.18) / 1.5))
total = atk + defn
atk_ratio = np.where(total > 0, atk / total, 0.5)
pcv_norm = np.clip(exact_cost - cost, 0.0, 1.0)
out = np.empty((n, 7), dtype=np.float32)
out[:, 0] = atk / _MAX_ATK
out[:, 1] = defn / _MAX_DEF
out[:, 2] = cost / 10.0
out[:, 3] = rar / 5.0
out[:, 4] = atk_ratio
out[:, 5] = pcv_norm
out[:, 6] = typ / 9.0
return out
class CardPickPlayer:
"""
Uses a NeuralNet to sequentially select cards from a pool until the cost
budget is exhausted. API mirrors NeuralPlayer so training code stays uniform.
In training mode: samples stochastically (softmax) and records the
trajectory for a REINFORCE update after the game ends.
In inference mode: picks the highest-scoring affordable card at each step.
Performance design:
- Static per-card features (7) are computed once via vectorized numpy.
- Context features (8) use running totals updated by O(1) increments.
- Picked cards are tracked with a boolean mask; no list.remove() calls.
- Each pick step does one small forward pass over the affordable subset only.
"""
def __init__(self, net: NeuralNet, training: bool = False, temperature: float = 1.0):
self.net = net
self.training = training
self.temperature = temperature
self.trajectory: list[tuple[np.ndarray, int]] = [] # (features_matrix, chosen_idx)
def choose_cards(self, allowed: list, difficulty: int) -> list:
"""
allowed: pre-filtered list of Card objects (cost ≤ max_card_cost already applied).
Returns the selected deck as a list of Cards.
"""
BUDGET = 50
n = len(allowed)
static = _precompute_static_features(allowed) # (n, 7) — computed once
costs = np.array([c.cost for c in allowed], dtype=np.float32)
picked = np.zeros(n, dtype=bool)
budget_remaining = BUDGET
selected: list = []
# Running totals for context features — incremented O(1) per pick.
n_picked = 0
sum_atk = 0.0
sum_def = 0.0
sum_cost = 0.0
n_cheap = 0 # cost ≤ 3
n_high = 0 # cost ≥ 6
diff_norm = difficulty / 10.0
while True:
mask = (~picked) & (costs <= budget_remaining)
if not mask.any():
break
idxs = np.where(mask)[0]
# Context row — same for every candidate this step, broadcast via tile.
if n_picked > 0:
ctx = np.array([
n_picked / 30.0,
budget_remaining / 50.0,
sum_atk / n_picked / _MAX_ATK,
sum_def / n_picked / _MAX_DEF,
sum_cost / n_picked / 10.0,
n_cheap / n_picked,
n_high / n_picked,
diff_norm,
], dtype=np.float32)
else:
ctx = np.array([
0.0, budget_remaining / 50.0, 0.0, 0.0, 0.0, 0.0, 0.0, diff_norm,
], dtype=np.float32)
features = np.concatenate(
[static[idxs], np.tile(ctx, (len(idxs), 1))],
axis=1,
)
scores = self.net.forward(features)
if self.training:
probs = _softmax((scores / self.temperature).astype(np.float64))
probs = np.clip(probs, 1e-10, None)
probs /= probs.sum()
local_idx = int(np.random.choice(len(idxs), p=probs))
self.trajectory.append((features, local_idx))
else:
local_idx = int(np.argmax(scores))
global_idx = idxs[local_idx]
card = allowed[global_idx]
picked[global_idx] = True
selected.append(card)
# Incremental context update — O(1).
budget_remaining -= card.cost
n_picked += 1
sum_atk += card.attack
sum_def += card.defense
sum_cost += card.cost
if card.cost <= 3: n_cheap += 1
if card.cost >= 6: n_high += 1
return selected
def compute_grads(self, outcome: float) -> tuple[list, list] | None:
"""
REINFORCE gradients averaged over the pick trajectory.
outcome: centered reward (win/loss minus baseline).
Returns (grads_w, grads_b), or None if no picks were made.
"""
if not self.trajectory:
return None
acc_gw = [np.zeros_like(w) for w in self.net.weights]
acc_gb = [np.zeros_like(b) for b in self.net.biases]
for features, chosen_idx in self.trajectory:
scores = self.net.forward(features)
probs = _softmax(scores.astype(np.float64)).astype(np.float32)
upstream = -probs.copy()
upstream[chosen_idx] += 1.0
upstream *= outcome
gw, gb = self.net.backward(upstream)
for i in range(len(acc_gw)):
acc_gw[i] += gw[i]
acc_gb[i] += gb[i]
n = len(self.trajectory)
for i in range(len(acc_gw)):
acc_gw[i] /= n
acc_gb[i] /= n
self.trajectory.clear()
return acc_gw, acc_gb
File diff suppressed because one or more lines are too long
+26 -9
View File
@@ -1,12 +1,15 @@
import asyncio
import random
import logging
import os
import random
from dataclasses import dataclass
from enum import Enum
from itertools import combinations, permutations
import numpy as np
from card import Card
from game import action_play_card, action_sacrifice, action_end_turn, BOARD_SIZE, STARTING_LIFE, PlayerState
from game.card import Card
from game.rules import action_play_card, action_sacrifice, action_end_turn, BOARD_SIZE, STARTING_LIFE, PlayerState
logger = logging.getLogger("app")
@@ -77,7 +80,21 @@ def choose_cards(cards: list[Card], difficulty: int, personality: AIPersonality)
elif personality == AIPersonality.CONTROL:
# Small cost_norm keeps flavour without causing severe deck shrinkage at D10
scores = 0.85 * pcv_norm + 0.15 * cost_norm
elif personality in (AIPersonality.BALANCED, AIPersonality.JEBRASKA):
elif personality == AIPersonality.BALANCED:
scores = 0.60 * pcv_norm + 0.25 * atk_ratio + 0.15 * (1.0 - atk_ratio)
elif personality == AIPersonality.JEBRASKA:
# Delegate entirely to the card-pick NN; skip the heuristic scoring path.
from ai.card_pick_nn import CardPickPlayer, CARD_PICK_WEIGHTS_PATH
from ai.nn import NeuralNet
if not hasattr(choose_cards, "_card_pick_net"):
choose_cards._card_pick_net = (
NeuralNet.load(CARD_PICK_WEIGHTS_PATH)
if os.path.exists(CARD_PICK_WEIGHTS_PATH) else None
)
net = choose_cards._card_pick_net
if net is not None:
return CardPickPlayer(net, training=False).choose_cards(allowed, difficulty)
# Fall through to BALANCED heuristic if weights aren't trained yet.
scores = 0.60 * pcv_norm + 0.25 * atk_ratio + 0.15 * (1.0 - atk_ratio)
else: # ARBITRARY
w = 0.09 * difficulty
@@ -97,7 +114,7 @@ def choose_cards(cards: list[Card], difficulty: int, personality: AIPersonality)
AIPersonality.DEFENSIVE: 15, # raised: stable cheap-card base across difficulty levels
AIPersonality.CONTROL: 8,
AIPersonality.BALANCED: 25, # spread the deck across all cost levels
AIPersonality.JEBRASKA: 25, # same as balanced
AIPersonality.JEBRASKA: 25, # fallback (no trained weights yet)
AIPersonality.ARBITRARY: 8,
}[personality]
@@ -320,14 +337,14 @@ def choose_plan(player: PlayerState, opponent: PlayerState, personality: AIPerso
plans = generate_plans(player, opponent)
if personality == AIPersonality.JEBRASKA:
from nn import NeuralNet
from ai.nn import NeuralNet
import os
_weights = os.path.join(os.path.dirname(__file__), "nn_weights.json")
if not hasattr(choose_plan, "_neural_net"):
choose_plan._neural_net = NeuralNet.load(_weights) if os.path.exists(_weights) else None
net = choose_plan._neural_net
if net is not None:
from nn import extract_plan_features
from ai.nn import extract_plan_features
scores = net.forward(extract_plan_features(plans, player, opponent))
else: # fallback to BALANCED if weights not found
scores = score_plans_batch(plans, player, opponent, AIPersonality.BALANCED)
@@ -339,7 +356,7 @@ def choose_plan(player: PlayerState, opponent: PlayerState, personality: AIPerso
return plans[int(np.argmax(scores + noise))]
async def run_ai_turn(game_id: str):
from game_manager import (
from game.manager import (
active_games, connections, active_deck_ids,
serialize_state, record_game_result, calculate_combat_animation_time
)
@@ -421,7 +438,7 @@ async def run_ai_turn(game_id: str):
await send_state(state)
if state.result:
from database import SessionLocal
from core.database import SessionLocal
db = SessionLocal()
try:
record_game_result(state, db)
+4 -3
View File
@@ -1,6 +1,7 @@
import numpy as np
import json
import numpy as np
# Layout: [state(8) | my_board(15) | opp_board(15) | plan(3) | result_board(15) | opp_deck_type(8)]
N_FEATURES = 64
@@ -137,7 +138,7 @@ def extract_plan_features(plans: list, player, opponent) -> np.ndarray:
Returns (n_plans, N_FEATURES) float32 array.
Layout: [state(8) | my_board(15) | opp_board(15) | plan(3) | result_board(15)]
"""
from game import BOARD_SIZE, HAND_SIZE, MAX_ENERGY_CAP, STARTING_LIFE
from game.rules import BOARD_SIZE, HAND_SIZE, MAX_ENERGY_CAP, STARTING_LIFE
n = len(plans)
@@ -217,7 +218,7 @@ class NeuralPlayer:
self.trajectory: list[tuple[np.ndarray, int]] = [] # (features, chosen_idx)
def choose_plan(self, player, opponent):
from ai import generate_plans
from ai.engine import generate_plans
plans = generate_plans(player, opponent)
features = extract_plan_features(plans, player, opponent)
scores = self.net.forward(features)
File diff suppressed because one or more lines are too long
@@ -1,21 +1,21 @@
import asyncio
import json
import math
import os
import random
import uuid
import asyncio
from concurrent.futures import ProcessPoolExecutor
from datetime import datetime
from dotenv import load_dotenv
load_dotenv()
from datetime import datetime
from card import Card, CardType, CardRarity, generate_cards, compute_deck_type
from game import (
from game.card import Card, CardType, CardRarity, generate_cards, compute_deck_type
from game.rules import (
CardInstance, PlayerState, GameState,
action_play_card, action_sacrifice, action_end_turn,
)
from ai import AIPersonality, choose_cards, choose_plan
from ai.engine import AIPersonality, choose_cards, choose_plan
SIMULATION_CARDS_PATH = os.path.join(os.path.dirname(__file__), "simulation_cards.json")
SIMULATION_CARD_COUNT = 1000
@@ -24,7 +24,7 @@ SIMULATION_CARD_COUNT = 1000
def _card_to_dict(card: Card) -> dict:
return {
"name": card.name,
"created_at": card.created_at.isoformat(),
"generated_at": card.generated_at.isoformat(),
"image_link": card.image_link,
"card_rarity": card.card_rarity.name,
"card_type": card.card_type.name,
@@ -39,7 +39,7 @@ def _card_to_dict(card: Card) -> dict:
def _dict_to_card(d: dict) -> Card:
return Card(
name=d["name"],
created_at=datetime.fromisoformat(d["created_at"]),
generated_at=datetime.fromisoformat(d["generated_at"]),
image_link=d["image_link"],
card_rarity=CardRarity[d["card_rarity"]],
card_type=CardType[d["card_type"]],
@@ -609,7 +609,7 @@ def draw_grid(
if __name__ == "__main__":
difficulties = list(range(7, 11))
difficulties = list(range(8, 11))
card_pool = get_simulation_cards()
players = _all_players(difficulties)
+102 -23
View File
@@ -1,27 +1,39 @@
import os
import random
import uuid
import numpy as np
from collections import deque
import numpy as np
from dotenv import load_dotenv
load_dotenv()
from card import compute_deck_type
from ai import AIPersonality, choose_cards, choose_plan
from game import PlayerState, GameState, action_play_card, action_sacrifice, action_end_turn
from simulate import get_simulation_cards, _make_instances, MAX_TURNS
from nn import NeuralNet, NeuralPlayer
from game.card import compute_deck_type
from ai.engine import AIPersonality, choose_cards, choose_plan
from game.rules import PlayerState, GameState, action_play_card, action_sacrifice, action_end_turn
from ai.simulate import get_simulation_cards, _make_instances, MAX_TURNS
from ai.nn import NeuralNet, NeuralPlayer
from ai.card_pick_nn import CardPickPlayer, N_CARD_FEATURES, CARD_PICK_WEIGHTS_PATH
NN_WEIGHTS_PATH = os.path.join(os.path.dirname(__file__), "nn_weights.json")
P1 = "p1"
P2 = "p2"
FIXED_PERSONALITIES = [p for p in AIPersonality if p != AIPersonality.ARBITRARY]
FIXED_PERSONALITIES = [
p for p in AIPersonality
if p not in (
AIPersonality.ARBITRARY,
AIPersonality.JEBRASKA
)
]
def _build_player(pid: str, name: str, cards: list, difficulty: int, personality: AIPersonality) -> PlayerState:
deck = choose_cards(cards, difficulty, personality)
def _build_player(pid: str, name: str, cards: list, difficulty: int, personality: AIPersonality,
deck_pool: dict | None = None) -> PlayerState:
if deck_pool and personality in deck_pool:
deck = random.choice(deck_pool[personality])
else:
deck = choose_cards(cards, difficulty, personality)
instances = _make_instances(deck)
random.shuffle(instances)
p = PlayerState(
@@ -32,6 +44,21 @@ def _build_player(pid: str, name: str, cards: list, difficulty: int, personality
return p
def _build_nn_player(pid: str, name: str, cards: list, difficulty: int,
card_pick_player: CardPickPlayer) -> PlayerState:
"""Build a PlayerState using the card-pick NN for deck selection."""
max_card_cost = difficulty + 1 if difficulty >= 6 else 6
allowed = [c for c in cards if c.cost <= max_card_cost] or list(cards)
deck = card_pick_player.choose_cards(allowed, difficulty)
instances = _make_instances(deck)
random.shuffle(instances)
return PlayerState(
user_id=pid, username=name,
deck_type=compute_deck_type(deck) or "Balanced",
deck=instances,
)
def run_episode(
p1_state: PlayerState,
p2_state: PlayerState,
@@ -81,25 +108,40 @@ def run_episode(
def train(
n_episodes: int = 20_000,
self_play_start: int = 5_000,
self_play_max_frac: float = 0.4,
n_episodes: int = 50_000,
self_play_start: int = 0,
self_play_max_frac: float = 0.9,
lr: float = 1e-3,
opp_difficulty: int = 10,
temperature: float = 1.0,
batch_size: int = 50,
batch_size: int = 500,
save_every: int = 5_000,
save_path: str = NN_WEIGHTS_PATH,
) -> NeuralNet:
cards = get_simulation_cards()
# Pre-build a pool of opponent decks per personality to avoid rebuilding from scratch each episode.
DECK_POOL_SIZE = 100
opp_deck_pool: dict[AIPersonality, list] = {
p: [choose_cards(cards, opp_difficulty, p) for _ in range(DECK_POOL_SIZE)]
for p in FIXED_PERSONALITIES
}
if os.path.exists(save_path):
print(f"Resuming from {save_path}")
print(f"Resuming plan net from {save_path}")
net = NeuralNet.load(save_path)
else:
print("Initializing new network")
print("Initializing new plan network")
net = NeuralNet(seed=42)
cp_path = CARD_PICK_WEIGHTS_PATH
if os.path.exists(cp_path):
print(f"Resuming card-pick net from {cp_path}")
card_pick_net = NeuralNet.load(cp_path)
else:
print("Initializing new card-pick network")
card_pick_net = NeuralNet(n_features=N_CARD_FEATURES, hidden=(32, 16), seed=43)
recent_outcomes: deque[int] = deque(maxlen=1000) # rolling window for win rate display
baseline = 0.0 # EMA of recent outcomes; subtracted before each update
baseline_alpha = 0.99 # decay — roughly a 100-episode window
@@ -108,6 +150,10 @@ def train(
batch_gb = [np.zeros_like(b) for b in net.biases]
batch_count = 0
cp_batch_gw = [np.zeros_like(w) for w in card_pick_net.weights]
cp_batch_gb = [np.zeros_like(b) for b in card_pick_net.biases]
cp_batch_count = 0
for episode in range(1, n_episodes + 1):
# Ramp self-play fraction linearly from 0 to self_play_max_frac
if episode >= self_play_start:
@@ -122,9 +168,11 @@ def train(
if random.random() < self_play_prob:
nn1 = NeuralPlayer(net, training=True, temperature=temperature)
nn2 = NeuralPlayer(net, training=True, temperature=temperature)
cp1 = CardPickPlayer(card_pick_net, training=True, temperature=temperature)
cp2 = CardPickPlayer(card_pick_net, training=True, temperature=temperature)
p1_state = _build_player(P1, "NN1", cards, 10, AIPersonality.BALANCED)
p2_state = _build_player(P2, "NN2", cards, 10, AIPersonality.BALANCED)
p1_state = _build_nn_player(P1, "NN1", cards, 10, cp1)
p2_state = _build_nn_player(P2, "NN2", cards, 10, cp2)
if not nn_goes_first:
p1_state, p2_state = p2_state, p1_state
@@ -142,20 +190,30 @@ def train(
batch_gb[i] += gb[i]
batch_count += 1
for cp_grads in [cp1.compute_grads(p1_outcome - baseline),
cp2.compute_grads(-p1_outcome - baseline)]:
if cp_grads is not None:
gw, gb = cp_grads
for i in range(len(cp_batch_gw)):
cp_batch_gw[i] += gw[i]
cp_batch_gb[i] += gb[i]
cp_batch_count += 1
else:
opp_personality = random.choice(FIXED_PERSONALITIES)
nn_player = NeuralPlayer(net, training=True, temperature=temperature)
cp_player = CardPickPlayer(card_pick_net, training=True, temperature=temperature)
opp_ctrl = lambda p, o, pers=opp_personality, diff=opp_difficulty: choose_plan(p, o, pers, diff)
if nn_goes_first:
nn_id = P1
p1_state = _build_player(P1, "NN", cards, 10, AIPersonality.BALANCED)
p2_state = _build_player(P2, "OPP", cards, opp_difficulty, opp_personality)
p1_state = _build_nn_player(P1, "NN", cards, 10, cp_player)
p2_state = _build_player(P2, "OPP", cards, opp_difficulty, opp_personality, opp_deck_pool)
winner = run_episode(p1_state, p2_state, nn_player.choose_plan, opp_ctrl)
else:
nn_id = P2
p1_state = _build_player(P1, "OPP", cards, opp_difficulty, opp_personality)
p2_state = _build_player(P2, "NN", cards, 10, AIPersonality.BALANCED)
p1_state = _build_player(P1, "OPP", cards, opp_difficulty, opp_personality, opp_deck_pool)
p2_state = _build_nn_player(P2, "NN", cards, 10, cp_player)
winner = run_episode(p1_state, p2_state, opp_ctrl, nn_player.choose_plan)
nn_outcome = 1.0 if winner == nn_id else -1.0
@@ -169,6 +227,14 @@ def train(
batch_gb[i] += gb[i]
batch_count += 1
cp_grads = cp_player.compute_grads(nn_outcome - baseline)
if cp_grads is not None:
gw, gb = cp_grads
for i in range(len(cp_batch_gw)):
cp_batch_gw[i] += gw[i]
cp_batch_gb[i] += gb[i]
cp_batch_count += 1
recent_outcomes.append(1 if winner == nn_id else 0)
if batch_count >= batch_size:
@@ -180,16 +246,29 @@ def train(
batch_gb = [np.zeros_like(b) for b in net.biases]
batch_count = 0
if cp_batch_count >= batch_size:
for i in range(len(cp_batch_gw)):
cp_batch_gw[i] /= cp_batch_count
cp_batch_gb[i] /= cp_batch_count
card_pick_net.adam_update(cp_batch_gw, cp_batch_gb, lr=lr)
cp_batch_gw = [np.zeros_like(w) for w in card_pick_net.weights]
cp_batch_gb = [np.zeros_like(b) for b in card_pick_net.biases]
cp_batch_count = 0
if episode % 1000 == 0 or episode == n_episodes:
wr = sum(recent_outcomes) / len(recent_outcomes) if recent_outcomes else 0.0
print(f"[{episode:>6}/{n_episodes}] win rate (last {len(recent_outcomes)}): {wr:.1%} "
print(f"\r[{episode:>6}/{n_episodes}] win rate (last {len(recent_outcomes)}): {wr:.1%} "
f"self-play frac: {self_play_prob:.0%}", flush=True)
else:
print(f" {episode % 1000}/1000", end="\r", flush=True)
if episode % save_every == 0:
net.save(save_path)
print(f" → saved to {save_path}")
card_pick_net.save(cp_path)
print(f" → saved to {save_path} and {cp_path}")
net.save(save_path)
card_pick_net.save(cp_path)
wr = sum(recent_outcomes) / len(recent_outcomes) if recent_outcomes else 0.0
print(f"Done. Final win rate (last {len(recent_outcomes)}): {wr:.1%}")
return net
+1 -1
View File
@@ -7,7 +7,7 @@ from sqlalchemy import engine_from_config
from sqlalchemy import pool, create_engine
from alembic import context
from models import Base
from core.models import Base
import os
from dotenv import load_dotenv
@@ -0,0 +1,32 @@
"""add trade_wishlist to users
Revision ID: 0fc168f5970d
Revises: e70b992e5d95
Create Date: 2026-03-27 23:01:32.739184
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '0fc168f5970d'
down_revision: Union[str, Sequence[str], None] = 'e70b992e5d95'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('users', sa.Column('trade_wishlist', sa.Text(), nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('users', 'trade_wishlist')
# ### end Alembic commands ###
@@ -0,0 +1,48 @@
"""add_game_challenges_table
Revision ID: 29da7c818b01
Revises: a1b2c3d4e5f6
Create Date: 2026-03-28 23:20:21.949520
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '29da7c818b01'
down_revision: Union[str, Sequence[str], None] = 'a1b2c3d4e5f6'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('game_challenges',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('challenger_id', sa.UUID(), nullable=False),
sa.Column('challenged_id', sa.UUID(), nullable=False),
sa.Column('challenger_deck_id', sa.UUID(), nullable=False),
sa.Column('status', sa.String(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('expires_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['challenged_id'], ['users.id'], ),
sa.ForeignKeyConstraint(['challenger_deck_id'], ['decks.id'], ),
sa.ForeignKeyConstraint(['challenger_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.drop_index(op.f('ix_trade_proposals_proposer_status'), table_name='trade_proposals')
op.drop_index(op.f('ix_trade_proposals_recipient_status'), table_name='trade_proposals')
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_index(op.f('ix_trade_proposals_recipient_status'), 'trade_proposals', ['recipient_id', 'status'], unique=False)
op.create_index(op.f('ix_trade_proposals_proposer_status'), 'trade_proposals', ['proposer_id', 'status'], unique=False)
op.drop_table('game_challenges')
# ### end Alembic commands ###
@@ -0,0 +1,36 @@
"""add_processed_webhook_events
Revision ID: 4603709eb82d
Revises: d1e2f3a4b5c6
Create Date: 2026-03-30 00:30:05.493030
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '4603709eb82d'
down_revision: Union[str, Sequence[str], None] = 'd1e2f3a4b5c6'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('processed_webhook_events',
sa.Column('stripe_event_id', sa.String(), nullable=False),
sa.Column('processed_at', sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint('stripe_event_id')
)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('processed_webhook_events')
# ### end Alembic commands ###
@@ -0,0 +1,55 @@
"""trade_proposals_multi_requested_cards
Revision ID: 58fc464be769
Revises: cfac344e21b4
Create Date: 2026-03-28 22:09:44.129838
Replace single requested_card_id FK with requested_card_ids JSONB array so proposals
can request zero or more cards, mirroring the real-time trade system's flexibility.
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = '58fc464be769'
down_revision: Union[str, Sequence[str], None] = 'cfac344e21b4'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Add the new column, migrate existing data, then drop the old column
op.add_column('trade_proposals',
sa.Column('requested_card_ids', postgresql.JSONB(astext_type=sa.Text()), nullable=True)
)
# Migrate any existing rows: wrap the single FK UUID into a JSON array
op.execute("""
UPDATE trade_proposals
SET requested_card_ids = json_build_array(requested_card_id::text)::jsonb
WHERE requested_card_id IS NOT NULL
""")
op.execute("""
UPDATE trade_proposals
SET requested_card_ids = '[]'::jsonb
WHERE requested_card_ids IS NULL
""")
op.alter_column('trade_proposals', 'requested_card_ids', nullable=False)
op.drop_constraint('trade_proposals_requested_card_id_fkey', 'trade_proposals', type_='foreignkey')
op.drop_column('trade_proposals', 'requested_card_id')
def downgrade() -> None:
op.add_column('trade_proposals',
sa.Column('requested_card_id', sa.UUID(), nullable=True)
)
# Best-effort reverse: take first element of the array if present
op.execute("""
UPDATE trade_proposals
SET requested_card_id = (requested_card_ids->0)::text::uuid
WHERE jsonb_array_length(requested_card_ids) > 0
""")
op.drop_column('trade_proposals', 'requested_card_ids')
@@ -0,0 +1,42 @@
"""add_fk_cascade_constraints
Revision ID: 8283acd4cbcc
Revises: a2b3c4d5e6f7
Create Date: 2026-03-29 13:55:46.488121
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '8283acd4cbcc'
down_revision: Union[str, Sequence[str], None] = 'a2b3c4d5e6f7'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
op.drop_constraint(op.f('cards_user_id_fkey'), 'cards', type_='foreignkey')
op.create_foreign_key(op.f('cards_user_id_fkey'), 'cards', 'users', ['user_id'], ['id'], ondelete='CASCADE')
op.drop_constraint(op.f('deck_cards_card_id_fkey'), 'deck_cards', type_='foreignkey')
op.drop_constraint(op.f('deck_cards_deck_id_fkey'), 'deck_cards', type_='foreignkey')
op.create_foreign_key(op.f('deck_cards_deck_id_fkey'), 'deck_cards', 'decks', ['deck_id'], ['id'], ondelete='CASCADE')
op.create_foreign_key(op.f('deck_cards_card_id_fkey'), 'deck_cards', 'cards', ['card_id'], ['id'], ondelete='CASCADE')
op.drop_constraint(op.f('decks_user_id_fkey'), 'decks', type_='foreignkey')
op.create_foreign_key(op.f('decks_user_id_fkey'), 'decks', 'users', ['user_id'], ['id'], ondelete='CASCADE')
def downgrade() -> None:
"""Downgrade schema."""
op.drop_constraint(op.f('decks_user_id_fkey'), 'decks', type_='foreignkey')
op.create_foreign_key(op.f('decks_user_id_fkey'), 'decks', 'users', ['user_id'], ['id'])
op.drop_constraint(op.f('deck_cards_deck_id_fkey'), 'deck_cards', type_='foreignkey')
op.drop_constraint(op.f('deck_cards_card_id_fkey'), 'deck_cards', type_='foreignkey')
op.create_foreign_key(op.f('deck_cards_deck_id_fkey'), 'deck_cards', 'decks', ['deck_id'], ['id'])
op.create_foreign_key(op.f('deck_cards_card_id_fkey'), 'deck_cards', 'cards', ['card_id'], ['id'])
op.drop_constraint(op.f('cards_user_id_fkey'), 'cards', type_='foreignkey')
op.create_foreign_key(op.f('cards_user_id_fkey'), 'cards', 'users', ['user_id'], ['id'])
@@ -0,0 +1,31 @@
"""add_received_at_rename_generated_at_on_cards
Revision ID: 98e23cab7057
Revises: 0fc168f5970d
Create Date: 2026-03-28 18:07:12.712311
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '98e23cab7057'
down_revision: Union[str, Sequence[str], None] = '0fc168f5970d'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
op.alter_column('cards', 'created_at', new_column_name='generated_at')
op.add_column('cards', sa.Column('received_at', sa.DateTime(), nullable=True))
op.execute("UPDATE cards SET received_at = generated_at")
def downgrade() -> None:
"""Downgrade schema."""
op.drop_column('cards', 'received_at')
op.alter_column('cards', 'generated_at', new_column_name='created_at')
@@ -0,0 +1,26 @@
"""add last_active_at to users
Revision ID: a1b2c3d4e5f6
Revises: 58fc464be769
Create Date: 2026-03-28
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'a1b2c3d4e5f6'
down_revision: Union[str, Sequence[str], None] = '58fc464be769'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column('users', sa.Column('last_active_at', sa.DateTime(), nullable=True))
def downgrade() -> None:
op.drop_column('users', 'last_active_at')
@@ -0,0 +1,48 @@
"""add_unique_constraint_friendship
Revision ID: a2b3c4d5e6f7
Revises: f4e8a1b2c3d9
Create Date: 2026-03-29 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
from sqlalchemy import text
# revision identifiers, used by Alembic.
revision: str = 'a2b3c4d5e6f7'
down_revision: Union[str, Sequence[str], None] = 'f4e8a1b2c3d9'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Remove duplicate (requester_id, addressee_id) pairs that already exist,
# keeping the earliest row per pair before adding the unique constraint.
conn = op.get_bind()
conn.execute(text("""
DELETE FROM friendships
WHERE id IN (
SELECT id FROM (
SELECT id,
ROW_NUMBER() OVER (
PARTITION BY requester_id, addressee_id
ORDER BY created_at
) AS rn
FROM friendships
) sub
WHERE rn > 1
)
"""))
op.create_unique_constraint(
"uq_friendship_requester_addressee",
"friendships",
["requester_id", "addressee_id"],
)
def downgrade() -> None:
op.drop_constraint("uq_friendship_requester_addressee", "friendships", type_="unique")
@@ -0,0 +1,41 @@
"""add_friendships_table
Revision ID: b989aae3e37d
Revises: de721927ff59
Create Date: 2026-03-28 19:14:54.623287
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'b989aae3e37d'
down_revision: Union[str, Sequence[str], None] = 'de721927ff59'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('friendships',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('requester_id', sa.UUID(), nullable=False),
sa.Column('addressee_id', sa.UUID(), nullable=False),
sa.Column('status', sa.String(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['addressee_id'], ['users.id'], ),
sa.ForeignKeyConstraint(['requester_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('friendships')
# ### end Alembic commands ###
@@ -0,0 +1,31 @@
"""add_check_constraints_on_status_fields
Revision ID: c1d2e3f4a5b6
Revises: 8283acd4cbcc
Create Date: 2026-03-29 14:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
# revision identifiers, used by Alembic.
revision: str = 'c1d2e3f4a5b6'
down_revision: Union[str, Sequence[str], None] = '8283acd4cbcc'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_check_constraint("ck_friendships_status", "friendships", "status IN ('pending', 'accepted', 'declined')")
op.create_check_constraint("ck_trade_proposals_status", "trade_proposals", "status IN ('pending', 'accepted', 'declined', 'expired', 'withdrawn')")
op.create_check_constraint("ck_game_challenges_status", "game_challenges", "status IN ('pending', 'accepted', 'declined', 'expired', 'withdrawn')")
op.create_check_constraint("ck_notifications_type", "notifications", "type IN ('friend_request', 'trade_offer', 'game_challenge')")
def downgrade() -> None:
op.drop_constraint("ck_notifications_type", "notifications", type_="check")
op.drop_constraint("ck_game_challenges_status", "game_challenges", type_="check")
op.drop_constraint("ck_trade_proposals_status", "trade_proposals", type_="check")
op.drop_constraint("ck_friendships_status", "friendships", type_="check")
@@ -0,0 +1,49 @@
"""add_trade_proposals_table
Revision ID: cfac344e21b4
Revises: b989aae3e37d
Create Date: 2026-03-28 22:01:28.188084
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = 'cfac344e21b4'
down_revision: Union[str, Sequence[str], None] = 'b989aae3e37d'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('trade_proposals',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('proposer_id', sa.UUID(), nullable=False),
sa.Column('recipient_id', sa.UUID(), nullable=False),
sa.Column('offered_card_ids', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column('requested_card_id', sa.UUID(), nullable=False),
sa.Column('status', sa.String(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('expires_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['proposer_id'], ['users.id'], ),
sa.ForeignKeyConstraint(['recipient_id'], ['users.id'], ),
sa.ForeignKeyConstraint(['requested_card_id'], ['cards.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index('ix_trade_proposals_proposer_status', 'trade_proposals', ['proposer_id', 'status'])
op.create_index('ix_trade_proposals_recipient_status', 'trade_proposals', ['recipient_id', 'status'])
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index('ix_trade_proposals_proposer_status', 'trade_proposals')
op.drop_index('ix_trade_proposals_recipient_status', 'trade_proposals')
op.drop_table('trade_proposals')
# ### end Alembic commands ###
@@ -0,0 +1,69 @@
"""add_fk_cascades_friendship_trade_challenge_notification
Revision ID: d1e2f3a4b5c6
Revises: c1d2e3f4a5b6
Create Date: 2026-03-29 15:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
# revision identifiers, used by Alembic.
revision: str = 'd1e2f3a4b5c6'
down_revision: Union[str, Sequence[str], None] = 'c1d2e3f4a5b6'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# notifications
op.drop_constraint(op.f('notifications_user_id_fkey'), 'notifications', type_='foreignkey')
op.create_foreign_key(None, 'notifications', 'users', ['user_id'], ['id'], ondelete='CASCADE')
# friendships
op.drop_constraint(op.f('friendships_requester_id_fkey'), 'friendships', type_='foreignkey')
op.create_foreign_key(None, 'friendships', 'users', ['requester_id'], ['id'], ondelete='CASCADE')
op.drop_constraint(op.f('friendships_addressee_id_fkey'), 'friendships', type_='foreignkey')
op.create_foreign_key(None, 'friendships', 'users', ['addressee_id'], ['id'], ondelete='CASCADE')
# trade_proposals
op.drop_constraint(op.f('trade_proposals_proposer_id_fkey'), 'trade_proposals', type_='foreignkey')
op.create_foreign_key(None, 'trade_proposals', 'users', ['proposer_id'], ['id'], ondelete='CASCADE')
op.drop_constraint(op.f('trade_proposals_recipient_id_fkey'), 'trade_proposals', type_='foreignkey')
op.create_foreign_key(None, 'trade_proposals', 'users', ['recipient_id'], ['id'], ondelete='CASCADE')
# game_challenges
op.drop_constraint(op.f('game_challenges_challenger_id_fkey'), 'game_challenges', type_='foreignkey')
op.create_foreign_key(None, 'game_challenges', 'users', ['challenger_id'], ['id'], ondelete='CASCADE')
op.drop_constraint(op.f('game_challenges_challenged_id_fkey'), 'game_challenges', type_='foreignkey')
op.create_foreign_key(None, 'game_challenges', 'users', ['challenged_id'], ['id'], ondelete='CASCADE')
op.drop_constraint(op.f('game_challenges_challenger_deck_id_fkey'), 'game_challenges', type_='foreignkey')
op.create_foreign_key(None, 'game_challenges', 'decks', ['challenger_deck_id'], ['id'], ondelete='CASCADE')
def downgrade() -> None:
# game_challenges
op.drop_constraint(None, 'game_challenges', type_='foreignkey')
op.create_foreign_key(op.f('game_challenges_challenger_deck_id_fkey'), 'game_challenges', 'decks', ['challenger_deck_id'], ['id'])
op.drop_constraint(None, 'game_challenges', type_='foreignkey')
op.create_foreign_key(op.f('game_challenges_challenged_id_fkey'), 'game_challenges', 'users', ['challenged_id'], ['id'])
op.drop_constraint(None, 'game_challenges', type_='foreignkey')
op.create_foreign_key(op.f('game_challenges_challenger_id_fkey'), 'game_challenges', 'users', ['challenger_id'], ['id'])
# trade_proposals
op.drop_constraint(None, 'trade_proposals', type_='foreignkey')
op.create_foreign_key(op.f('trade_proposals_recipient_id_fkey'), 'trade_proposals', 'users', ['recipient_id'], ['id'])
op.drop_constraint(None, 'trade_proposals', type_='foreignkey')
op.create_foreign_key(op.f('trade_proposals_proposer_id_fkey'), 'trade_proposals', 'users', ['proposer_id'], ['id'])
# friendships
op.drop_constraint(None, 'friendships', type_='foreignkey')
op.create_foreign_key(op.f('friendships_addressee_id_fkey'), 'friendships', 'users', ['addressee_id'], ['id'])
op.drop_constraint(None, 'friendships', type_='foreignkey')
op.create_foreign_key(op.f('friendships_requester_id_fkey'), 'friendships', 'users', ['requester_id'], ['id'])
# notifications
op.drop_constraint(None, 'notifications', type_='foreignkey')
op.create_foreign_key(op.f('notifications_user_id_fkey'), 'notifications', 'users', ['user_id'], ['id'])
@@ -0,0 +1,42 @@
"""add_notifications_table
Revision ID: de721927ff59
Revises: 98e23cab7057
Create Date: 2026-03-28 18:51:11.848830
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = 'de721927ff59'
down_revision: Union[str, Sequence[str], None] = '98e23cab7057'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('notifications',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('user_id', sa.UUID(), nullable=False),
sa.Column('type', sa.String(), nullable=False),
sa.Column('payload', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column('read', sa.Boolean(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('expires_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('notifications')
# ### end Alembic commands ###
@@ -0,0 +1,34 @@
"""add is_favorite and willing_to_trade to cards
Revision ID: e70b992e5d95
Revises: a9f2d4e7c301
Create Date: 2026-03-27 17:41:30.462441
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'e70b992e5d95'
down_revision: Union[str, Sequence[str], None] = 'a9f2d4e7c301'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('cards', sa.Column('is_favorite', sa.Boolean(), nullable=False, server_default=sa.false()))
op.add_column('cards', sa.Column('willing_to_trade', sa.Boolean(), nullable=False, server_default=sa.false()))
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('cards', 'willing_to_trade')
op.drop_column('cards', 'is_favorite')
# ### end Alembic commands ###
@@ -0,0 +1,40 @@
"""add fk indices
Revision ID: f4e8a1b2c3d9
Revises: 29da7c818b01
Create Date: 2026-03-29 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
# revision identifiers, used by Alembic.
revision: str = 'f4e8a1b2c3d9'
down_revision: Union[str, None] = '29da7c818b01'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Add indices on FK columns that are missing them."""
op.create_index('ix_cards_user_id', 'cards', ['user_id'])
op.create_index('ix_decks_user_id', 'decks', ['user_id'])
op.create_index('ix_notifications_user_id', 'notifications', ['user_id'])
op.create_index('ix_friendships_requester_id', 'friendships', ['requester_id'])
op.create_index('ix_friendships_addressee_id', 'friendships', ['addressee_id'])
# Composite indices mirror the trade_proposals pattern: filter by owner + status together
op.create_index('ix_game_challenges_challenger_status', 'game_challenges', ['challenger_id', 'status'])
op.create_index('ix_game_challenges_challenged_status', 'game_challenges', ['challenged_id', 'status'])
def downgrade() -> None:
"""Drop FK indices."""
op.drop_index('ix_game_challenges_challenged_status', table_name='game_challenges')
op.drop_index('ix_game_challenges_challenger_status', table_name='game_challenges')
op.drop_index('ix_friendships_addressee_id', table_name='friendships')
op.drop_index('ix_friendships_requester_id', table_name='friendships')
op.drop_index('ix_notifications_user_id', table_name='notifications')
op.drop_index('ix_decks_user_id', table_name='decks')
op.drop_index('ix_cards_user_id', table_name='cards')
@@ -0,0 +1,28 @@
"""add trade_response to notification type check constraint
Revision ID: f657d45be3ae
Revises: 4603709eb82d
Create Date: 2026-03-30 12:10:21.112505
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'f657d45be3ae'
down_revision: Union[str, Sequence[str], None] = '4603709eb82d'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.drop_constraint("ck_notifications_type", "notifications", type_="check")
op.create_check_constraint("ck_notifications_type", "notifications", "type IN ('friend_request', 'trade_offer', 'trade_response', 'game_challenge')")
def downgrade() -> None:
op.drop_constraint("ck_notifications_type", "notifications", type_="check")
op.create_check_constraint("ck_notifications_type", "notifications", "type IN ('friend_request', 'trade_offer', 'game_challenge')")
View File
+4 -1
View File
@@ -1,9 +1,10 @@
import logging
from datetime import datetime, timedelta
from jose import JWTError, jwt
from passlib.context import CryptContext
from config import JWT_SECRET_KEY
from core.config import JWT_SECRET_KEY
logger = logging.getLogger("app")
@@ -40,6 +41,8 @@ def decode_refresh_token(token: str) -> str | None:
def decode_access_token(token: str) -> str | None:
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
if payload.get("type") != "access":
return None
return payload.get("sub")
except JWTError:
return None
@@ -1,9 +1,14 @@
from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, sessionmaker
from config import DATABASE_URL
from core.config import DATABASE_URL
engine = create_engine(DATABASE_URL)
engine = create_engine(
DATABASE_URL,
pool_size=10,
max_overflow=20,
pool_timeout=30,
)
SessionLocal = sessionmaker(bind=engine)
class Base(DeclarativeBase):
+43
View File
@@ -0,0 +1,43 @@
import uuid
from datetime import datetime
from fastapi import Depends, HTTPException, Request, status
from fastapi.security import OAuth2PasswordBearer
from slowapi import Limiter
from slowapi.util import get_remote_address
from sqlalchemy.orm import Session
from core.auth import decode_access_token
from core.database import get_db
from core.models import User as UserModel
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="login")
# Shared rate limiter — registered on app.state in main.py
limiter = Limiter(key_func=get_remote_address)
def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)) -> UserModel:
user_id = decode_access_token(token)
if not user_id:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
user = db.query(UserModel).filter(UserModel.id == uuid.UUID(user_id)).first()
if not user:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
# Throttle to one write per 5 minutes so every authenticated request doesn't hammer the DB
now = datetime.now()
if not user.last_active_at or (now - user.last_active_at).total_seconds() > 300:
user.last_active_at = now
db.commit()
return user
# Per-user key for rate limiting authenticated endpoints — prevents shared IPs (NAT/VPN)
# from having their limits pooled. Falls back to remote IP for unauthenticated requests.
def get_user_id_from_request(request: Request) -> str:
auth = request.headers.get("Authorization", "")
if auth.startswith("Bearer "):
user_id = decode_access_token(auth[7:])
if user_id:
return f"user:{user_id}"
return get_remote_address(request)
+189
View File
@@ -0,0 +1,189 @@
import uuid
from datetime import datetime
from sqlalchemy import String, Integer, ForeignKey, DateTime, Text, Boolean, UniqueConstraint, CheckConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID, JSONB
from core.database import Base
class User(Base):
__tablename__ = "users"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
username: Mapped[str] = mapped_column(String, unique=True, nullable=False)
email: Mapped[str] = mapped_column(String, unique=True, nullable=False)
password_hash: Mapped[str] = mapped_column(String, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now)
boosters: Mapped[int] = mapped_column(Integer, default=5, nullable=False)
boosters_countdown: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
wins: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
losses: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
shards: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
last_refresh_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
reset_token: Mapped[str | None] = mapped_column(String, nullable=True)
reset_token_expires_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
email_verified: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
email_verification_token: Mapped[str | None] = mapped_column(String, nullable=True)
email_verification_token_expires_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
trade_wishlist: Mapped[str | None] = mapped_column(Text, nullable=True, default="")
last_active_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
cards: Mapped[list["Card"]] = relationship(back_populates="user", cascade="all, delete-orphan")
decks: Mapped[list["Deck"]] = relationship(back_populates="user", cascade="all, delete-orphan")
notifications: Mapped[list["Notification"]] = relationship(back_populates="user", cascade="all, delete-orphan")
friendships_sent: Mapped[list["Friendship"]] = relationship(
foreign_keys="Friendship.requester_id", back_populates="requester", cascade="all, delete-orphan"
)
friendships_received: Mapped[list["Friendship"]] = relationship(
foreign_keys="Friendship.addressee_id", back_populates="addressee", cascade="all, delete-orphan"
)
proposals_sent: Mapped[list["TradeProposal"]] = relationship(
foreign_keys="TradeProposal.proposer_id", back_populates="proposer", cascade="all, delete-orphan"
)
proposals_received: Mapped[list["TradeProposal"]] = relationship(
foreign_keys="TradeProposal.recipient_id", back_populates="recipient", cascade="all, delete-orphan"
)
challenges_sent: Mapped[list["GameChallenge"]] = relationship(
foreign_keys="GameChallenge.challenger_id", back_populates="challenger", cascade="all, delete-orphan"
)
challenges_received: Mapped[list["GameChallenge"]] = relationship(
foreign_keys="GameChallenge.challenged_id", back_populates="challenged", cascade="all, delete-orphan"
)
class Card(Base):
__tablename__ = "cards"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=True)
name: Mapped[str] = mapped_column(String, nullable=False)
image_link: Mapped[str] = mapped_column(String, nullable=True)
card_rarity: Mapped[str] = mapped_column(String, nullable=False)
card_type: Mapped[str] = mapped_column(String, nullable=False)
text: Mapped[str] = mapped_column(Text, nullable=True)
attack: Mapped[int] = mapped_column(Integer, nullable=False)
defense: Mapped[int] = mapped_column(Integer, nullable=False)
cost: Mapped[int] = mapped_column(Integer, nullable=False)
generated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now)
received_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, default=None)
times_played: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
reported: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
ai_used: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
is_favorite: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
willing_to_trade: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
user: Mapped["User | None"] = relationship(back_populates="cards")
deck_cards: Mapped[list["DeckCard"]] = relationship(back_populates="card", cascade="all, delete-orphan")
class Deck(Base):
__tablename__ = "decks"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
name: Mapped[str] = mapped_column(String, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now)
times_played: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
wins: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
losses: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
deleted: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
user: Mapped["User"] = relationship(back_populates="decks")
deck_cards: Mapped[list["DeckCard"]] = relationship(back_populates="deck", cascade="all, delete-orphan")
class Notification(Base):
__tablename__ = "notifications"
__table_args__ = (
CheckConstraint("type IN ('friend_request', 'trade_offer', 'trade_response', 'game_challenge')", name="ck_notifications_type"),
)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
# type is one of: friend_request, trade_offer, trade_response, game_challenge
type: Mapped[str] = mapped_column(String, nullable=False)
payload: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
read: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now)
expires_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
user: Mapped["User"] = relationship(back_populates="notifications")
class Friendship(Base):
__tablename__ = "friendships"
__table_args__ = (
UniqueConstraint("requester_id", "addressee_id", name="uq_friendship_requester_addressee"),
CheckConstraint("status IN ('pending', 'accepted', 'declined')", name="ck_friendships_status"),
)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
requester_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
addressee_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
# status: pending / accepted / declined
status: Mapped[str] = mapped_column(String, nullable=False, default="pending")
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now)
requester: Mapped["User"] = relationship(foreign_keys=[requester_id], back_populates="friendships_sent")
addressee: Mapped["User"] = relationship(foreign_keys=[addressee_id], back_populates="friendships_received")
class TradeProposal(Base):
__tablename__ = "trade_proposals"
__table_args__ = (
CheckConstraint("status IN ('pending', 'accepted', 'declined', 'expired')", name="ck_trade_proposals_status"),
)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
proposer_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
recipient_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
# Both sides stored as JSONB lists of UUID strings so either party can offer 0 or more cards,
# mirroring the flexibility of the real-time trade system
offered_card_ids: Mapped[list] = mapped_column(JSONB, nullable=False, default=list)
requested_card_ids: Mapped[list] = mapped_column(JSONB, nullable=False, default=list)
# status: pending / accepted / declined / expired
status: Mapped[str] = mapped_column(String, nullable=False, default="pending")
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now)
expires_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
proposer: Mapped["User"] = relationship(foreign_keys=[proposer_id])
recipient: Mapped["User"] = relationship(foreign_keys=[recipient_id])
class GameChallenge(Base):
__tablename__ = "game_challenges"
__table_args__ = (
CheckConstraint("status IN ('pending', 'accepted', 'declined', 'expired')", name="ck_game_challenges_status"),
)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
challenger_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
challenged_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
challenger_deck_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("decks.id", ondelete="CASCADE"), nullable=False)
# status: pending / accepted / declined / expired
status: Mapped[str] = mapped_column(String, nullable=False, default="pending")
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now)
expires_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
challenger: Mapped["User"] = relationship(foreign_keys=[challenger_id], back_populates="challenges_sent")
challenged: Mapped["User"] = relationship(foreign_keys=[challenged_id], back_populates="challenges_received")
challenger_deck: Mapped["Deck"] = relationship(foreign_keys=[challenger_deck_id])
class DeckCard(Base):
__tablename__ = "deck_cards"
deck_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("decks.id", ondelete="CASCADE"), primary_key=True)
card_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("cards.id", ondelete="CASCADE"), primary_key=True)
deck: Mapped["Deck"] = relationship(back_populates="deck_cards")
card: Mapped["Card"] = relationship(back_populates="deck_cards")
class ProcessedWebhookEvent(Base):
__tablename__ = "processed_webhook_events"
# stripe_event_id is the primary key — acts as unique constraint to prevent duplicate processing
stripe_event_id: Mapped[str] = mapped_column(String, primary_key=True)
processed_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now, nullable=False)
-89
View File
@@ -1,89 +0,0 @@
import logging
import asyncio
from datetime import datetime, timedelta
from sqlalchemy.orm import Session
from card import _get_cards_async
from models import Card as CardModel
from models import User as UserModel
from database import SessionLocal
logger = logging.getLogger("app")
POOL_MINIMUM = 1000
POOL_TARGET = 2000
POOL_BATCH_SIZE = 10
POOL_SLEEP = 4.0
pool_filling = False
async def fill_card_pool():
global pool_filling
if pool_filling:
logger.info("Pool fill already in progress, skipping")
return
db: Session = SessionLocal()
while True:
try:
unassigned = db.query(CardModel).filter(CardModel.user_id == None, CardModel.ai_used == False).count()
logger.info(f"Card pool has {unassigned} unassigned cards")
if unassigned >= POOL_MINIMUM:
logger.info("Pool sufficiently stocked, skipping fill")
return
pool_filling = True
needed = POOL_TARGET - unassigned
logger.info(f"Filling pool with {needed} cards")
fetched = 0
while fetched < needed:
batch_size = min(POOL_BATCH_SIZE, needed - fetched)
cards = await _get_cards_async(batch_size)
for card in cards:
db.add(CardModel(
name=card.name,
image_link=card.image_link,
card_rarity=card.card_rarity.name,
card_type=card.card_type.name,
text=card.text,
attack=card.attack,
defense=card.defense,
cost=card.cost,
user_id=None,
))
db.commit()
fetched += batch_size
logger.info(f"Pool fill progress: {fetched}/{needed}")
await asyncio.sleep(POOL_SLEEP)
finally:
pool_filling = False
db.close()
BOOSTER_MAX = 5
BOOSTER_COOLDOWN_HOURS = 5
def check_boosters(user: UserModel, db: Session) -> tuple[int, datetime|None]:
if user.boosters_countdown is None:
if user.boosters < BOOSTER_MAX:
user.boosters = BOOSTER_MAX
db.commit()
return (user.boosters, user.boosters_countdown)
now = datetime.now()
countdown = user.boosters_countdown
while user.boosters < BOOSTER_MAX:
next_tick = countdown + timedelta(hours=BOOSTER_COOLDOWN_HOURS)
if now >= next_tick:
user.boosters += 1
countdown = next_tick
else:
break
user.boosters_countdown = countdown if user.boosters < BOOSTER_MAX else None
db.commit()
return (user.boosters, user.boosters_countdown)
View File
+8 -5
View File
@@ -6,7 +6,7 @@ from urllib.parse import quote
from datetime import datetime, timedelta
from time import sleep
from config import WIKIRANK_USER_AGENT
from core.config import WIKIRANK_USER_AGENT
HEADERS = {"User-Agent": WIKIRANK_USER_AGENT}
logger = logging.getLogger("app")
@@ -33,7 +33,7 @@ class CardRarity(Enum):
class Card(NamedTuple):
name: str
created_at: datetime
generated_at: datetime
image_link: str
card_rarity: CardRarity
card_type: CardType
@@ -81,7 +81,7 @@ class Card(NamedTuple):
return_string += ""+f"{l:{' '}<50}"+"\n"
return_string += ""+""*50+"\n"
date_text = str(self.created_at.date())
date_text = str(self.generated_at.date())
stats = f"{self.attack}/{self.defense}"
spaces = 50 - (len(date_text) + len(stats))
return_string += ""+date_text + " "*spaces + stats + "\n"
@@ -123,6 +123,7 @@ WIKIDATA_INSTANCE_TYPE_MAP = {
"Q1446621": CardType.artwork, # recital
"Q1868552": CardType.artwork, # local newspaper
"Q3244175": CardType.artwork, # tabletop game
"Q2031291": CardType.artwork, # musical release
"Q63952888": CardType.artwork, # anime television series
"Q47461344": CardType.artwork, # written work
"Q71631512": CardType.artwork, # tabletop role-playing game supplement
@@ -167,6 +168,7 @@ WIKIDATA_INSTANCE_TYPE_MAP = {
"Q198": CardType.event, # war
"Q8465": CardType.event, # civil war
"Q844482": CardType.event, # killing
"Q141022": CardType.event, # eclipse
"Q103495": CardType.event, # world war
"Q350604": CardType.event, # armed conflict
@@ -180,7 +182,7 @@ WIKIDATA_INSTANCE_TYPE_MAP = {
"Q1361229": CardType.event, # conquest
"Q2223653": CardType.event, # terrorist attack
"Q2672648": CardType.event, # social conflict
"Q2627975": CardType.event, # ceremony
"Q2627975": CardType.event, # ceremony"
"Q16510064": CardType.event, # sporting event
"Q10688145": CardType.event, # season
"Q13418847": CardType.event, # historical event
@@ -275,6 +277,7 @@ WIKIDATA_INSTANCE_TYPE_MAP = {
"Q1428357": CardType.vehicle, # submarine class
"Q1499623": CardType.vehicle, # destroyer escort
"Q4818021": CardType.vehicle, # attack submarine
"Q45296117": CardType.vehicle, # aircraft type
"Q15141321": CardType.vehicle, # train service
"Q19832486": CardType.vehicle, # locomotive class
"Q23866334": CardType.vehicle, # motorcycle model
@@ -544,7 +547,7 @@ async def _get_card_async(client: httpx.AsyncClient, page_title: str|None = None
return Card(
name=summary["title"],
created_at=datetime.now(),
generated_at=datetime.now(),
image_link=summary.get("thumbnail", {}).get("source", ""),
card_rarity=rarity,
card_type=card_type,
@@ -1,20 +1,21 @@
import asyncio
import uuid
from datetime import datetime
import logging
import random
import uuid
from dataclasses import dataclass
from datetime import datetime
from fastapi import WebSocket
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import Session
from game import (
from game.rules import (
GameState, CardInstance, PlayerState, action_play_card, action_sacrifice,
action_end_turn, create_game, CombatEvent, GameResult, BOARD_SIZE
)
from models import Card as CardModel, Deck as DeckModel, DeckCard as DeckCardModel, User as UserModel
from card import compute_deck_type
from ai import AI_USER_ID, run_ai_turn, get_random_personality, choose_cards
from core.models import Card as CardModel, Deck as DeckModel, DeckCard as DeckCardModel, User as UserModel
from game.card import compute_deck_type
from ai.engine import AI_USER_ID, run_ai_turn, get_random_personality, choose_cards
logger = logging.getLogger("app")
@@ -90,7 +91,9 @@ def serialize_card(card: CardInstance|None) -> dict | None:
"card_type": card.card_type,
"card_rarity": card.card_rarity,
"image_link": card.image_link,
"text": card.text
"text": card.text,
"is_favorite": card.is_favorite,
"willing_to_trade": card.willing_to_trade,
}
def serialize_player(player: PlayerState, hide_hand=False) -> dict:
@@ -150,8 +153,8 @@ async def broadcast_state(game_id: str):
"type": "state",
"state": serialize_state(state, user_id),
})
except Exception:
pass
except Exception as e:
logger.debug(f"WebSocket send failed (stale connection): {e}")
if state.active_player_id == AI_USER_ID and not state.result:
asyncio.create_task(run_ai_turn(game_id))
@@ -221,6 +224,33 @@ async def try_match(db: Session):
await broadcast_state(state.game_id)
## Direct challenge game creation (no WebSocket needed at creation time)
def create_challenge_game(
challenger_id: str, challenger_deck_id: str,
challenged_id: str, challenged_deck_id: str,
db: Session
) -> str:
challenger = db.query(UserModel).filter(UserModel.id == uuid.UUID(challenger_id)).first()
challenged = db.query(UserModel).filter(UserModel.id == uuid.UUID(challenged_id)).first()
p1_cards = load_deck_cards(challenger_deck_id, challenger_id, db)
p2_cards = load_deck_cards(challenged_deck_id, challenged_id, db)
if not p1_cards or not p2_cards or not challenger or not challenged:
raise ValueError("Could not load decks or players")
p1_deck_type = compute_deck_type(p1_cards)
p2_deck_type = compute_deck_type(p2_cards)
state = create_game(
challenger_id, challenger.username, p1_deck_type or "", p1_cards,
challenged_id, challenged.username, p2_deck_type or "", p2_cards,
)
active_games[state.game_id] = state
# Initialize with no websockets; players connect via /ws/game/{game_id} after redirect
connections[state.game_id] = {challenger_id: None, challenged_id: None}
active_deck_ids[challenger_id] = challenger_deck_id
active_deck_ids[challenged_id] = challenged_deck_id
return state.game_id
## Action handler
async def handle_action(game_id: str, user_id: str, message: dict, db: Session):
@@ -255,7 +285,7 @@ async def handle_action(game_id: str, user_id: str, message: dict, db: Session):
if card:
card.times_played += 1
db.commit()
except Exception as e:
except (SQLAlchemyError, ValueError) as e:
logger.warning(f"Failed to increment times_played for card {card_instance.card_id}: {e}")
db.rollback()
elif action == "sacrifice":
@@ -275,8 +305,8 @@ async def handle_action(game_id: str, user_id: str, message: dict, db: Session):
"type": "sacrifice_animation",
"instance_id": card.instance_id,
})
except Exception:
pass
except Exception as e:
logger.debug(f"WebSocket send failed (stale connection): {e}")
await asyncio.sleep(0.65)
err = action_sacrifice(state, slot)
elif action == "end_turn":
@@ -325,7 +355,7 @@ async def handle_disconnect(game_id: str, user_id: str):
)
state.phase = "end"
from database import SessionLocal
from core.database import SessionLocal
db = SessionLocal()
try:
record_game_result(state, db)
@@ -340,8 +370,8 @@ async def handle_disconnect(game_id: str, user_id: str):
"type": "state",
"state": serialize_state(state, winner_id),
})
except Exception:
pass
except Exception as e:
logger.debug(f"WebSocket send failed (stale connection): {e}")
active_deck_ids.pop(user_id, None)
active_deck_ids.pop(winner_id, None)
+8 -4
View File
@@ -1,10 +1,10 @@
from dataclasses import dataclass, field
from typing import Optional
import random
import uuid
from dataclasses import dataclass, field
from datetime import datetime
from typing import Optional
from models import Card as CardModel
from core.models import Card as CardModel
STARTING_LIFE = 1000
MAX_ENERGY_CAP = 6
@@ -24,6 +24,8 @@ class CardInstance:
card_rarity: str
image_link: str
text: str
is_favorite: bool = False
willing_to_trade: bool = False
@classmethod
def from_db_card(cls, card: CardModel) -> "CardInstance":
@@ -38,7 +40,9 @@ class CardInstance:
card_type=card.card_type,
card_rarity=card.card_rarity,
image_link=card.image_link or "",
text=card.text
text=card.text,
is_favorite=card.is_favorite,
willing_to_trade=card.willing_to_trade,
)
@dataclass
+8 -5
View File
@@ -8,15 +8,17 @@ Example:
python give_card.py nikolaj "Marie Curie"
"""
import sys
import asyncio
import sys
import uuid
from datetime import datetime
from dotenv import load_dotenv
load_dotenv()
from database import SessionLocal
from models import User as UserModel, Card as CardModel
from card import _get_specific_card_async
import uuid
from game.card import _get_specific_card_async
from core.database import SessionLocal
from core.models import User as UserModel, Card as CardModel
async def main(username: str, page_title: str) -> None:
@@ -44,6 +46,7 @@ async def main(username: str, page_title: str) -> None:
attack=card.attack,
defense=card.defense,
cost=card.cost,
received_at=datetime.now(),
)
db.add(db_card)
db.commit()
+26 -818
View File
@@ -1,846 +1,54 @@
import asyncio
import logging
import uuid
import re
from contextlib import asynccontextmanager
from datetime import datetime, timedelta
from typing import cast, Callable
import secrets
from typing import Callable, cast
from dotenv import load_dotenv
load_dotenv()
from sqlalchemy.orm import Session
from sqlalchemy import func
from fastapi import FastAPI, Depends, HTTPException, status, WebSocket, WebSocketDisconnect, Request
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
from slowapi import Limiter, _rate_limit_exceeded_handler
from slowapi.util import get_remote_address
from slowapi.errors import RateLimitExceeded
from database import get_db
from database_functions import fill_card_pool, check_boosters, BOOSTER_MAX
from models import Card as CardModel
from models import User as UserModel
from models import Deck as DeckModel
from models import DeckCard as DeckCardModel
from auth import (
hash_password, verify_password, create_access_token, create_refresh_token,
decode_access_token, decode_refresh_token
)
from game_manager import (
queue, queue_lock, QueueEntry, try_match, handle_action, connections, active_games,
serialize_state, handle_disconnect, handle_timeout_claim, load_deck_cards, create_solo_game
)
from trade_manager import (
trade_queue, trade_queue_lock, TradeQueueEntry, try_trade_match,
handle_trade_action, active_trades, handle_trade_disconnect,
serialize_trade,
)
from card import compute_deck_type, _get_specific_card_async
from email_utils import send_password_reset_email, send_verification_email
from config import CORS_ORIGINS, STRIPE_SECRET_KEY, STRIPE_PUBLISHABLE_KEY, STRIPE_WEBHOOK_SECRET, FRONTEND_URL
import stripe
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from slowapi.errors import RateLimitExceeded
from slowapi import _rate_limit_exceeded_handler
from core.config import CORS_ORIGINS, STRIPE_SECRET_KEY
from core.dependencies import limiter
from services.database_functions import fill_card_pool, run_cleanup_loop
from routers import auth, cards, decks, games, health, notifications, profile, friends, store, trades
stripe.api_key = STRIPE_SECRET_KEY
logger = logging.getLogger("app")
# Auth
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="login")
class RegisterRequest(BaseModel):
username: str
email: str
password: str
def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)) -> UserModel:
user_id = decode_access_token(token)
if not user_id:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
user = db.query(UserModel).filter(UserModel.id == uuid.UUID(user_id)).first()
if not user:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
return user
class ForgotPasswordRequest(BaseModel):
email: str
class ResetPasswordWithTokenRequest(BaseModel):
token: str
new_password: str
@asynccontextmanager
async def lifespan(app: FastAPI):
asyncio.create_task(fill_card_pool())
asyncio.create_task(run_cleanup_loop())
yield
app = FastAPI(lifespan=lifespan)
# Rate limiting
limiter = Limiter(key_func=get_remote_address)
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, cast(Callable, _rate_limit_exceeded_handler))
app.add_middleware(
CORSMiddleware,
allow_origins=CORS_ORIGINS, # SvelteKit's default dev port
allow_origins=CORS_ORIGINS,
allow_methods=["*"],
allow_headers=["*"],
)
try:
from disposable_email_domains import blocklist as _disposable_blocklist
except ImportError:
_disposable_blocklist: set[str] = set()
def validate_register(username: str, email: str, password: str) -> str | None:
if not username.strip():
return "Username is required"
if len(username) > 16:
return "Username must be 16 characters or fewer"
if not re.match(r"^[^\s@]+@[^\s@]+\.[^\s@]+$", email):
return "Please enter a valid email"
domain = email.split("@")[-1].lower()
if domain in _disposable_blocklist:
return "Disposable email addresses are not allowed"
if len(password) < 8:
return "Password must be at least 8 characters"
if len(password) > 256:
return "Password must be 256 characters or fewer"
return None
@app.post("/register")
def register(req: RegisterRequest, db: Session = Depends(get_db)):
err = validate_register(req.username, req.email, req.password)
if err:
raise HTTPException(status_code=400, detail=err)
if db.query(UserModel).filter(UserModel.username == req.username).first():
raise HTTPException(status_code=400, detail="Username already taken")
if db.query(UserModel).filter(UserModel.email == req.email).first():
raise HTTPException(status_code=400, detail="Email already registered")
verification_token = secrets.token_urlsafe(32)
user = UserModel(
id=uuid.uuid4(),
username=req.username,
email=req.email,
password_hash=hash_password(req.password),
email_verified=False,
email_verification_token=verification_token,
email_verification_token_expires_at=datetime.now() + timedelta(hours=24),
)
db.add(user)
db.commit()
try:
send_verification_email(req.email, req.username, verification_token)
except Exception as e:
logger.error(f"Failed to send verification email: {e}")
return {"message": "Account created. Please check your email to verify your account."}
@app.post("/login")
def login(form: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)):
user = db.query(UserModel).filter(UserModel.username == form.username).first()
if not user or not verify_password(form.password, user.password_hash):
raise HTTPException(status_code=400, detail="Invalid username or password")
return {
"access_token": create_access_token(str(user.id)),
"refresh_token": create_refresh_token(str(user.id)),
"token_type": "bearer",
}
@app.get("/boosters")
def get_boosters(user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
count, countdown = check_boosters(user, db)
return {"count": count, "countdown": countdown, "email_verified": user.email_verified}
@app.get("/cards")
def get_cards(user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
cards = db.query(CardModel).filter(CardModel.user_id == user.id).all()
return [
{**{c.name: getattr(card, c.name) for c in card.__table__.columns},
"card_rarity": card.card_rarity,
"card_type": card.card_type}
for card in cards
]
@app.get("/cards/in-decks")
def get_cards_in_decks(user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
deck_ids = [d.id for d in db.query(DeckModel).filter(DeckModel.user_id == user.id, DeckModel.deleted == False).all()]
if not deck_ids:
return []
card_ids = db.query(DeckCardModel.card_id).filter(DeckCardModel.deck_id.in_(deck_ids)).distinct().all()
return [str(row.card_id) for row in card_ids]
@app.post("/open_pack")
@limiter.limit("10/minute")
async def open_pack(request: Request, user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
if not user.email_verified:
raise HTTPException(status_code=403, detail="You must verify your email before opening packs")
check_boosters(user, db)
if user.boosters == 0:
raise HTTPException(status_code=400, detail="No booster packs available")
cards = (
db.query(CardModel)
.filter(CardModel.user_id == None, CardModel.ai_used == False)
.limit(5)
.all()
)
if len(cards) < 5:
asyncio.create_task(fill_card_pool())
raise HTTPException(status_code=503, detail="Card pool is low, please try again shortly")
for card in cards:
card.user_id = user.id
was_full = user.boosters == BOOSTER_MAX
user.boosters -= 1
if was_full:
user.boosters_countdown = datetime.now()
db.commit()
asyncio.create_task(fill_card_pool())
return [
{**{c.name: getattr(card, c.name) for c in card.__table__.columns},
"card_rarity": card.card_rarity,
"card_type": card.card_type}
for card in cards
]
@app.get("/decks")
def get_decks(user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
decks = db.query(DeckModel).filter(
DeckModel.user_id == user.id,
DeckModel.deleted == False
).order_by(DeckModel.created_at).all()
result = []
for deck in decks:
card_ids = [dc.card_id for dc in db.query(DeckCardModel).filter(DeckCardModel.deck_id == deck.id).all()]
cards = db.query(CardModel).filter(CardModel.id.in_(card_ids)).all()
result.append({
"id": str(deck.id),
"name": deck.name,
"card_count": len(cards),
"total_cost": sum(card.cost for card in cards),
"times_played": deck.times_played,
"wins": deck.wins,
"losses": deck.losses,
"deck_type": compute_deck_type(cards),
})
return result
@app.post("/decks")
def create_deck(user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
count = db.query(DeckModel).filter(DeckModel.user_id == user.id).count()
deck = DeckModel(id=uuid.uuid4(), user_id=user.id, name=f"Deck #{count + 1}")
db.add(deck)
db.commit()
return {"id": str(deck.id), "name": deck.name, "card_count": 0}
@app.patch("/decks/{deck_id}")
def update_deck(deck_id: str, body: dict, user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
deck = db.query(DeckModel).filter(DeckModel.id == uuid.UUID(deck_id), DeckModel.user_id == user.id).first()
if not deck:
raise HTTPException(status_code=404, detail="Deck not found")
if "name" in body:
deck.name = body["name"]
if "card_ids" in body:
db.query(DeckCardModel).filter(DeckCardModel.deck_id == deck.id).delete()
for card_id in body["card_ids"]:
db.add(DeckCardModel(deck_id=deck.id, card_id=uuid.UUID(card_id)))
if deck.times_played > 0:
deck.wins = 0
deck.losses = 0
deck.times_played = 0
db.commit()
return {"id": str(deck.id), "name": deck.name}
@app.delete("/decks/{deck_id}")
def delete_deck(deck_id: str, user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
deck = db.query(DeckModel).filter(DeckModel.id == uuid.UUID(deck_id), DeckModel.user_id == user.id).first()
if not deck:
raise HTTPException(status_code=404, detail="Deck not found")
if deck.times_played > 0:
deck.deleted = True
else:
db.query(DeckCardModel).filter(DeckCardModel.deck_id == deck.id).delete()
db.delete(deck)
db.commit()
return {"message": "Deleted"}
@app.get("/decks/{deck_id}/cards")
def get_deck_cards(deck_id: str, user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
deck = db.query(DeckModel).filter(DeckModel.id == uuid.UUID(deck_id), DeckModel.user_id == user.id).first()
if not deck:
raise HTTPException(status_code=404, detail="Deck not found")
deck_cards = db.query(DeckCardModel).filter(DeckCardModel.deck_id == deck.id).all()
return [str(dc.card_id) for dc in deck_cards]
@app.websocket("/ws/queue")
async def queue_endpoint(websocket: WebSocket, deck_id: str, db: Session = Depends(get_db)):
await websocket.accept()
token = await websocket.receive_text()
user_id = decode_access_token(token)
if not user_id:
await websocket.close(code=1008)
return
deck = db.query(DeckModel).filter(
DeckModel.id == uuid.UUID(deck_id),
DeckModel.user_id == uuid.UUID(user_id)
).first()
if not deck:
await websocket.send_json({"type": "error", "message": "Deck not found"})
await websocket.close(code=1008)
return
card_ids = [dc.card_id for dc in db.query(DeckCardModel).filter(DeckCardModel.deck_id == deck.id).all()]
total_cost = db.query(func.sum(CardModel.cost)).filter(CardModel.id.in_(card_ids)).scalar() or 0
if total_cost == 0 or total_cost > 50:
await websocket.send_json({"type": "error", "message": "Deck total cost must be between 1 and 50"})
await websocket.close(code=1008)
return
entry = QueueEntry(user_id=user_id, deck_id=deck_id, websocket=websocket)
async with queue_lock:
queue.append(entry)
await websocket.send_json({"type": "queued"})
await try_match(db)
try:
while True:
# Keeping socket alive
await websocket.receive_text()
except WebSocketDisconnect:
async with queue_lock:
queue[:] = [e for e in queue if e.user_id != user_id]
@app.websocket("/ws/game/{game_id}")
async def game_endpoint(websocket: WebSocket, game_id: str, db: Session = Depends(get_db)):
await websocket.accept()
token = await websocket.receive_text()
user_id = decode_access_token(token)
if not user_id:
await websocket.close(code=1008)
return
if game_id not in active_games:
await websocket.close(code=1008)
return
# Register this connection (handles reconnects)
connections[game_id][user_id] = websocket
# Send current state immediately on connect
await websocket.send_json({
"type": "state",
"state": serialize_state(active_games[game_id], user_id),
})
try:
while True:
data = await websocket.receive_json()
await handle_action(game_id, user_id, data, db)
except WebSocketDisconnect:
if game_id in connections:
connections[game_id].pop(user_id, None)
asyncio.create_task(handle_disconnect(game_id, user_id))
@app.websocket("/ws/trade/queue")
async def trade_queue_endpoint(websocket: WebSocket, db: Session = Depends(get_db)):
await websocket.accept()
token = await websocket.receive_text()
user_id = decode_access_token(token)
if not user_id:
await websocket.close(code=1008)
return
user = db.query(UserModel).filter(UserModel.id == uuid.UUID(user_id)).first()
if not user:
await websocket.close(code=1008)
return
if not user.email_verified:
await websocket.send_json({"type": "error", "message": "You must verify your email before trading."})
await websocket.close(code=1008)
return
entry = TradeQueueEntry(user_id=user_id, username=user.username, websocket=websocket)
async with trade_queue_lock:
trade_queue.append(entry)
await websocket.send_json({"type": "queued"})
await try_trade_match()
try:
while True:
await websocket.receive_text()
except WebSocketDisconnect:
async with trade_queue_lock:
trade_queue[:] = [e for e in trade_queue if e.user_id != user_id]
@app.websocket("/ws/trade/{trade_id}")
async def trade_endpoint(websocket: WebSocket, trade_id: str, db: Session = Depends(get_db)):
await websocket.accept()
token = await websocket.receive_text()
user_id = decode_access_token(token)
if not user_id:
await websocket.close(code=1008)
return
session = active_trades.get(trade_id)
if not session or user_id not in session.offers:
await websocket.close(code=1008)
return
session.connections[user_id] = websocket
await websocket.send_json({
"type": "state",
"state": serialize_trade(session, user_id),
})
try:
while True:
data = await websocket.receive_json()
await handle_trade_action(trade_id, user_id, data, db)
except WebSocketDisconnect:
session.connections.pop(user_id, None)
asyncio.create_task(handle_trade_disconnect(trade_id, user_id))
@app.get("/profile")
def get_profile(user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
total_games = user.wins + user.losses
most_played_deck = (
db.query(DeckModel)
.filter(DeckModel.user_id == user.id, DeckModel.times_played > 0)
.order_by(DeckModel.times_played.desc())
.first()
)
most_played_card = (
db.query(CardModel)
.filter(CardModel.user_id == user.id, CardModel.times_played > 0)
.order_by(CardModel.times_played.desc())
.first()
)
return {
"username": user.username,
"email": user.email,
"email_verified": user.email_verified,
"created_at": user.created_at,
"wins": user.wins,
"losses": user.losses,
"shards": user.shards,
"win_rate": round((user.wins / total_games) * 100) if total_games > 0 else None,
"most_played_deck": {
"name": most_played_deck.name,
"times_played": most_played_deck.times_played,
} if most_played_deck else None,
"most_played_card": {
"name": most_played_card.name,
"times_played": most_played_card.times_played,
"card_type": most_played_card.card_type,
"card_rarity": most_played_card.card_rarity,
"image_link": most_played_card.image_link,
} if most_played_card else None,
}
class ShatterRequest(BaseModel):
card_ids: list[str]
@app.post("/shards/shatter")
def shatter_cards(req: ShatterRequest, user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
if not req.card_ids:
raise HTTPException(status_code=400, detail="No cards selected")
try:
parsed_ids = [uuid.UUID(cid) for cid in req.card_ids]
except ValueError:
raise HTTPException(status_code=400, detail="Invalid card IDs")
cards = db.query(CardModel).filter(
CardModel.id.in_(parsed_ids),
CardModel.user_id == user.id,
).all()
if len(cards) != len(parsed_ids):
raise HTTPException(status_code=400, detail="Some cards are not in your collection")
total = sum(c.cost for c in cards)
for card in cards:
db.query(DeckCardModel).filter(DeckCardModel.card_id == card.id).delete()
db.delete(card)
user.shards += total
db.commit()
return {"shards": user.shards, "gained": total}
# Shard packages sold for real money.
# price_oere is in Danish øre (1 DKK = 100 øre). Stripe minimum is 250 øre.
SHARD_PACKAGES = {
"s1": {"base": 100, "bonus": 0, "shards": 100, "price_oere": 1000, "price_label": "10 DKK"},
"s2": {"base": 250, "bonus": 50, "shards": 300, "price_oere": 2500, "price_label": "25 DKK"},
"s3": {"base": 500, "bonus": 200, "shards": 700, "price_oere": 5000, "price_label": "50 DKK"},
"s4": {"base": 1000, "bonus": 600, "shards": 1600, "price_oere": 10000, "price_label": "100 DKK"},
"s5": {"base": 2500, "bonus": 2000, "shards": 4500, "price_oere": 25000, "price_label": "250 DKK"},
"s6": {"base": 5000, "bonus": 5000, "shards": 10000, "price_oere": 50000, "price_label": "500 DKK"},
}
class StripeCheckoutRequest(BaseModel):
package_id: str
@app.post("/store/stripe/checkout")
def create_stripe_checkout(req: StripeCheckoutRequest, user: UserModel = Depends(get_current_user)):
package = SHARD_PACKAGES.get(req.package_id)
if not package:
raise HTTPException(status_code=400, detail="Invalid package")
session = stripe.checkout.Session.create(
payment_method_types=["card"],
line_items=[{
"price_data": {
"currency": "dkk",
"product_data": {"name": f"WikiTCG Shards — {package['price_label']}"},
"unit_amount": package["price_oere"],
},
"quantity": 1,
}],
mode="payment",
success_url=f"{FRONTEND_URL}/store?payment=success",
cancel_url=f"{FRONTEND_URL}/store",
metadata={"user_id": str(user.id), "shards": str(package["shards"])},
)
return {"url": session.url}
@app.post("/stripe/webhook")
async def stripe_webhook(request: Request, db: Session = Depends(get_db)):
payload = await request.body()
sig = request.headers.get("stripe-signature", "")
try:
event = stripe.Webhook.construct_event(payload, sig, STRIPE_WEBHOOK_SECRET)
except stripe.error.SignatureVerificationError: # type: ignore
raise HTTPException(status_code=400, detail="Invalid signature")
if event["type"] == "checkout.session.completed":
data = event["data"]["object"]
user_id = data.get("metadata", {}).get("user_id")
shards = data.get("metadata", {}).get("shards")
if user_id and shards:
user = db.query(UserModel).filter(UserModel.id == uuid.UUID(user_id)).first()
if user:
user.shards += int(shards)
db.commit()
return {"ok": True}
@app.get("/store/config")
def store_config():
return {
"publishable_key": STRIPE_PUBLISHABLE_KEY,
"shard_packages": SHARD_PACKAGES,
}
STORE_PACKAGES = {
1: 15,
5: 65,
10: 120,
25: 260,
}
class StoreBuyRequest(BaseModel):
quantity: int
class BuySpecificCardRequest(BaseModel):
wiki_title: str
SPECIFIC_CARD_COST = 1000
@app.post("/store/buy-specific-card")
@limiter.limit("10/hour")
async def buy_specific_card(request: Request, req: BuySpecificCardRequest, user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
if user.shards < SPECIFIC_CARD_COST:
raise HTTPException(status_code=400, detail="Not enough shards")
card = await _get_specific_card_async(req.wiki_title)
if card is None:
raise HTTPException(status_code=404, detail="Could not generate a card for that Wikipedia page")
db_card = CardModel(
name=card.name,
image_link=card.image_link,
card_rarity=card.card_rarity.name,
card_type=card.card_type.name,
text=card.text,
attack=card.attack,
defense=card.defense,
cost=card.cost,
user_id=user.id,
)
db.add(db_card)
user.shards -= SPECIFIC_CARD_COST
db.commit()
db.refresh(db_card)
return {
**{c.name: getattr(db_card, c.name) for c in db_card.__table__.columns},
"card_rarity": db_card.card_rarity,
"card_type": db_card.card_type,
"shards": user.shards,
}
@app.post("/store/buy")
def store_buy(req: StoreBuyRequest, user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
cost = STORE_PACKAGES.get(req.quantity)
if cost is None:
raise HTTPException(status_code=400, detail="Invalid package")
if user.shards < cost:
raise HTTPException(status_code=400, detail="Not enough shards")
user.shards -= cost
user.boosters += req.quantity
db.commit()
return {"shards": user.shards, "boosters": user.boosters}
@app.post("/cards/{card_id}/report")
def report_card(card_id: str, user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
card = db.query(CardModel).filter(
CardModel.id == uuid.UUID(card_id),
CardModel.user_id == user.id
).first()
if not card:
raise HTTPException(status_code=404, detail="Card not found")
card.reported = True
db.commit()
return {"message": "Card reported"}
@app.post("/cards/{card_id}/refresh")
@limiter.limit("5/hour")
async def refresh_card(request: Request, card_id: str, user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
card = db.query(CardModel).filter(
CardModel.id == uuid.UUID(card_id),
CardModel.user_id == user.id
).first()
if not card:
raise HTTPException(status_code=404, detail="Card not found")
if user.last_refresh_at and datetime.now() - user.last_refresh_at < timedelta(hours=2):
remaining = (user.last_refresh_at + timedelta(hours=2)) - datetime.now()
hours = int(remaining.total_seconds() // 3600)
minutes = int((remaining.total_seconds() % 3600) // 60)
raise HTTPException(
status_code=429,
detail=f"You can refresh again in {hours}h {minutes}m"
)
new_card = await _get_specific_card_async(card.name)
if not new_card:
raise HTTPException(status_code=502, detail="Failed to regenerate card from Wikipedia")
card.image_link = new_card.image_link
card.card_rarity = new_card.card_rarity.name
card.card_type = new_card.card_type.name
card.text = new_card.text
card.attack = new_card.attack
card.defense = new_card.defense
card.cost = new_card.cost
card.reported = False
user.last_refresh_at = datetime.now()
db.commit()
return {
**{c.name: getattr(card, c.name) for c in card.__table__.columns},
"card_rarity": card.card_rarity,
"card_type": card.card_type,
}
@app.get("/profile/refresh-status")
def refresh_status(user: UserModel = Depends(get_current_user)):
if not user.last_refresh_at:
return {"can_refresh": True, "next_refresh_at": None}
next_refresh = user.last_refresh_at + timedelta(hours=2)
can_refresh = datetime.now() >= next_refresh
return {
"can_refresh": can_refresh,
"next_refresh_at": next_refresh.isoformat() if not can_refresh else None,
}
@app.post("/game/{game_id}/claim-timeout-win")
async def claim_timeout_win(game_id: str, user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
err = await handle_timeout_claim(game_id, str(user.id), db)
if err:
raise HTTPException(status_code=400, detail=err)
return {"message": "Win claimed"}
@app.post("/game/solo")
async def start_solo_game(deck_id: str, difficulty: int = 5, user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
if difficulty < 1 or difficulty > 10:
raise HTTPException(status_code=400, detail="Difficulty must be between 1 and 10")
deck = db.query(DeckModel).filter(
DeckModel.id == uuid.UUID(deck_id),
DeckModel.user_id == user.id
).first()
if not deck:
raise HTTPException(status_code=404, detail="Deck not found")
card_ids = [dc.card_id for dc in db.query(DeckCardModel).filter(DeckCardModel.deck_id == deck.id).all()]
total_cost = db.query(func.sum(CardModel.cost)).filter(CardModel.id.in_(card_ids)).scalar() or 0
if total_cost == 0 or total_cost > 50:
raise HTTPException(status_code=400, detail="Deck total cost must be between 1 and 50")
player_cards = load_deck_cards(deck_id, str(user.id), db)
if player_cards is None:
raise HTTPException(status_code=503, detail="Couldn't load deck")
ai_cards = db.query(CardModel).filter(
CardModel.user_id == None,
).order_by(func.random()).limit(500).all()
if len(ai_cards) == 0:
raise HTTPException(status_code=503, detail="Not enough cards in pool for AI deck")
for card in ai_cards:
card.ai_used = True
db.commit()
game_id = create_solo_game(str(user.id), user.username, player_cards, ai_cards, deck_id, difficulty)
asyncio.create_task(fill_card_pool())
return {"game_id": game_id}
class ResetPasswordRequest(BaseModel):
current_password: str
new_password: str
@app.post("/auth/reset-password")
def reset_password(req: ResetPasswordRequest, user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
if not verify_password(req.current_password, user.password_hash):
raise HTTPException(status_code=400, detail="Current password is incorrect")
if len(req.new_password) < 8:
raise HTTPException(status_code=400, detail="Password must be at least 8 characters")
if len(req.new_password) > 256:
raise HTTPException(status_code=400, detail="Password must be 256 characters or fewer")
if req.current_password == req.new_password:
raise HTTPException(status_code=400, detail="New password must be different from current password")
user.password_hash = hash_password(req.new_password)
db.commit()
return {"message": "Password updated"}
@app.post("/auth/forgot-password")
def forgot_password(req: ForgotPasswordRequest, db: Session = Depends(get_db)):
user = db.query(UserModel).filter(UserModel.email == req.email).first()
# Always return success even if email not found. Prevents user enumeration
if user:
token = secrets.token_urlsafe(32)
user.reset_token = token
user.reset_token_expires_at = datetime.now() + timedelta(hours=1)
db.commit()
try:
send_password_reset_email(user.email, user.username, token)
except Exception as e:
logger.error(f"Failed to send reset email: {e}")
return {"message": "If that email is registered you will receive a reset link shortly"}
@app.post("/auth/reset-password-with-token")
def reset_password_with_token(req: ResetPasswordWithTokenRequest, db: Session = Depends(get_db)):
user = db.query(UserModel).filter(UserModel.reset_token == req.token).first()
if not user or not user.reset_token_expires_at or user.reset_token_expires_at < datetime.now():
raise HTTPException(status_code=400, detail="Invalid or expired reset link")
if len(req.new_password) < 8:
raise HTTPException(status_code=400, detail="Password must be at least 8 characters")
if len(req.new_password) > 256:
raise HTTPException(status_code=400, detail="Password must be 256 characters or fewer")
user.password_hash = hash_password(req.new_password)
user.reset_token = None
user.reset_token_expires_at = None
db.commit()
return {"message": "Password updated"}
@app.get("/auth/verify-email")
def verify_email(token: str, db: Session = Depends(get_db)):
user = db.query(UserModel).filter(UserModel.email_verification_token == token).first()
if not user or not user.email_verification_token_expires_at or user.email_verification_token_expires_at < datetime.now():
raise HTTPException(status_code=400, detail="Invalid or expired verification link")
user.email_verified = True
user.email_verification_token = None
user.email_verification_token_expires_at = None
db.commit()
return {"message": "Email verified"}
class ResendVerificationRequest(BaseModel):
email: str
@app.post("/auth/resend-verification")
def resend_verification(req: ResendVerificationRequest, db: Session = Depends(get_db)):
user = db.query(UserModel).filter(UserModel.email == req.email).first()
# Always return success to prevent user enumeration
if user and not user.email_verified:
token = secrets.token_urlsafe(32)
user.email_verification_token = token
user.email_verification_token_expires_at = datetime.now() + timedelta(hours=24)
db.commit()
try:
send_verification_email(user.email, user.username, token)
except Exception as e:
logger.error(f"Failed to resend verification email: {e}")
return {"message": "If that email is registered and unverified, you will receive a new verification link shortly"}
class RefreshRequest(BaseModel):
refresh_token: str
@app.post("/auth/refresh")
def refresh(req: RefreshRequest, db: Session = Depends(get_db)):
user_id = decode_refresh_token(req.refresh_token)
if not user_id:
raise HTTPException(status_code=401, detail="Invalid or expired refresh token")
user = db.query(UserModel).filter(UserModel.id == uuid.UUID(user_id)).first()
if not user:
raise HTTPException(status_code=401, detail="User not found")
return {
"access_token": create_access_token(str(user.id)),
"refresh_token": create_refresh_token(str(user.id)),
"token_type": "bearer",
}
if __name__ == "__main__":
from ai import AIPersonality, choose_cards
from card import generate_cards, Card
from time import sleep
all_cards = generate_cards(500)
all_cards.sort(key=lambda x: x.cost, reverse=True)
print(len(all_cards))
def write_cards(cards: list[Card], file: str):
with open(file, "w") as fp:
fp.write('\n'.join([
f"{c.name} - {c.attack}/{c.defense} - {c.cost}"
for c in cards
]))
write_cards(all_cards, "output/all.txt")
for personality in AIPersonality:
print(personality.value)
for difficulty in range(1,11):
chosen_cards = choose_cards(all_cards, difficulty, personality)
chosen_cards.sort(key=lambda x: x.cost, reverse=True)
write_cards(chosen_cards, f"output/{personality.value}-{difficulty}.txt")
app.include_router(health.router)
app.include_router(auth.router)
app.include_router(cards.router)
app.include_router(decks.router)
app.include_router(games.router)
app.include_router(notifications.router)
app.include_router(profile.router)
app.include_router(friends.router)
app.include_router(store.router)
app.include_router(trades.router)
-77
View File
@@ -1,77 +0,0 @@
import uuid
from datetime import datetime
from sqlalchemy import String, Integer, ForeignKey, DateTime, Text, Boolean
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID
from database import Base
class User(Base):
__tablename__ = "users"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
username: Mapped[str] = mapped_column(String, unique=True, nullable=False)
email: Mapped[str] = mapped_column(String, unique=True, nullable=False)
password_hash: Mapped[str] = mapped_column(String, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now)
boosters: Mapped[int] = mapped_column(Integer, default=5, nullable=False)
boosters_countdown: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
wins: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
losses: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
shards: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
last_refresh_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
reset_token: Mapped[str | None] = mapped_column(String, nullable=True)
reset_token_expires_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
email_verified: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
email_verification_token: Mapped[str | None] = mapped_column(String, nullable=True)
email_verification_token_expires_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
cards: Mapped[list["Card"]] = relationship(back_populates="user")
decks: Mapped[list["Deck"]] = relationship(back_populates="user")
class Card(Base):
__tablename__ = "cards"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
name: Mapped[str] = mapped_column(String, nullable=False)
image_link: Mapped[str] = mapped_column(String, nullable=True)
card_rarity: Mapped[str] = mapped_column(String, nullable=False)
card_type: Mapped[str] = mapped_column(String, nullable=False)
text: Mapped[str] = mapped_column(Text, nullable=True)
attack: Mapped[int] = mapped_column(Integer, nullable=False)
defense: Mapped[int] = mapped_column(Integer, nullable=False)
cost: Mapped[int] = mapped_column(Integer, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now)
times_played: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
reported: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
ai_used: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
user: Mapped["User | None"] = relationship(back_populates="cards")
deck_cards: Mapped[list["DeckCard"]] = relationship(back_populates="card")
class Deck(Base):
__tablename__ = "decks"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
name: Mapped[str] = mapped_column(String, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now)
times_played: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
wins: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
losses: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
deleted: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
user: Mapped["User"] = relationship(back_populates="decks")
deck_cards: Mapped[list["DeckCard"]] = relationship(back_populates="deck")
class DeckCard(Base):
__tablename__ = "deck_cards"
deck_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("decks.id"), primary_key=True)
card_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("cards.id"), primary_key=True)
deck: Mapped["Deck"] = relationship(back_populates="deck_cards")
card: Mapped["Card"] = relationship(back_populates="deck_cards")
File diff suppressed because one or more lines are too long
View File
+224
View File
@@ -0,0 +1,224 @@
import logging
import re
import secrets
import uuid
from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.security import OAuth2PasswordRequestForm
from pydantic import BaseModel
from sqlalchemy.orm import Session
from core.auth import (
create_access_token, create_refresh_token,
decode_refresh_token, hash_password, verify_password,
)
from core.database import get_db
from core.dependencies import get_current_user, limiter
from services.email_utils import send_password_reset_email, send_verification_email
from core.models import User as UserModel
logger = logging.getLogger("app")
router = APIRouter()
try:
from disposable_email_domains import blocklist as _disposable_blocklist
except ImportError:
_disposable_blocklist: set[str] = set()
class RegisterRequest(BaseModel):
username: str
email: str
password: str
class ForgotPasswordRequest(BaseModel):
email: str
class ResetPasswordWithTokenRequest(BaseModel):
token: str
new_password: str
class ResetPasswordRequest(BaseModel):
current_password: str
new_password: str
class ResendVerificationRequest(BaseModel):
email: str
class RefreshRequest(BaseModel):
refresh_token: str
def validate_register(username: str, email: str, password: str) -> str | None:
if not username.strip():
return "Username is required"
if len(username) < 2:
return "Username must be at least 2 characters"
if len(username) > 16:
return "Username must be 16 characters or fewer"
if not re.match(r"^[^\s@]+@[^\s@]+\.[^\s@]+$", email):
return "Please enter a valid email"
domain = email.split("@")[-1].lower()
if domain in _disposable_blocklist:
return "Disposable email addresses are not allowed"
if len(password) < 8:
return "Password must be at least 8 characters"
if len(password) > 256:
return "Password must be 256 characters or fewer"
return None
@router.post("/register")
@limiter.limit("5/minute")
def register(request: Request, req: RegisterRequest, db: Session = Depends(get_db)):
err = validate_register(req.username, req.email, req.password)
if err:
raise HTTPException(status_code=400, detail=err)
if db.query(UserModel).filter(UserModel.username.ilike(req.username)).first():
raise HTTPException(status_code=400, detail="Username already taken")
if db.query(UserModel).filter(UserModel.email == req.email).first():
raise HTTPException(status_code=400, detail="Email already registered")
verification_token = secrets.token_urlsafe(32)
user = UserModel(
id=uuid.uuid4(),
username=req.username,
email=req.email,
password_hash=hash_password(req.password),
email_verified=False,
email_verification_token=verification_token,
email_verification_token_expires_at=datetime.now() + timedelta(hours=24),
)
db.add(user)
db.commit()
try:
send_verification_email(req.email, req.username, verification_token)
except Exception as e:
logger.error(f"Failed to send verification email: {e}")
raise HTTPException(
status_code=500,
detail="Account created but we couldn't send the verification email. Please use 'Resend verification' to try again."
)
return {"message": "Account created. Please check your email to verify your account."}
@router.post("/login")
@limiter.limit("10/minute")
def login(request: Request, form: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)):
user = db.query(UserModel).filter(UserModel.username.ilike(form.username)).first()
if not user or not verify_password(form.password, user.password_hash):
raise HTTPException(status_code=400, detail="Invalid username or password")
user.last_active_at = datetime.now()
db.commit()
return {
"access_token": create_access_token(str(user.id)),
"refresh_token": create_refresh_token(str(user.id)),
"token_type": "bearer",
}
@router.post("/auth/reset-password")
@limiter.limit("5/minute")
def reset_password(request: Request, req: ResetPasswordRequest, user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
if not verify_password(req.current_password, user.password_hash):
raise HTTPException(status_code=400, detail="Current password is incorrect")
if len(req.new_password) < 8:
raise HTTPException(status_code=400, detail="Password must be at least 8 characters")
if len(req.new_password) > 256:
raise HTTPException(status_code=400, detail="Password must be 256 characters or fewer")
if req.current_password == req.new_password:
raise HTTPException(status_code=400, detail="New password must be different from current password")
user.password_hash = hash_password(req.new_password)
db.commit()
return {"message": "Password updated"}
@router.post("/auth/forgot-password")
@limiter.limit("5/minute")
def forgot_password(request: Request, req: ForgotPasswordRequest, db: Session = Depends(get_db)):
user = db.query(UserModel).filter(UserModel.email == req.email).first()
# Always return success even if email not found. Prevents user enumeration
if user:
token = secrets.token_urlsafe(32)
user.reset_token = token
user.reset_token_expires_at = datetime.now() + timedelta(hours=1)
db.commit()
try:
send_password_reset_email(user.email, user.username, token)
except Exception as e:
logger.error(f"Failed to send reset email: {e}")
raise HTTPException(
status_code=500,
detail="Failed to send the password reset email. Please try again later."
)
return {"message": "If that email is registered you will receive a reset link shortly"}
@router.post("/auth/reset-password-with-token")
@limiter.limit("5/minute")
def reset_password_with_token(request: Request, req: ResetPasswordWithTokenRequest, db: Session = Depends(get_db)):
user = db.query(UserModel).filter(UserModel.reset_token == req.token).first()
if not user or not user.reset_token_expires_at or user.reset_token_expires_at < datetime.now():
raise HTTPException(status_code=400, detail="Invalid or expired reset link")
if len(req.new_password) < 8:
raise HTTPException(status_code=400, detail="Password must be at least 8 characters")
if len(req.new_password) > 256:
raise HTTPException(status_code=400, detail="Password must be 256 characters or fewer")
user.password_hash = hash_password(req.new_password)
user.reset_token = None
user.reset_token_expires_at = None
db.commit()
return {"message": "Password updated"}
@router.get("/auth/verify-email")
@limiter.limit("10/minute")
def verify_email(request: Request, token: str, db: Session = Depends(get_db)):
user = db.query(UserModel).filter(UserModel.email_verification_token == token).first()
if not user or not user.email_verification_token_expires_at or user.email_verification_token_expires_at < datetime.now():
raise HTTPException(status_code=400, detail="Invalid or expired verification link")
user.email_verified = True
user.email_verification_token = None
user.email_verification_token_expires_at = None
db.commit()
return {"message": "Email verified"}
@router.post("/auth/resend-verification")
@limiter.limit("5/minute")
def resend_verification(request: Request, req: ResendVerificationRequest, db: Session = Depends(get_db)):
user = db.query(UserModel).filter(UserModel.email == req.email).first()
# Always return success to prevent user enumeration
if user and not user.email_verified:
token = secrets.token_urlsafe(32)
user.email_verification_token = token
user.email_verification_token_expires_at = datetime.now() + timedelta(hours=24)
db.commit()
try:
send_verification_email(user.email, user.username, token)
except Exception as e:
logger.error(f"Failed to resend verification email: {e}")
raise HTTPException(
status_code=500,
detail="Failed to send the verification email. Please try again later."
)
return {"message": "If that email is registered and unverified, you will receive a new verification link shortly"}
@router.post("/auth/refresh")
@limiter.limit("20/minute")
def refresh(request: Request, req: RefreshRequest, db: Session = Depends(get_db)):
user_id = decode_refresh_token(req.refresh_token)
if not user_id:
raise HTTPException(status_code=401, detail="Invalid or expired refresh token")
user = db.query(UserModel).filter(UserModel.id == uuid.UUID(user_id)).first()
if not user:
raise HTTPException(status_code=401, detail="User not found")
user.last_active_at = datetime.now()
db.commit()
return {
"access_token": create_access_token(str(user.id)),
"refresh_token": create_refresh_token(str(user.id)),
"token_type": "bearer",
}
+229
View File
@@ -0,0 +1,229 @@
import asyncio
import uuid
from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from sqlalchemy import asc, case, desc, func
from sqlalchemy.orm import Session
from game.card import _get_specific_card_async
from core.database import get_db
from services.database_functions import check_boosters, fill_card_pool, BOOSTER_MAX
from core.dependencies import get_current_user, limiter
from core.models import Card as CardModel
from core.models import Deck as DeckModel
from core.models import DeckCard as DeckCardModel
from core.models import User as UserModel
router = APIRouter()
@router.get("/boosters")
def get_boosters(user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
count, countdown = check_boosters(user, db)
return {"count": count, "countdown": countdown, "email_verified": user.email_verified}
@router.get("/cards")
def get_cards(
skip: int = 0,
limit: int = 40,
search: str = "",
rarities: list[str] = Query(default=[]),
types: list[str] = Query(default=[]),
cost_min: int = 1,
cost_max: int = 10,
favorites_only: bool = False,
wtt_only: bool = False,
sort_by: str = "name",
sort_dir: str = "asc",
user: UserModel = Depends(get_current_user),
db: Session = Depends(get_db),
):
q = db.query(CardModel).filter(CardModel.user_id == user.id)
if search:
q = q.filter(CardModel.name.ilike(f"%{search}%"))
if rarities:
q = q.filter(CardModel.card_rarity.in_(rarities))
if types:
q = q.filter(CardModel.card_type.in_(types))
q = q.filter(CardModel.cost >= cost_min, CardModel.cost <= cost_max)
if favorites_only:
q = q.filter(CardModel.is_favorite == True)
if wtt_only:
q = q.filter(CardModel.willing_to_trade == True)
total = q.count()
# case() for rarity ordering matches frontend RARITY_ORDER constant
rarity_order_expr = case(
(CardModel.card_rarity == 'common', 0),
(CardModel.card_rarity == 'uncommon', 1),
(CardModel.card_rarity == 'rare', 2),
(CardModel.card_rarity == 'super_rare', 3),
(CardModel.card_rarity == 'epic', 4),
(CardModel.card_rarity == 'legendary', 5),
else_=0
)
# coalesce mirrors frontend: received_at ?? generated_at
date_received_expr = func.coalesce(CardModel.received_at, CardModel.generated_at)
sort_map = {
"name": CardModel.name,
"cost": CardModel.cost,
"attack": CardModel.attack,
"defense": CardModel.defense,
"rarity": rarity_order_expr,
"date_generated": CardModel.generated_at,
"date_received": date_received_expr,
}
sort_col = sort_map.get(sort_by, CardModel.name)
order_fn = desc if sort_dir == "desc" else asc
# Secondary sort by name keeps pages stable when primary values are tied
q = q.order_by(order_fn(sort_col), asc(CardModel.name))
cards = q.offset(skip).limit(limit).all()
return {
"cards": [
{c.name: getattr(card, c.name) for c in card.__table__.columns}
for card in cards
],
"total": total,
}
@router.get("/cards/in-decks")
def get_cards_in_decks(user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
deck_ids = [d.id for d in db.query(DeckModel).filter(DeckModel.user_id == user.id, DeckModel.deleted == False).all()]
if not deck_ids:
return []
card_ids = db.query(DeckCardModel.card_id).filter(DeckCardModel.deck_id.in_(deck_ids)).distinct().all()
return [str(row.card_id) for row in card_ids]
@router.post("/open_pack")
@limiter.limit("10/minute")
async def open_pack(request: Request, user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
if not user.email_verified:
raise HTTPException(status_code=403, detail="You must verify your email before opening packs")
check_boosters(user, db)
if user.boosters == 0:
raise HTTPException(status_code=400, detail="No booster packs available")
cards = (
db.query(CardModel)
.filter(CardModel.user_id == None, CardModel.ai_used == False)
.limit(5)
.all()
)
if len(cards) < 5:
asyncio.create_task(fill_card_pool())
raise HTTPException(status_code=503, detail="Card pool is low, please try again shortly")
now = datetime.now()
for card in cards:
card.user_id = user.id
card.received_at = now
was_full = user.boosters == BOOSTER_MAX
user.boosters -= 1
if was_full:
user.boosters_countdown = datetime.now()
db.commit()
asyncio.create_task(fill_card_pool())
return [
{**{c.name: getattr(card, c.name) for c in card.__table__.columns},
"card_rarity": card.card_rarity,
"card_type": card.card_type}
for card in cards
]
@router.post("/cards/{card_id}/report")
def report_card(card_id: str, user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
card = db.query(CardModel).filter(
CardModel.id == uuid.UUID(card_id),
CardModel.user_id == user.id
).first()
if not card:
raise HTTPException(status_code=404, detail="Card not found")
card.reported = True
db.commit()
return {"message": "Card reported"}
@router.post("/cards/{card_id}/refresh")
@limiter.limit("5/hour")
async def refresh_card(request: Request, card_id: str, user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
card = db.query(CardModel).filter(
CardModel.id == uuid.UUID(card_id),
CardModel.user_id == user.id
).first()
if not card:
raise HTTPException(status_code=404, detail="Card not found")
if user.last_refresh_at and datetime.now() - user.last_refresh_at < timedelta(minutes=10):
remaining = (user.last_refresh_at + timedelta(minutes=10)) - datetime.now()
minutes = int(remaining.total_seconds() // 60)
seconds = int(remaining.total_seconds() % 60)
raise HTTPException(
status_code=429,
detail=f"You can refresh again in {minutes}m {seconds}s"
)
new_card = await _get_specific_card_async(card.name)
if not new_card:
raise HTTPException(status_code=502, detail="Failed to regenerate card from Wikipedia")
card.image_link = new_card.image_link
card.card_rarity = new_card.card_rarity.name
card.card_type = new_card.card_type.name
card.text = new_card.text
card.attack = new_card.attack
card.defense = new_card.defense
card.cost = new_card.cost
card.reported = False
card.generated_at = datetime.now()
card.received_at = datetime.now()
user.last_refresh_at = datetime.now()
db.commit()
return {
**{c.name: getattr(card, c.name) for c in card.__table__.columns},
"card_rarity": card.card_rarity,
"card_type": card.card_type,
}
@router.post("/cards/{card_id}/favorite")
def toggle_favorite(card_id: str, user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
card = db.query(CardModel).filter(
CardModel.id == uuid.UUID(card_id),
CardModel.user_id == user.id
).first()
if not card:
raise HTTPException(status_code=404, detail="Card not found")
card.is_favorite = not card.is_favorite
db.commit()
return {"is_favorite": card.is_favorite}
@router.post("/cards/{card_id}/willing-to-trade")
def toggle_willing_to_trade(card_id: str, user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
card = db.query(CardModel).filter(
CardModel.id == uuid.UUID(card_id),
CardModel.user_id == user.id
).first()
if not card:
raise HTTPException(status_code=404, detail="Card not found")
card.willing_to_trade = not card.willing_to_trade
db.commit()
return {"willing_to_trade": card.willing_to_trade}
+97
View File
@@ -0,0 +1,97 @@
import uuid
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session, selectinload
from game.card import compute_deck_type
from core.database import get_db
from core.dependencies import get_current_user
from core.models import Card as CardModel
from core.models import Deck as DeckModel
from core.models import DeckCard as DeckCardModel
from core.models import User as UserModel
router = APIRouter()
class DeckUpdate(BaseModel):
name: Optional[str] = Field(None, max_length=64)
card_ids: Optional[List[str]] = None
@router.get("/decks")
def get_decks(user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
decks = db.query(DeckModel).options(
selectinload(DeckModel.deck_cards).selectinload(DeckCardModel.card)
).filter(
DeckModel.user_id == user.id,
DeckModel.deleted == False
).order_by(DeckModel.created_at).all()
result = []
for deck in decks:
cards = [dc.card for dc in deck.deck_cards]
result.append({
"id": str(deck.id),
"name": deck.name,
"card_count": len(cards),
"total_cost": sum(card.cost for card in cards),
"times_played": deck.times_played,
"wins": deck.wins,
"losses": deck.losses,
"deck_type": compute_deck_type(cards),
})
return result
@router.post("/decks")
def create_deck(user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
count = db.query(DeckModel).filter(DeckModel.user_id == user.id).count()
deck = DeckModel(id=uuid.uuid4(), user_id=user.id, name=f"Deck #{count + 1}")
db.add(deck)
db.commit()
return {"id": str(deck.id), "name": deck.name, "card_count": 0}
@router.patch("/decks/{deck_id}")
def update_deck(deck_id: str, body: DeckUpdate, user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
deck = db.query(DeckModel).filter(DeckModel.id == uuid.UUID(deck_id), DeckModel.user_id == user.id).first()
if not deck:
raise HTTPException(status_code=404, detail="Deck not found")
if body.name is not None:
deck.name = body.name
if body.card_ids is not None:
db.query(DeckCardModel).filter(DeckCardModel.deck_id == deck.id).delete()
for card_id in body.card_ids:
db.add(DeckCardModel(deck_id=deck.id, card_id=uuid.UUID(card_id)))
if deck.times_played > 0:
deck.wins = 0
deck.losses = 0
deck.times_played = 0
db.commit()
return {"id": str(deck.id), "name": deck.name}
@router.delete("/decks/{deck_id}")
def delete_deck(deck_id: str, user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
deck = db.query(DeckModel).filter(DeckModel.id == uuid.UUID(deck_id), DeckModel.user_id == user.id).first()
if not deck:
raise HTTPException(status_code=404, detail="Deck not found")
if deck.times_played > 0:
deck.deleted = True
else:
db.delete(deck)
db.commit()
return {"message": "Deleted"}
@router.get("/decks/{deck_id}/cards")
def get_deck_cards(deck_id: str, user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
deck = db.query(DeckModel).filter(DeckModel.id == uuid.UUID(deck_id), DeckModel.user_id == user.id).first()
if not deck:
raise HTTPException(status_code=404, detail="Deck not found")
deck_cards = db.query(DeckCardModel).options(
selectinload(DeckCardModel.card)
).filter(DeckCardModel.deck_id == deck.id).all()
return [{"id": str(dc.card_id), "cost": dc.card.cost} for dc in deck_cards]
+134
View File
@@ -0,0 +1,134 @@
import uuid
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy.orm import Session, joinedload
from services import notification_manager
from core.database import get_db
from core.dependencies import get_current_user, get_user_id_from_request, limiter
from core.models import Friendship as FriendshipModel
from core.models import Notification as NotificationModel
from core.models import User as UserModel
from routers.notifications import _serialize_notification
router = APIRouter()
@router.post("/users/{username}/friend-request")
@limiter.limit("10/minute", key_func=get_user_id_from_request)
async def send_friend_request(request: Request, username: str, user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
addressee = db.query(UserModel).filter(UserModel.username == username).first()
if not addressee:
raise HTTPException(status_code=404, detail="User not found")
if addressee.id == user.id:
raise HTTPException(status_code=400, detail="Cannot send friend request to yourself")
# Check for any existing friendship in either direction
existing = db.query(FriendshipModel).filter(
((FriendshipModel.requester_id == user.id) & (FriendshipModel.addressee_id == addressee.id)) |
((FriendshipModel.requester_id == addressee.id) & (FriendshipModel.addressee_id == user.id)),
).first()
if existing and existing.status != "declined":
raise HTTPException(status_code=400, detail="Friend request already exists or already friends")
# Clear stale declined row so the unique constraint allows re-requesting
if existing:
db.delete(existing)
db.flush()
friendship = FriendshipModel(requester_id=user.id, addressee_id=addressee.id, status="pending")
db.add(friendship)
db.flush() # get friendship.id before notification
notif = NotificationModel(
user_id=addressee.id,
type="friend_request",
payload={"friendship_id": str(friendship.id), "from_username": user.username},
)
db.add(notif)
db.commit()
await notification_manager.send_notification(str(addressee.id), _serialize_notification(notif))
return {"ok": True}
@router.post("/friendships/{friendship_id}/accept")
def accept_friend_request(friendship_id: str, user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
friendship = db.query(FriendshipModel).filter(FriendshipModel.id == uuid.UUID(friendship_id)).first()
if not friendship:
raise HTTPException(status_code=404, detail="Friendship not found")
if friendship.addressee_id != user.id:
raise HTTPException(status_code=403, detail="Not authorized")
if friendship.status != "pending":
raise HTTPException(status_code=400, detail="Friendship is not pending")
friendship.status = "accepted"
db.commit()
return {"ok": True}
@router.post("/friendships/{friendship_id}/decline")
def decline_friend_request(friendship_id: str, user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
friendship = db.query(FriendshipModel).filter(FriendshipModel.id == uuid.UUID(friendship_id)).first()
if not friendship:
raise HTTPException(status_code=404, detail="Friendship not found")
if friendship.addressee_id != user.id:
raise HTTPException(status_code=403, detail="Not authorized")
if friendship.status != "pending":
raise HTTPException(status_code=400, detail="Friendship is not pending")
friendship.status = "declined"
# Clean up the associated notification so it disappears from the bell
db.query(NotificationModel).filter(
NotificationModel.user_id == user.id,
NotificationModel.type == "friend_request",
NotificationModel.payload["friendship_id"].astext == friendship_id,
).delete(synchronize_session=False)
db.commit()
return {"ok": True}
@router.get("/friends")
def get_friends(user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
friendships = db.query(FriendshipModel).options(
joinedload(FriendshipModel.requester),
joinedload(FriendshipModel.addressee),
).filter(
(FriendshipModel.requester_id == user.id) | (FriendshipModel.addressee_id == user.id),
FriendshipModel.status == "accepted",
).all()
result = []
for f in friendships:
other = f.addressee if f.requester_id == user.id else f.requester
result.append({"id": str(other.id), "username": other.username, "friendship_id": str(f.id)})
return result
@router.get("/friendship-status/{username}")
def get_friendship_status(username: str, user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
"""Returns the friendship status between the current user and the given username."""
other = db.query(UserModel).filter(UserModel.username == username).first()
if not other:
raise HTTPException(status_code=404, detail="User not found")
friendship = db.query(FriendshipModel).filter(
((FriendshipModel.requester_id == user.id) & (FriendshipModel.addressee_id == other.id)) |
((FriendshipModel.requester_id == other.id) & (FriendshipModel.addressee_id == user.id)),
FriendshipModel.status != "declined",
).first()
if not friendship:
return {"status": "none"}
if friendship.status == "accepted":
return {"status": "friends", "friendship_id": str(friendship.id)}
# pending: distinguish sent vs received
if friendship.requester_id == user.id:
return {"status": "pending_sent", "friendship_id": str(friendship.id)}
return {"status": "pending_received", "friendship_id": str(friendship.id)}
@router.delete("/friendships/{friendship_id}")
def remove_friend(friendship_id: str, user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
friendship = db.query(FriendshipModel).filter(FriendshipModel.id == uuid.UUID(friendship_id)).first()
if not friendship:
raise HTTPException(status_code=404, detail="Friendship not found")
if friendship.requester_id != user.id and friendship.addressee_id != user.id:
raise HTTPException(status_code=403, detail="Not authorized")
db.delete(friendship)
db.commit()
return {"ok": True}
+404
View File
@@ -0,0 +1,404 @@
import asyncio
import uuid
from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException, Request, WebSocket, WebSocketDisconnect
from pydantic import BaseModel
from sqlalchemy import func
from sqlalchemy.orm import Session, joinedload
from services import notification_manager
from core.auth import decode_access_token
from core.database import get_db
from services.database_functions import fill_card_pool
from core.dependencies import get_current_user, get_user_id_from_request, limiter
from game.manager import (
QueueEntry, active_games, connections, create_challenge_game, create_solo_game,
handle_action, handle_disconnect, handle_timeout_claim, load_deck_cards,
queue, queue_lock, serialize_state, try_match,
)
from core.models import Card as CardModel
from core.models import Deck as DeckModel
from core.models import DeckCard as DeckCardModel
from core.models import GameChallenge as GameChallengeModel
from core.models import Notification as NotificationModel
from core.models import User as UserModel
from routers.notifications import _serialize_notification
router = APIRouter()
def _serialize_challenge(c: GameChallengeModel, current_user_id: uuid.UUID) -> dict:
deck = c.challenger_deck
return {
"id": str(c.id),
"status": c.status,
"direction": "outgoing" if c.challenger_id == current_user_id else "incoming",
"challenger_username": c.challenger.username,
"challenged_username": c.challenged.username,
"deck_name": deck.name if deck else "Unknown Deck",
"deck_id": str(c.challenger_deck_id),
"created_at": c.created_at.isoformat(),
"expires_at": c.expires_at.isoformat(),
}
# ── WebSocket game matchmaking ────────────────────────────────────────────────
@router.websocket("/ws/queue")
async def queue_endpoint(websocket: WebSocket, deck_id: str, db: Session = Depends(get_db)):
await websocket.accept()
token = await websocket.receive_text()
user_id = decode_access_token(token)
if not user_id:
await websocket.close(code=1008)
return
deck = db.query(DeckModel).filter(
DeckModel.id == uuid.UUID(deck_id),
DeckModel.user_id == uuid.UUID(user_id)
).first()
if not deck:
await websocket.send_json({"type": "error", "message": "Deck not found"})
await websocket.close(code=1008)
return
card_ids = [dc.card_id for dc in db.query(DeckCardModel).filter(DeckCardModel.deck_id == deck.id).all()]
total_cost = db.query(func.sum(CardModel.cost)).filter(CardModel.id.in_(card_ids)).scalar() or 0
if total_cost == 0 or total_cost > 50:
await websocket.send_json({"type": "error", "message": "Deck total cost must be between 1 and 50"})
await websocket.close(code=1008)
return
entry = QueueEntry(user_id=user_id, deck_id=deck_id, websocket=websocket)
async with queue_lock:
queue.append(entry)
await websocket.send_json({"type": "queued"})
await try_match(db)
try:
while True:
# Keeping socket alive
await websocket.receive_text()
except WebSocketDisconnect:
async with queue_lock:
queue[:] = [e for e in queue if e.user_id != user_id]
@router.websocket("/ws/game/{game_id}")
async def game_endpoint(websocket: WebSocket, game_id: str, db: Session = Depends(get_db)):
await websocket.accept()
token = await websocket.receive_text()
user_id = decode_access_token(token)
if not user_id:
await websocket.close(code=1008)
return
if game_id not in active_games:
await websocket.close(code=1008)
return
if user_id not in active_games[game_id].players:
await websocket.close(code=1008)
return
# Register this connection (handles reconnects)
connections[game_id][user_id] = websocket
# Send current state immediately on connect
await websocket.send_json({
"type": "state",
"state": serialize_state(active_games[game_id], user_id),
})
try:
while True:
data = await websocket.receive_json()
await handle_action(game_id, user_id, data, db)
except WebSocketDisconnect:
if game_id in connections:
connections[game_id].pop(user_id, None)
asyncio.create_task(handle_disconnect(game_id, user_id))
# ── Game challenges ───────────────────────────────────────────────────────────
class CreateGameChallengeRequest(BaseModel):
deck_id: str
class AcceptGameChallengeRequest(BaseModel):
deck_id: str
@router.post("/users/{username}/challenge")
@limiter.limit("10/minute", key_func=get_user_id_from_request)
async def create_game_challenge(
request: Request,
username: str,
req: CreateGameChallengeRequest,
user: UserModel = Depends(get_current_user),
db: Session = Depends(get_db),
):
target = db.query(UserModel).filter(UserModel.username == username).first()
if not target:
raise HTTPException(status_code=404, detail="User not found")
if target.id == user.id:
raise HTTPException(status_code=400, detail="Cannot challenge yourself")
try:
deck_id = uuid.UUID(req.deck_id)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid deck_id")
deck = db.query(DeckModel).filter(DeckModel.id == deck_id, DeckModel.user_id == user.id, DeckModel.deleted == False).first()
if not deck:
raise HTTPException(status_code=404, detail="Deck not found")
existing = db.query(GameChallengeModel).filter(
GameChallengeModel.status == "pending",
(
((GameChallengeModel.challenger_id == user.id) & (GameChallengeModel.challenged_id == target.id)) |
((GameChallengeModel.challenger_id == target.id) & (GameChallengeModel.challenged_id == user.id))
)
).first()
if existing:
raise HTTPException(status_code=400, detail="A pending challenge already exists between you two")
now = datetime.now()
challenge = GameChallengeModel(
challenger_id=user.id,
challenged_id=target.id,
challenger_deck_id=deck_id,
expires_at=now + timedelta(minutes=5),
)
db.add(challenge)
db.flush()
notif = NotificationModel(
user_id=target.id,
type="game_challenge",
expires_at=challenge.expires_at,
payload={
"challenge_id": str(challenge.id),
"from_username": user.username,
"deck_name": deck.name,
},
)
db.add(notif)
db.commit()
await notification_manager.send_notification(str(target.id), _serialize_notification(notif))
return {"challenge_id": str(challenge.id)}
@router.post("/challenges/{challenge_id}/accept")
async def accept_game_challenge(
challenge_id: str,
req: AcceptGameChallengeRequest,
user: UserModel = Depends(get_current_user),
db: Session = Depends(get_db),
):
try:
cid = uuid.UUID(challenge_id)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid challenge_id")
challenge = db.query(GameChallengeModel).filter(GameChallengeModel.id == cid).with_for_update().first()
if not challenge:
raise HTTPException(status_code=404, detail="Challenge not found")
if challenge.challenged_id != user.id:
raise HTTPException(status_code=403, detail="Not authorized")
now = datetime.now()
if challenge.status == "pending" and now > challenge.expires_at:
challenge.status = "expired"
db.commit()
raise HTTPException(status_code=400, detail="Challenge has expired")
if challenge.status != "pending":
raise HTTPException(status_code=400, detail=f"Challenge is already {challenge.status}")
try:
deck_id = uuid.UUID(req.deck_id)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid deck_id")
deck = db.query(DeckModel).filter(DeckModel.id == deck_id, DeckModel.user_id == user.id, DeckModel.deleted == False).first()
if not deck:
raise HTTPException(status_code=404, detail="Deck not found")
# Verify challenger's deck still exists — it could have been deleted since the challenge was sent
challenger_deck = db.query(DeckModel).filter(
DeckModel.id == challenge.challenger_deck_id,
DeckModel.deleted == False,
).first()
if not challenger_deck:
raise HTTPException(status_code=400, detail="The challenger's deck no longer exists")
try:
game_id = create_challenge_game(
str(challenge.challenger_id), str(challenge.challenger_deck_id),
str(challenge.challenged_id), str(deck_id),
db,
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
challenge.status = "accepted"
# Delete the original challenge notification from the challenged player's bell
old_notif = db.query(NotificationModel).filter(
NotificationModel.user_id == user.id,
NotificationModel.type == "game_challenge",
NotificationModel.payload["challenge_id"].astext == str(challenge.id),
).first()
deleted_notif_id = str(old_notif.id) if old_notif else None
if old_notif:
db.delete(old_notif)
# Notify the challenger that their challenge was accepted
response_notif = NotificationModel(
user_id=challenge.challenger_id,
type="game_challenge",
payload={
"challenge_id": str(challenge.id),
"status": "accepted",
"game_id": game_id,
"from_username": user.username,
},
)
db.add(response_notif)
db.commit()
if deleted_notif_id:
await notification_manager.send_delete(str(user.id), deleted_notif_id)
await notification_manager.send_notification(str(challenge.challenger_id), _serialize_notification(response_notif))
return {"game_id": game_id}
@router.post("/challenges/{challenge_id}/decline")
async def decline_game_challenge(
challenge_id: str,
user: UserModel = Depends(get_current_user),
db: Session = Depends(get_db),
):
try:
cid = uuid.UUID(challenge_id)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid challenge_id")
challenge = db.query(GameChallengeModel).filter(GameChallengeModel.id == cid).first()
if not challenge:
raise HTTPException(status_code=404, detail="Challenge not found")
if challenge.challenger_id != user.id and challenge.challenged_id != user.id:
raise HTTPException(status_code=403, detail="Not authorized")
now = datetime.now()
if challenge.status == "pending" and now > challenge.expires_at:
challenge.status = "expired"
db.commit()
raise HTTPException(status_code=400, detail="Challenge has already expired")
if challenge.status != "pending":
raise HTTPException(status_code=400, detail=f"Challenge is already {challenge.status}")
is_withdrawal = challenge.challenger_id == user.id
challenge.status = "withdrawn" if is_withdrawal else "declined"
# Remove the notification from the other party's bell
if is_withdrawal:
# Challenger withdrawing: remove challenge notif from challenged player's bell
notif = db.query(NotificationModel).filter(
NotificationModel.user_id == challenge.challenged_id,
NotificationModel.type == "game_challenge",
NotificationModel.payload["challenge_id"].astext == str(challenge.id),
).first()
recipient_id = str(challenge.challenged_id)
else:
# Challenged player declining: remove challenge notif from their own bell
notif = db.query(NotificationModel).filter(
NotificationModel.user_id == user.id,
NotificationModel.type == "game_challenge",
NotificationModel.payload["challenge_id"].astext == str(challenge.id),
).first()
recipient_id = str(user.id)
deleted_notif_id = str(notif.id) if notif else None
if notif:
db.delete(notif)
db.commit()
if deleted_notif_id:
await notification_manager.send_delete(recipient_id, deleted_notif_id)
return {"ok": True}
@router.get("/challenges")
def get_challenges(user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
now = datetime.now()
# Lazy-expire pending challenges past deadline
db.query(GameChallengeModel).filter(
GameChallengeModel.status == "pending",
GameChallengeModel.expires_at < now,
(GameChallengeModel.challenger_id == user.id) | (GameChallengeModel.challenged_id == user.id),
).update({"status": "expired"})
db.commit()
challenges = db.query(GameChallengeModel).options(
joinedload(GameChallengeModel.challenger_deck)
).filter(
(GameChallengeModel.challenger_id == user.id) | (GameChallengeModel.challenged_id == user.id)
).order_by(GameChallengeModel.created_at.desc()).all()
return [_serialize_challenge(c, user.id) for c in challenges]
@router.post("/game/{game_id}/claim-timeout-win")
async def claim_timeout_win(game_id: str, user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
err = await handle_timeout_claim(game_id, str(user.id), db)
if err:
raise HTTPException(status_code=400, detail=err)
return {"message": "Win claimed"}
@router.post("/game/solo")
async def start_solo_game(deck_id: str, difficulty: int = 5, user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
if difficulty < 1 or difficulty > 10:
raise HTTPException(status_code=400, detail="Difficulty must be between 1 and 10")
deck = db.query(DeckModel).filter(
DeckModel.id == uuid.UUID(deck_id),
DeckModel.user_id == user.id
).first()
if not deck:
raise HTTPException(status_code=404, detail="Deck not found")
card_ids = [dc.card_id for dc in db.query(DeckCardModel).filter(DeckCardModel.deck_id == deck.id).all()]
total_cost = db.query(func.sum(CardModel.cost)).filter(CardModel.id.in_(card_ids)).scalar() or 0
if total_cost == 0 or total_cost > 50:
raise HTTPException(status_code=400, detail="Deck total cost must be between 1 and 50")
player_cards = load_deck_cards(deck_id, str(user.id), db)
if player_cards is None:
raise HTTPException(status_code=503, detail="Couldn't load deck")
ai_cards = db.query(CardModel).filter(
CardModel.user_id == None,
).order_by(func.random()).limit(500).all()
if len(ai_cards) == 0:
raise HTTPException(status_code=503, detail="Not enough cards in pool for AI deck")
for card in ai_cards:
card.ai_used = True
db.commit()
game_id = create_solo_game(str(user.id), user.username, player_cards, ai_cards, deck_id, difficulty)
asyncio.create_task(fill_card_pool())
return {"game_id": game_id}
+17
View File
@@ -0,0 +1,17 @@
from fastapi import APIRouter, Depends
from fastapi.responses import JSONResponse
from sqlalchemy.orm import Session
from sqlalchemy import text
from core.database import get_db
router = APIRouter()
@router.get("/health")
def health_check(db: Session = Depends(get_db)):
# Validates that the DB is reachable, not just that the process is up
db.execute(text("SELECT 1"))
return {"status": "ok"}
@router.get("/teapot")
def teapot():
return JSONResponse(status_code=418, content={"message": "I'm a teapot"})
+115
View File
@@ -0,0 +1,115 @@
import uuid
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect
from sqlalchemy.orm import Session
from services import notification_manager
from core.auth import decode_access_token
from core.database import get_db
from core.dependencies import get_current_user
from core.models import Notification as NotificationModel
from core.models import User as UserModel
router = APIRouter()
def _serialize_notification(n: NotificationModel) -> dict:
return {
"id": str(n.id),
"type": n.type,
"payload": n.payload,
"read": n.read,
"created_at": n.created_at.isoformat(),
"expires_at": n.expires_at.isoformat() if n.expires_at else None,
}
@router.websocket("/ws/notifications")
async def notifications_endpoint(websocket: WebSocket, db: Session = Depends(get_db)):
await websocket.accept()
token = await websocket.receive_text()
user_id = decode_access_token(token)
if not user_id:
await websocket.close(code=1008)
return
user = db.query(UserModel).filter(UserModel.id == uuid.UUID(user_id)).first()
if not user:
await websocket.close(code=1008)
return
notification_manager.register(user_id, websocket)
# Flush all unread (non-expired) notifications on connect
now = datetime.now()
pending = (
db.query(NotificationModel)
.filter(
NotificationModel.user_id == uuid.UUID(user_id),
NotificationModel.read == False,
(NotificationModel.expires_at == None) | (NotificationModel.expires_at > now),
)
.order_by(NotificationModel.created_at.asc())
.all()
)
await websocket.send_json({
"type": "flush",
"notifications": [_serialize_notification(n) for n in pending],
})
try:
while True:
await websocket.receive_text() # keep connection alive; server only pushes
except WebSocketDisconnect:
notification_manager.unregister(user_id)
@router.get("/notifications")
def get_notifications(user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
now = datetime.now()
notifications = (
db.query(NotificationModel)
.filter(
NotificationModel.user_id == user.id,
(NotificationModel.expires_at == None) | (NotificationModel.expires_at > now),
)
.order_by(NotificationModel.created_at.desc())
.all()
)
return [_serialize_notification(n) for n in notifications]
@router.post("/notifications/{notification_id}/read")
def mark_notification_read(
notification_id: str,
user: UserModel = Depends(get_current_user),
db: Session = Depends(get_db),
):
n = db.query(NotificationModel).filter(
NotificationModel.id == uuid.UUID(notification_id),
NotificationModel.user_id == user.id,
).first()
if not n:
raise HTTPException(status_code=404, detail="Notification not found")
n.read = True
db.commit()
return {"ok": True}
@router.delete("/notifications/{notification_id}")
def delete_notification(
notification_id: str,
user: UserModel = Depends(get_current_user),
db: Session = Depends(get_db),
):
n = db.query(NotificationModel).filter(
NotificationModel.id == uuid.UUID(notification_id),
NotificationModel.user_id == user.id,
).first()
if not n:
raise HTTPException(status_code=404, detail="Notification not found")
db.delete(n)
db.commit()
return {"ok": True}
+147
View File
@@ -0,0 +1,147 @@
from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy.orm import Session
from core.database import get_db
from core.dependencies import get_current_user
from core.models import Card as CardModel
from core.models import Deck as DeckModel
from core.models import User as UserModel
router = APIRouter()
def _serialize_card_public(card: CardModel) -> dict:
"""Card fields safe to expose on public profiles (no user_id)."""
return {
"id": str(card.id),
"name": card.name,
"image_link": card.image_link,
"card_rarity": card.card_rarity,
"card_type": card.card_type,
"text": card.text,
"attack": card.attack,
"defense": card.defense,
"cost": card.cost,
"is_favorite": card.is_favorite,
"willing_to_trade": card.willing_to_trade,
}
class UpdateProfileRequest(BaseModel):
trade_wishlist: str
@router.get("/profile")
def get_profile(user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
total_games = user.wins + user.losses
most_played_deck = (
db.query(DeckModel)
.filter(DeckModel.user_id == user.id, DeckModel.times_played > 0)
.order_by(DeckModel.times_played.desc())
.first()
)
most_played_card = (
db.query(CardModel)
.filter(CardModel.user_id == user.id, CardModel.times_played > 0)
.order_by(CardModel.times_played.desc())
.first()
)
return {
"username": user.username,
"email": user.email,
"email_verified": user.email_verified,
"created_at": user.created_at,
"wins": user.wins,
"losses": user.losses,
"shards": user.shards,
"win_rate": round((user.wins / total_games) * 100) if total_games > 0 else None,
"trade_wishlist": user.trade_wishlist or "",
"most_played_deck": {
"name": most_played_deck.name,
"times_played": most_played_deck.times_played,
} if most_played_deck else None,
"most_played_card": {
"name": most_played_card.name,
"times_played": most_played_card.times_played,
"card_type": most_played_card.card_type,
"card_rarity": most_played_card.card_rarity,
"image_link": most_played_card.image_link,
} if most_played_card else None,
}
@router.get("/profile/refresh-status")
def refresh_status(user: UserModel = Depends(get_current_user)):
if not user.last_refresh_at:
return {"can_refresh": True, "next_refresh_at": None}
next_refresh = user.last_refresh_at + timedelta(minutes=10)
can_refresh = datetime.now() >= next_refresh
return {
"can_refresh": can_refresh,
"next_refresh_at": next_refresh.isoformat() if not can_refresh else None,
}
@router.patch("/profile")
def update_profile(req: UpdateProfileRequest, user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
user.trade_wishlist = req.trade_wishlist
db.commit()
return {"trade_wishlist": user.trade_wishlist}
@router.get("/users")
def search_users(q: str, current_user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
# Require auth to prevent scraping
if len(q) < 2:
return []
results = (
db.query(UserModel)
.filter(UserModel.username.ilike(f"%{q}%"))
.limit(20)
.all()
)
return [
{
"username": u.username,
"wins": u.wins,
"losses": u.losses,
"win_rate": round(u.wins / (u.wins + u.losses) * 100) if (u.wins + u.losses) > 0 else 0,
}
for u in results
]
@router.get("/users/{username}")
def get_public_profile(username: str, db: Session = Depends(get_db)):
user = db.query(UserModel).filter(UserModel.username == username).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
total_games = user.wins + user.losses
favorite_cards = (
db.query(CardModel)
.filter(CardModel.user_id == user.id, CardModel.is_favorite == True)
.order_by(CardModel.received_at.desc())
.all()
)
wtt_cards = (
db.query(CardModel)
.filter(CardModel.user_id == user.id, CardModel.willing_to_trade == True)
.order_by(CardModel.received_at.desc())
.all()
)
return {
"username": user.username,
"wins": user.wins,
"losses": user.losses,
"win_rate": round((user.wins / total_games) * 100) if total_games > 0 else None,
"trade_wishlist": user.trade_wishlist or "",
"last_active_at": user.last_active_at.isoformat() if user.last_active_at else None,
"favorite_cards": [_serialize_card_public(c) for c in favorite_cards],
"wtt_cards": [_serialize_card_public(c) for c in wtt_cards],
}
+189
View File
@@ -0,0 +1,189 @@
import uuid
from datetime import datetime
import stripe
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from game.card import _get_specific_card_async
from core.config import FRONTEND_URL, STRIPE_PUBLISHABLE_KEY, STRIPE_WEBHOOK_SECRET
from core.database import get_db
from core.dependencies import get_current_user, limiter
from core.models import Card as CardModel
from core.models import ProcessedWebhookEvent
from core.models import User as UserModel
router = APIRouter()
# Shard packages sold for real money.
# price_oere is in Danish øre (1 DKK = 100 øre). Stripe minimum is 250 øre.
SHARD_PACKAGES = {
"s1": {"base": 100, "bonus": 0, "shards": 100, "price_oere": 1000, "price_label": "10 DKK"},
"s2": {"base": 250, "bonus": 50, "shards": 300, "price_oere": 2500, "price_label": "25 DKK"},
"s3": {"base": 500, "bonus": 200, "shards": 700, "price_oere": 5000, "price_label": "50 DKK"},
"s4": {"base": 1000, "bonus": 600, "shards": 1600, "price_oere": 10000, "price_label": "100 DKK"},
"s5": {"base": 2500, "bonus": 2000, "shards": 4500, "price_oere": 25000, "price_label": "250 DKK"},
"s6": {"base": 5000, "bonus": 5000, "shards": 10000, "price_oere": 50000, "price_label": "500 DKK"},
}
STORE_PACKAGES = {
1: 15,
5: 65,
10: 120,
25: 260,
}
SPECIFIC_CARD_COST = 1000
class ShatterRequest(BaseModel):
card_ids: list[str]
class StripeCheckoutRequest(BaseModel):
package_id: str
class StoreBuyRequest(BaseModel):
quantity: int
class BuySpecificCardRequest(BaseModel):
wiki_title: str
@router.post("/shards/shatter")
def shatter_cards(req: ShatterRequest, user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
if not req.card_ids:
raise HTTPException(status_code=400, detail="No cards selected")
try:
parsed_ids = [uuid.UUID(cid) for cid in req.card_ids]
except ValueError:
raise HTTPException(status_code=400, detail="Invalid card IDs")
cards = db.query(CardModel).filter(
CardModel.id.in_(parsed_ids),
CardModel.user_id == user.id,
).all()
if len(cards) != len(parsed_ids):
raise HTTPException(status_code=400, detail="Some cards are not in your collection")
total = sum(c.cost for c in cards)
for card in cards:
db.delete(card)
user.shards += total
db.commit()
return {"shards": user.shards, "gained": total}
@router.post("/store/stripe/checkout")
def create_stripe_checkout(req: StripeCheckoutRequest, user: UserModel = Depends(get_current_user)):
package = SHARD_PACKAGES.get(req.package_id)
if not package:
raise HTTPException(status_code=400, detail="Invalid package")
session = stripe.checkout.Session.create(
payment_method_types=["card"],
line_items=[{
"price_data": {
"currency": "dkk",
"product_data": {"name": f"WikiTCG Shards — {package['price_label']}"},
"unit_amount": package["price_oere"],
},
"quantity": 1,
}],
mode="payment",
success_url=f"{FRONTEND_URL}/store?payment=success",
cancel_url=f"{FRONTEND_URL}/store",
metadata={"user_id": str(user.id), "shards": str(package["shards"])},
)
return {"url": session.url}
@router.post("/stripe/webhook")
async def stripe_webhook(request: Request, db: Session = Depends(get_db)):
payload = await request.body()
sig = request.headers.get("stripe-signature", "")
try:
event = stripe.Webhook.construct_event(payload, sig, STRIPE_WEBHOOK_SECRET)
except stripe.error.SignatureVerificationError: # type: ignore
raise HTTPException(status_code=400, detail="Invalid signature")
# Guard against duplicate delivery: Stripe retries on timeout/5xx, so the same
# event can arrive more than once. The PK constraint on stripe_event_id is the
# arbiter — if the INSERT fails, we've already processed this event.
try:
db.add(ProcessedWebhookEvent(stripe_event_id=event["id"]))
db.flush()
except IntegrityError:
db.rollback()
return {"ok": True}
if event["type"] == "checkout.session.completed":
data = event["data"]["object"]
user_id = data.get("metadata", {}).get("user_id")
shards = data.get("metadata", {}).get("shards")
if user_id and shards:
user = db.query(UserModel).filter(UserModel.id == uuid.UUID(user_id)).first()
if user:
user.shards += int(shards)
db.commit()
return {"ok": True}
@router.get("/store/config")
def store_config():
return {
"publishable_key": STRIPE_PUBLISHABLE_KEY,
"shard_packages": SHARD_PACKAGES,
}
@router.post("/store/buy-specific-card")
@limiter.limit("10/hour")
async def buy_specific_card(request: Request, req: BuySpecificCardRequest, user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
if user.shards < SPECIFIC_CARD_COST:
raise HTTPException(status_code=400, detail="Not enough shards")
card = await _get_specific_card_async(req.wiki_title)
if card is None:
raise HTTPException(status_code=404, detail="Could not generate a card for that Wikipedia page")
db_card = CardModel(
name=card.name,
image_link=card.image_link,
card_rarity=card.card_rarity.name,
card_type=card.card_type.name,
text=card.text,
attack=card.attack,
defense=card.defense,
cost=card.cost,
user_id=user.id,
received_at=datetime.now(),
)
db.add(db_card)
user.shards -= SPECIFIC_CARD_COST
db.commit()
db.refresh(db_card)
return {
**{c.name: getattr(db_card, c.name) for c in db_card.__table__.columns},
"card_rarity": db_card.card_rarity,
"card_type": db_card.card_type,
"shards": user.shards,
}
@router.post("/store/buy")
def store_buy(req: StoreBuyRequest, user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
cost = STORE_PACKAGES.get(req.quantity)
if cost is None:
raise HTTPException(status_code=400, detail="Invalid package")
if user.shards < cost:
raise HTTPException(status_code=400, detail="Not enough shards")
user.shards -= cost
user.boosters += req.quantity
db.commit()
return {"shards": user.shards, "boosters": user.boosters}
+411
View File
@@ -0,0 +1,411 @@
import uuid
from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException, Request, WebSocket, WebSocketDisconnect
from pydantic import BaseModel
from sqlalchemy.orm import Session
from services import notification_manager
from core.auth import decode_access_token
from core.database import get_db
from core.dependencies import get_current_user, get_user_id_from_request, limiter
from core.models import Card as CardModel
from core.models import Notification as NotificationModel
from core.models import TradeProposal as TradeProposalModel
from core.models import User as UserModel
from routers.notifications import _serialize_notification
from services.trade_manager import (
TradeQueueEntry, active_trades, handle_trade_action,
handle_trade_disconnect, serialize_trade, trade_queue, trade_queue_lock, try_trade_match,
)
from services.trade_manager import transfer_cards
router = APIRouter()
def _fetch_cards_for_ids(id_strings: list, db: Session) -> list:
"""Fetch CardModel rows for a JSONB list of UUID strings, preserving nothing if list is empty."""
if not id_strings:
return []
uuids = [uuid.UUID(cid) for cid in id_strings]
return db.query(CardModel).filter(CardModel.id.in_(uuids)).all()
def _serialize_proposal(p: TradeProposalModel, current_user_id: uuid.UUID, card_map: dict) -> dict:
offered_cards = [card_map[cid] for cid in p.offered_card_ids if cid in card_map]
requested_cards = [card_map[cid] for cid in p.requested_card_ids if cid in card_map]
def card_summary(c: CardModel) -> dict:
return {
"id": str(c.id),
"name": c.name,
"card_rarity": c.card_rarity,
"card_type": c.card_type,
"image_link": c.image_link,
"cost": c.cost,
"text": c.text,
"attack": c.attack,
"defense": c.defense,
"generated_at": c.generated_at.isoformat() if c.generated_at else None,
}
return {
"id": str(p.id),
"status": p.status,
"direction": "outgoing" if p.proposer_id == current_user_id else "incoming",
"proposer_username": p.proposer.username,
"recipient_username": p.recipient.username,
"offered_cards": [card_summary(c) for c in offered_cards],
"requested_cards": [card_summary(c) for c in requested_cards],
"created_at": p.created_at.isoformat(),
"expires_at": p.expires_at.isoformat(),
}
# ── WebSocket trade matchmaking ───────────────────────────────────────────────
@router.websocket("/ws/trade/queue")
async def trade_queue_endpoint(websocket: WebSocket, db: Session = Depends(get_db)):
await websocket.accept()
token = await websocket.receive_text()
user_id = decode_access_token(token)
if not user_id:
await websocket.close(code=1008)
return
user = db.query(UserModel).filter(UserModel.id == uuid.UUID(user_id)).first()
if not user:
await websocket.close(code=1008)
return
if not user.email_verified:
await websocket.send_json({"type": "error", "message": "You must verify your email before trading."})
await websocket.close(code=1008)
return
entry = TradeQueueEntry(user_id=user_id, username=user.username, websocket=websocket)
async with trade_queue_lock:
trade_queue.append(entry)
await websocket.send_json({"type": "queued"})
await try_trade_match()
try:
while True:
await websocket.receive_text()
except WebSocketDisconnect:
async with trade_queue_lock:
trade_queue[:] = [e for e in trade_queue if e.user_id != user_id]
@router.websocket("/ws/trade/{trade_id}")
async def trade_endpoint(websocket: WebSocket, trade_id: str, db: Session = Depends(get_db)):
await websocket.accept()
token = await websocket.receive_text()
user_id = decode_access_token(token)
if not user_id:
await websocket.close(code=1008)
return
session = active_trades.get(trade_id)
if not session or user_id not in session.offers:
await websocket.close(code=1008)
return
session.connections[user_id] = websocket
await websocket.send_json({
"type": "state",
"state": serialize_trade(session, user_id),
})
try:
while True:
data = await websocket.receive_json()
await handle_trade_action(trade_id, user_id, data, db)
except WebSocketDisconnect:
session.connections.pop(user_id, None)
import asyncio
asyncio.create_task(handle_trade_disconnect(trade_id, user_id))
# ── Trade proposals ───────────────────────────────────────────────────────────
class CreateTradeProposalRequest(BaseModel):
recipient_username: str
offered_card_ids: list[str]
requested_card_ids: list[str]
@router.post("/trade-proposals")
@limiter.limit("10/minute", key_func=get_user_id_from_request)
async def create_trade_proposal(
request: Request,
req: CreateTradeProposalRequest,
user: UserModel = Depends(get_current_user),
db: Session = Depends(get_db),
):
# Parse UUIDs early so we give a clear error if malformed
try:
offered_uuids = [uuid.UUID(cid) for cid in req.offered_card_ids]
requested_uuids = [uuid.UUID(cid) for cid in req.requested_card_ids]
except ValueError:
raise HTTPException(status_code=400, detail="Invalid card IDs")
recipient = db.query(UserModel).filter(UserModel.username == req.recipient_username).first()
if not recipient:
raise HTTPException(status_code=404, detail="User not found")
if recipient.id == user.id:
raise HTTPException(status_code=400, detail="Cannot propose a trade with yourself")
if not offered_uuids and not requested_uuids:
raise HTTPException(status_code=400, detail="At least one side must include cards")
# Verify proposer owns all offered cards
if offered_uuids:
owned_count = db.query(CardModel).filter(
CardModel.id.in_(offered_uuids),
CardModel.user_id == user.id,
).count()
if owned_count != len(offered_uuids):
raise HTTPException(status_code=400, detail="Some offered cards are not in your collection")
# Verify all requested cards belong to recipient and are marked WTT
if requested_uuids:
wtt_count = db.query(CardModel).filter(
CardModel.id.in_(requested_uuids),
CardModel.user_id == recipient.id,
CardModel.willing_to_trade == True,
).count()
if wtt_count != len(requested_uuids):
raise HTTPException(status_code=400, detail="Some requested cards are not available for trade")
# One pending proposal per direction between two users prevents spam
duplicate = db.query(TradeProposalModel).filter(
TradeProposalModel.proposer_id == user.id,
TradeProposalModel.recipient_id == recipient.id,
TradeProposalModel.status == "pending",
).first()
if duplicate:
raise HTTPException(status_code=400, detail="You already have a pending proposal with this user")
now = datetime.now()
proposal = TradeProposalModel(
proposer_id=user.id,
recipient_id=recipient.id,
offered_card_ids=[str(cid) for cid in offered_uuids],
requested_card_ids=[str(cid) for cid in requested_uuids],
expires_at=now + timedelta(hours=72),
)
db.add(proposal)
db.flush() # get proposal.id before notification
notif = NotificationModel(
user_id=recipient.id,
type="trade_offer",
payload={
"proposal_id": str(proposal.id),
"from_username": user.username,
"offered_count": len(offered_uuids),
"requested_count": len(requested_uuids),
},
expires_at=proposal.expires_at,
)
db.add(notif)
db.commit()
await notification_manager.send_notification(str(recipient.id), _serialize_notification(notif))
return {"proposal_id": str(proposal.id)}
@router.get("/trade-proposals/{proposal_id}")
def get_trade_proposal(
proposal_id: str,
user: UserModel = Depends(get_current_user),
db: Session = Depends(get_db),
):
try:
pid = uuid.UUID(proposal_id)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid proposal ID")
proposal = db.query(TradeProposalModel).filter(TradeProposalModel.id == pid).first()
if not proposal:
raise HTTPException(status_code=404, detail="Proposal not found")
if proposal.proposer_id != user.id and proposal.recipient_id != user.id:
raise HTTPException(status_code=403, detail="Not authorized")
# Lazy-expire before returning so the UI always sees accurate status
if proposal.status == "pending" and datetime.now() > proposal.expires_at:
proposal.status = "expired"
db.commit()
all_ids = set(proposal.offered_card_ids + proposal.requested_card_ids)
card_map = {str(c.id): c for c in _fetch_cards_for_ids(list(all_ids), db)}
return _serialize_proposal(proposal, user.id, card_map)
@router.post("/trade-proposals/{proposal_id}/accept")
async def accept_trade_proposal(
proposal_id: str,
user: UserModel = Depends(get_current_user),
db: Session = Depends(get_db),
):
proposal = db.query(TradeProposalModel).filter(TradeProposalModel.id == uuid.UUID(proposal_id)).with_for_update().first()
if not proposal:
raise HTTPException(status_code=404, detail="Proposal not found")
if proposal.recipient_id != user.id:
raise HTTPException(status_code=403, detail="Only the recipient can accept a proposal")
if proposal.status != "pending":
raise HTTPException(status_code=400, detail=f"Proposal is already {proposal.status}")
now = datetime.now()
if now > proposal.expires_at:
proposal.status = "expired"
db.commit()
raise HTTPException(status_code=400, detail="This trade proposal has expired")
offered_uuids = [uuid.UUID(cid) for cid in proposal.offered_card_ids]
requested_uuids = [uuid.UUID(cid) for cid in proposal.requested_card_ids]
# Re-verify proposer still owns all offered cards at accept time
if offered_uuids:
owned_count = db.query(CardModel).filter(
CardModel.id.in_(offered_uuids),
CardModel.user_id == proposal.proposer_id,
).count()
if owned_count != len(offered_uuids):
proposal.status = "expired"
db.commit()
raise HTTPException(status_code=400, detail="The proposer no longer owns all offered cards")
# Re-verify all requested cards still belong to recipient and are still WTT
if requested_uuids:
wtt_count = db.query(CardModel).filter(
CardModel.id.in_(requested_uuids),
CardModel.user_id == user.id,
CardModel.willing_to_trade == True,
).count()
if wtt_count != len(requested_uuids):
raise HTTPException(status_code=400, detail="Some requested cards are no longer available for trade")
# Execute both sides of the transfer atomically
transfer_cards(proposal.proposer_id, user.id, offered_uuids, db, now)
transfer_cards(user.id, proposal.proposer_id, requested_uuids, db, now)
proposal.status = "accepted"
# Clean up the trade_offer notification from the recipient's bell
deleted_notif = db.query(NotificationModel).filter(
NotificationModel.user_id == proposal.recipient_id,
NotificationModel.type == "trade_offer",
NotificationModel.payload["proposal_id"].astext == proposal_id,
).first()
deleted_notif_id = str(deleted_notif.id) if deleted_notif else None
if deleted_notif:
db.delete(deleted_notif)
# Notify the proposer that their offer was accepted
response_notif = NotificationModel(
user_id=proposal.proposer_id,
type="trade_response",
payload={
"proposal_id": proposal_id,
"status": "accepted",
"from_username": user.username,
},
)
db.add(response_notif)
# Withdraw any other pending proposals that involve cards that just changed hands.
# Both sides are now non-tradeable: offered cards left the proposer, requested cards left the recipient.
transferred_strs = {str(c) for c in offered_uuids + requested_uuids}
if transferred_strs:
for p in db.query(TradeProposalModel).filter(
TradeProposalModel.status == "pending",
TradeProposalModel.id != proposal.id,
(
(TradeProposalModel.proposer_id == proposal.proposer_id) |
(TradeProposalModel.proposer_id == proposal.recipient_id) |
(TradeProposalModel.recipient_id == proposal.proposer_id) |
(TradeProposalModel.recipient_id == proposal.recipient_id)
),
).all():
if set(p.offered_card_ids) & transferred_strs or set(p.requested_card_ids) & transferred_strs:
p.status = "withdrawn"
db.commit()
if deleted_notif_id:
await notification_manager.send_delete(str(proposal.recipient_id), deleted_notif_id)
await notification_manager.send_notification(str(proposal.proposer_id), _serialize_notification(response_notif))
return {"ok": True}
@router.post("/trade-proposals/{proposal_id}/decline")
async def decline_trade_proposal(
proposal_id: str,
user: UserModel = Depends(get_current_user),
db: Session = Depends(get_db),
):
proposal = db.query(TradeProposalModel).filter(TradeProposalModel.id == uuid.UUID(proposal_id)).first()
if not proposal:
raise HTTPException(status_code=404, detail="Proposal not found")
if proposal.proposer_id != user.id and proposal.recipient_id != user.id:
raise HTTPException(status_code=403, detail="Not authorized")
if proposal.status != "pending":
raise HTTPException(status_code=400, detail=f"Proposal is already {proposal.status}")
is_withdrawal = proposal.proposer_id == user.id
proposal.status = "withdrawn" if is_withdrawal else "declined"
# Clean up the trade_offer notification from the recipient's bell
deleted_notif = db.query(NotificationModel).filter(
NotificationModel.user_id == proposal.recipient_id,
NotificationModel.type == "trade_offer",
NotificationModel.payload["proposal_id"].astext == proposal_id,
).first()
deleted_notif_id = str(deleted_notif.id) if deleted_notif else None
if deleted_notif:
db.delete(deleted_notif)
# Notify the proposer if the recipient declined (not a withdrawal)
response_notif = None
if not is_withdrawal:
response_notif = NotificationModel(
user_id=proposal.proposer_id,
type="trade_response",
payload={
"proposal_id": proposal_id,
"status": "declined",
"from_username": user.username,
},
)
db.add(response_notif)
db.commit()
if deleted_notif_id:
await notification_manager.send_delete(str(proposal.recipient_id), deleted_notif_id)
if response_notif:
await notification_manager.send_notification(str(proposal.proposer_id), _serialize_notification(response_notif))
return {"ok": True}
@router.get("/trade-proposals")
def get_trade_proposals(user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
# Lazy-expire any pending proposals that have passed their deadline
now = datetime.now()
db.query(TradeProposalModel).filter(
TradeProposalModel.status == "pending",
TradeProposalModel.expires_at < now,
(TradeProposalModel.proposer_id == user.id) | (TradeProposalModel.recipient_id == user.id),
).update({"status": "expired"})
db.commit()
proposals = db.query(TradeProposalModel).filter(
(TradeProposalModel.proposer_id == user.id) | (TradeProposalModel.recipient_id == user.id)
).order_by(TradeProposalModel.created_at.desc()).all()
# Batch-fetch all cards referenced across all proposals in one query
all_ids = {cid for p in proposals for cid in p.offered_card_ids + p.requested_card_ids}
card_map = {str(c.id): c for c in _fetch_cards_for_ids(list(all_ids), db)}
return [_serialize_proposal(p, user.id, card_map) for p in proposals]
View File
+161
View File
@@ -0,0 +1,161 @@
import asyncio
import logging
from datetime import datetime, timedelta
from sqlalchemy import delete, insert
from sqlalchemy.orm import Session
from game.card import _get_cards_async
from core.models import Card as CardModel
from core.models import GameChallenge as GameChallengeModel
from core.models import Notification as NotificationModel
from core.models import TradeProposal as TradeProposalModel
from core.models import User as UserModel
from core.database import SessionLocal
logger = logging.getLogger("app")
## Card pool management
POOL_MINIMUM = 1000
POOL_TARGET = 2000
POOL_BATCH_SIZE = 10
POOL_SLEEP = 4.0
# After this many consecutive empty batches, stop trying and wait for the cooldown.
POOL_MAX_CONSECUTIVE_EMPTY = 5
POOL_CIRCUIT_BREAKER_COOLDOWN = 600.0 # seconds
pool_filling = False
# asyncio monotonic timestamp; 0 means breaker is closed (no cooldown active)
_cb_open_until: float = 0.0
async def fill_card_pool():
global pool_filling, _cb_open_until
if pool_filling:
logger.info("Pool fill already in progress, skipping")
return
loop_time = asyncio.get_event_loop().time()
if loop_time < _cb_open_until:
remaining = int(_cb_open_until - loop_time)
logger.warning(f"Card generation circuit breaker open, skipping fill ({remaining}s remaining)")
return
pool_filling = True
db: Session = SessionLocal()
try:
unassigned = db.query(CardModel).filter(CardModel.user_id == None, CardModel.ai_used == False).count()
logger.info(f"Card pool has {unassigned} unassigned cards")
if unassigned >= POOL_MINIMUM:
logger.info("Pool sufficiently stocked, skipping fill")
return
needed = POOL_TARGET - unassigned
logger.info(f"Filling pool with {needed} cards")
fetched = 0
consecutive_empty = 0
while fetched < needed:
batch_size = min(POOL_BATCH_SIZE, needed - fetched)
cards = await _get_cards_async(batch_size)
if not cards:
consecutive_empty += 1
logger.warning(
f"Card generation batch returned 0 cards "
f"({consecutive_empty}/{POOL_MAX_CONSECUTIVE_EMPTY} consecutive empty batches)"
)
if consecutive_empty >= POOL_MAX_CONSECUTIVE_EMPTY:
_cb_open_until = asyncio.get_event_loop().time() + POOL_CIRCUIT_BREAKER_COOLDOWN
logger.error(
f"ALERT: Card generation circuit breaker tripped — {consecutive_empty} consecutive "
f"empty batches. Wikipedia/Wikirank API may be down. "
f"Next retry in {int(POOL_CIRCUIT_BREAKER_COOLDOWN)}s."
)
return
await asyncio.sleep(POOL_SLEEP)
continue
consecutive_empty = 0
db.execute(insert(CardModel).values([
dict(
name=card.name,
image_link=card.image_link,
card_rarity=card.card_rarity.name,
card_type=card.card_type.name,
text=card.text,
attack=card.attack,
defense=card.defense,
cost=card.cost,
user_id=None,
)
for card in cards
]))
db.commit()
fetched += len(cards)
logger.info(f"Pool fill progress: {fetched}/{needed}")
await asyncio.sleep(POOL_SLEEP)
finally:
pool_filling = False
db.close()
## Booster management
BOOSTER_MAX = 5
BOOSTER_COOLDOWN_HOURS = 5
def check_boosters(user: UserModel, db: Session) -> tuple[int, datetime|None]:
if user.boosters_countdown is None:
if user.boosters < BOOSTER_MAX:
user.boosters = BOOSTER_MAX
db.commit()
return (user.boosters, user.boosters_countdown)
now = datetime.now()
countdown = user.boosters_countdown
while user.boosters < BOOSTER_MAX:
next_tick = countdown + timedelta(hours=BOOSTER_COOLDOWN_HOURS)
if now >= next_tick:
user.boosters += 1
countdown = next_tick
else:
break
user.boosters_countdown = countdown if user.boosters < BOOSTER_MAX else None
db.commit()
return (user.boosters, user.boosters_countdown)
## Periodic cleanup
CLEANUP_INTERVAL_SECONDS = 3600 # 1 hour
async def run_cleanup_loop():
# Brief startup delay so the DB is fully ready before first run
await asyncio.sleep(60)
while True:
try:
_delete_expired_records()
except Exception:
logger.exception("Periodic cleanup job failed")
await asyncio.sleep(CLEANUP_INTERVAL_SECONDS)
def _delete_expired_records():
now = datetime.now()
with SessionLocal() as db:
for model in (NotificationModel, TradeProposalModel, GameChallengeModel):
# Notification.expires_at is nullable — skip rows without an expiry.
# TradeProposal and GameChallenge always have expires_at, but the
# guard is harmless and makes the intent explicit.
result = db.execute(
delete(model).where(
model.expires_at != None, # noqa: E711
model.expires_at < now,
)
)
db.commit()
logger.info("Cleanup: deleted %d expired %s rows", result.rowcount, model.__tablename__)
@@ -1,6 +1,8 @@
import resend
import os
from config import RESEND_API_KEY, EMAIL_FROM, FRONTEND_URL
import resend
from core.config import RESEND_API_KEY, EMAIL_FROM, FRONTEND_URL
def send_verification_email(to_email: str, username: str, token: str):
resend.api_key = RESEND_API_KEY
+41
View File
@@ -0,0 +1,41 @@
"""
Manages persistent per-user WebSocket connections for the notification channel.
The DB is the source of truth this layer just delivers live pushes to connected clients.
"""
import logging
from fastapi import WebSocket
logger = logging.getLogger("app")
# user_id (str) -> active WebSocket; replaced on reconnect
connections: dict[str, WebSocket] = {}
def register(user_id: str, ws: WebSocket) -> None:
connections[user_id] = ws
def unregister(user_id: str) -> None:
connections.pop(user_id, None)
async def send_notification(user_id: str, notification: dict) -> None:
"""Push a single notification to the user if they're connected. No-op otherwise."""
ws = connections.get(user_id)
if ws:
try:
await ws.send_json({"type": "push", "notification": notification})
except Exception as e:
# Stale connection — the disconnect handler will clean it up
logger.debug(f"WebSocket send failed (stale connection): {e}")
async def send_delete(user_id: str, notification_id: str) -> None:
"""Tell the client to remove a notification from its local list."""
ws = connections.get(user_id)
if ws:
try:
await ws.send_json({"type": "delete", "notification_id": notification_id})
except Exception as e:
logger.debug(f"WebSocket send failed (stale connection): {e}")
@@ -1,14 +1,54 @@
import asyncio
import uuid
import logging
import uuid
from dataclasses import dataclass, field
from datetime import datetime
from fastapi import WebSocket
from sqlalchemy.orm import Session
from models import Card as CardModel, DeckCard as DeckCardModel
from core.models import Card as CardModel, DeckCard as DeckCardModel
logger = logging.getLogger("app")
## Card transfer
def transfer_cards(
from_user_id: uuid.UUID,
to_user_id: uuid.UUID,
card_ids: list[uuid.UUID],
db: Session,
now: datetime,
) -> None:
"""
Reassigns card ownership, stamps received_at, removes deck memberships, and clears WTT.
Does NOT commit caller owns the transaction.
Clearing WTT on transfer prevents a card from auto-appearing as tradeable on the new owner's
profile without them explicitly opting in.
"""
if not card_ids:
return
matched_cards = db.query(CardModel).filter(
CardModel.id.in_(card_ids),
CardModel.user_id == from_user_id,
).all()
# Bail out if any card is missing or no longer owned by the sender — a partial
# transfer would silently give the receiver fewer cards than agreed upon.
if len(matched_cards) != len(card_ids):
raise ValueError(
f"Expected {len(card_ids)} cards owned by {from_user_id}, "
f"found {len(matched_cards)}"
)
for card in matched_cards:
card.user_id = to_user_id
card.received_at = now
card.willing_to_trade = False
db.query(DeckCardModel).filter(DeckCardModel.card_id == card.id).delete(synchronize_session=False)
## Storage
@dataclass
@@ -47,7 +87,10 @@ def serialize_card_model(card: CardModel) -> dict:
"defense": card.defense,
"cost": card.cost,
"text": card.text,
"created_at": card.created_at.isoformat() if card.created_at else None,
"generated_at": card.generated_at.isoformat() if card.generated_at else None,
"received_at": card.received_at.isoformat() if card.received_at else None,
"is_favorite": card.is_favorite,
"willing_to_trade": card.willing_to_trade,
}
def serialize_trade(session: TradeSession, perspective_user_id: str) -> dict:
@@ -76,8 +119,8 @@ async def broadcast_trade(session: TradeSession) -> None:
"type": "state",
"state": serialize_trade(session, user_id),
})
except Exception:
pass
except Exception as e:
logger.debug(f"WebSocket send failed (stale connection): {e}")
## Matchmaking
@@ -108,8 +151,8 @@ async def try_trade_match() -> None:
for entry in [p1, p2]:
try:
await entry.websocket.send_json({"type": "trade_start", "trade_id": trade_id})
except Exception:
pass
except Exception as e:
logger.debug(f"WebSocket send failed (stale connection): {e}")
## Action handling
@@ -230,28 +273,17 @@ async def _complete_trade(trade_id: str, db: Session) -> None:
"type": "error",
"message": "Trade failed: ownership check failed. Offers have been reset.",
})
except Exception:
pass
except Exception as e:
logger.debug(f"WebSocket send failed (stale connection): {e}")
for offer in session.offers.values():
offer.accepted = False
await broadcast_trade(session)
return
# Transfer ownership and clear deck relationships
for cid_str in [c["id"] for c in cards_u1]:
cid = uuid.UUID(cid_str)
card = db.query(CardModel).filter(CardModel.id == cid).first()
if card:
card.user_id = uuid.UUID(u2)
db.query(DeckCardModel).filter(DeckCardModel.card_id == cid).delete()
for cid_str in [c["id"] for c in cards_u2]:
cid = uuid.UUID(cid_str)
card = db.query(CardModel).filter(CardModel.id == cid).first()
if card:
card.user_id = uuid.UUID(u1)
db.query(DeckCardModel).filter(DeckCardModel.card_id == cid).delete()
now = datetime.now()
transfer_cards(uuid.UUID(u1), uuid.UUID(u2), [uuid.UUID(c["id"]) for c in cards_u1], db, now)
transfer_cards(uuid.UUID(u2), uuid.UUID(u1), [uuid.UUID(c["id"]) for c in cards_u2], db, now)
db.commit()
active_trades.pop(trade_id, None)
@@ -259,8 +291,8 @@ async def _complete_trade(trade_id: str, db: Session) -> None:
for ws in list(session.connections.values()):
try:
await ws.send_json({"type": "trade_complete"})
except Exception:
pass
except Exception as e:
logger.debug(f"WebSocket send failed (stale connection): {e}")
## Disconnect handling
@@ -279,5 +311,5 @@ async def handle_trade_disconnect(trade_id: str, user_id: str) -> None:
"type": "error",
"message": "Your trade partner disconnected. Trade cancelled.",
})
except Exception:
pass
except Exception as e:
logger.debug(f"WebSocket send failed (stale connection): {e}")
+11 -2
View File
@@ -1,13 +1,14 @@
import uuid
from dotenv import load_dotenv
load_dotenv()
from game import (
from game.rules import (
GameState, PlayerState, CardInstance, CombatEvent, GameResult,
create_game, resolve_combat, check_win_condition,
action_play_card, action_sacrifice, action_end_turn,
BOARD_SIZE, HAND_SIZE, STARTING_LIFE, MAX_ENERGY_CAP,
)
import uuid
# ── Helpers ──────────────────────────────────────────────────────────────────
@@ -79,6 +80,8 @@ class TestCreateGame:
card_rarity = "common"
image_link = ""
text = ""
is_favorite = False
willing_to_trade = False
cards = [FakeCard() for _ in range(20)]
state = create_game("p1", "player 1", "test", cards, "p2", "player 2", "test", cards)
@@ -96,6 +99,8 @@ class TestCreateGame:
card_rarity = "common"
image_link = ""
text = ""
is_favorite = False
willing_to_trade = False
cards = [FakeCard() for _ in range(20)]
state = create_game("p1", "player 1", "test", cards, "p2", "player 2", "test", cards)
@@ -113,6 +118,8 @@ class TestCreateGame:
card_rarity = "common"
image_link = ""
text = ""
is_favorite = False
willing_to_trade = False
cards = [FakeCard() for _ in range(20)]
state = create_game("p1", "player 1", "test", cards, "p2", "player 2", "test", cards)
@@ -131,6 +138,8 @@ class TestCreateGame:
card_rarity = "common"
image_link = ""
text = ""
is_favorite = False
willing_to_trade = False
cards = [FakeCard() for _ in range(20)]
state = create_game("p1", "player 1", "test", cards, "p2", "player 2", "test", cards)
+119
View File
@@ -0,0 +1,119 @@
# Design Language
## Aesthetic
Dark fantasy TCG aesthetic. Warm golds and bronzes on near-black brown backgrounds. Cinzel for headings/labels/buttons, Crimson Text for body/prose/inputs.
## Colors
All core colors are available as CSS custom properties on `:root` in `app.css` (e.g. `var(--color-bg)`, `var(--color-bronze)`).
| Role | Value |
|------|-------|
| Page background | `#0d0a04` |
| Header background | `#1a1008` |
| Modal / dark surface | `#110d04` |
| Raised surface | `#3d2507` |
| Primary text / accent | `#f0d080` (gold) |
| Secondary text | `rgba(240, 180, 80, 0.6)` |
| Placeholder text | `rgba(240, 180, 80, 0.30.4)` |
| Interactive accent (buttons, borders, hover) | `#c8861a` (bronze-orange) |
| Interactive accent hover | `#e09820` |
| Border / divider | `#6b4c1e` |
| Subtle border | `rgba(107, 76, 30, 0.30.5)` |
| Button text | `#fff8e0` |
| Error / delete | `#c84040` |
| Success / positive | `#6aaa6a` |
| Energy cost indicator | `#6ea0ec` |
## Typography
- **Headings / labels / buttons**: Cinzel (Google Fonts), weights 400/700/900
- **Body / prose / inputs**: Crimson Text (Google Fonts), weights 400/600; italic for flavor/secondary text
- Button and label text: **uppercase**, `letter-spacing: 0.060.1em`
- Form labels: uppercase, `letter-spacing: 0.08em`
### Type Scale
All type scale tokens are CSS custom properties on `:root` in `app.css`.
| Token | Value | Use for |
|-------|-------|---------|
| `--text-xs` | `9px` | Fine print, badges, metadata labels |
| `--text-sm` | `11px` | Secondary text, captions, small labels |
| `--text-base` | `13px` | Default body text, buttons, card details |
| `--text-md` | `15px` | Form inputs, emphasized body text |
| `--text-lg` | `18px` | Section headings, card titles |
| `--text-xl` | `22px` | Page headings, prominent labels |
| `--text-2xl` | `28px` | Large display headings |
| `--text-3xl` | `36px` | Hero headings, splash text |
## Buttons
| Variant | Background | Border | Text |
|---------|-----------|--------|------|
| Primary | `#c8861a` | none | `#fff8e0` |
| Secondary | `#3d2507` | `1px solid rgba(107,76,30,0.4)` | `#f0d080` |
| Destructive | `rgba(180,40,40,0.8)` | none | `#fff` |
### Sizes
All button size tokens are CSS custom properties on `:root` in `app.css`.
| Size | Padding | Font-size | Border-radius | Use for |
|------|---------|-----------|---------------|---------|
| Small | `4px 10px` | `10px` | `var(--radius-sm)` | Toolbar filters, sort toggles, inline actions, edit/delete |
| Medium | `8px 18px` | `12px` | `var(--radius-md)` | Card actions, done/choose, friend, secondary, cancel |
| Large | `10px 32px` | `13px` | `var(--radius-md)` | Primary CTAs, auth submit, play, buy, accept trade |
- Font: Cinzel 700 uppercase, letter-spacing 0.060.1em
- Disabled: opacity 0.5; hover: lighten background or brighten border + text
## Inputs / Forms
- Background: `#1a1008`; border-radius: 6px; color: `#f0d080`; font: Crimson Text 15px
- Text inputs: `1.5px solid #6b4c1e`; focus border: `#c8861a`
- Selects: `1.5px solid #6b4c1e`
- Placeholder: `rgba(240, 180, 80, 0.4)`; accent-color (checkboxes, ranges): `#c8861a`
## Containers / Panels
Border-radius, shadows, z-index layers, and spacing are available as CSS custom properties on `:root` in `app.css` (e.g. `var(--radius-lg)`, `var(--shadow-card)`, `var(--z-modal)`, `var(--space-md)`).
- Border-radius: 1012px; box-shadow: `0 4px 24px rgba(0,0,0,0.5)`
- Borders: `12px solid #6b4c1e` standard, `#c8861a` for emphasis
- Backgrounds: `#1a1008` (surface), `#3d2507` (raised)
- Card hover lift: `translateY(-4px) scale(1.02)`, shadow `0 12px 40px rgba(0,0,0,0.6)`
## Transitions
- Default: `0.15s ease` on background, border-color, color, transform
- Card hover: `0.2s ease`
## Card Type Colors (CSS vars `--bg` / `--header`)
| Type | Background | Header |
|------|-----------|--------|
| person | `#f0e0c8` | `#b87830` |
| location | `#d8e8d4` | `#4a7a50` |
| artwork | `#e4d4e8` | `#7a5090` |
| life_form | `#ccdce8` | `#3a6878` |
| event | `#e8d4d4` | `#8b2020` |
| group | `#e8e4d0` | `#748c12` |
| science_thing | `#c7c5c1` | `#060c17` |
| vehicle | `#c7c1c4` | `#801953` |
| organization | `#b7c1c4` | `#3c5251` |
## Rarity Badge Colors
| Rarity | Background | Text |
|--------|-----------|------|
| common | `#c8c8c8` | `#333` |
| uncommon | `#4a7a50` | `#fff` |
| rare | `#2a5a9b` | `#fff` |
| super_rare | `#7a3a9b` | `#fff` |
| epic | `#9b3a3a` | `#fff` |
| legendary | `#b87820` | `#fff` |
## Card Component
- Outer border and internal borders: `#000` (pure black) — these are structural borders within the card face, distinct from the themed `#6b4c1e` borders on containers/panels.
- Card background: `#111`; border-radius: 12px; padding: 7px
## Spacing
- Page padding: `2rem`; section gap: `11.5rem`; component internal gap: `0.40.75rem`
+102 -1
View File
@@ -1,14 +1,115 @@
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;700;900&family=Crimson+Text:ital,wght@0,400;0,600;1,400&display=swap');
:root {
/* Colors */
--color-bg: #0d0a04;
--color-surface: #1a1008;
--color-surface-raised: #3d2507;
--color-gold: #f0d080;
--color-gold-muted: rgba(240, 180, 80, 0.8);
--color-gold-dim: rgba(240, 180, 80, 0.6);
--color-gold-faint: rgba(240, 180, 80, 0.4);
--color-bronze: #c8861a;
--color-bronze-hover: #e09820;
--color-border: #6b4c1e;
--color-border-subtle: rgba(107, 76, 30, 0.4);
--color-border-dim: rgba(107, 76, 30, 0.3);
--color-overlay: rgba(0, 0, 0, 0.5);
--color-btn-text: #fff8e0;
--color-error: #c84040;
--color-success: #6aaa6a;
--color-energy: #6ea0ec;
--color-shard: #b87820;
--color-cyan: #7ecfcf; /* shard quantity / cyan accent */
/* Border-radius */
--radius-sm: 4px;
--radius-md: 6px;
--radius-lg: 10px;
--radius-xl: 12px;
--radius-full: 50%;
/* Shadows */
--shadow-subtle: 0 2px 8px rgba(0, 0, 0, 0.3);
--shadow-card: 0 4px 24px rgba(0, 0, 0, 0.5);
--shadow-elevated: 0 12px 40px rgba(0, 0, 0, 0.6);
--shadow-glow: 0 0 20px rgba(200, 134, 26, 0.3);
/* Z-index layers */
--z-base: 1;
--z-card: 10;
--z-header: 100;
--z-dropdown: 200;
--z-modal: 300;
--z-toast: 400;
/* Type scale */
--text-xs: 9px;
--text-sm: 11px;
--text-base: 13px;
--text-md: 15px;
--text-lg: 18px;
--text-xl: 22px;
--text-2xl: 28px;
--text-3xl: 36px;
/* Button sizes */
--btn-padding-sm: 4px 10px;
--btn-font-sm: 10px;
--btn-padding-md: 8px 18px;
--btn-font-md: 12px;
--btn-padding-lg: 10px 32px;
--btn-font-lg: 13px;
/* Spacing */
--space-xs: 0.25rem;
--space-sm: 0.5rem;
--space-md: 1rem;
--space-lg: 1.5rem;
--space-xl: 2rem;
}
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
* {
scrollbar-width: thin;
scrollbar-color: var(--color-border) transparent;
}
body {
background: #0d0a04;
background: var(--color-bg);
}
html, body {
height: 100%;
overflow: hidden;
}
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--color-border);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--color-bronze);
}
@keyframes shard-pulse {
0%, 100% {
filter: brightness(1);
text-shadow: 0 0 6px rgba(126, 207, 207, 0.45);
}
50% {
filter: brightness(1.45);
text-shadow: 0 0 14px rgba(126, 207, 207, 0.9), 0 0 28px rgba(126, 207, 207, 0.35);
}
}
+69 -29
View File
@@ -1,4 +1,4 @@
<script>
<script lang="ts">
let { card, noHover = false, defenseOverride = null } = $props();
const RARITY_BADGE = {
@@ -7,7 +7,7 @@
rare: { symbol: "R", label: "Rare", bg: "#2a5a9b", color: "#fff" },
super_rare: { symbol: "SR", label: "Super Rare", bg: "#7a3a9b", color: "#fff" },
epic: { symbol: "E", label: "Epic", bg: "#9b3a3a", color: "#fff" },
legendary: { symbol: "L", label: "Legendary", bg: "#b87820", color: "#fff" },
legendary: { symbol: "L", label: "Legendary", bg: "#b87820", color: "#fff8e0" },
};
const TYPE_COLORS = {
@@ -26,13 +26,13 @@
const FOIL_RARITIES = new Set(["super_rare", "epic", "legendary"]);
let rarity = $derived(card.card_rarity);
let badge = $derived(RARITY_BADGE[rarity] ?? RARITY_BADGE.common);
let badge = $derived(RARITY_BADGE[rarity as keyof typeof RARITY_BADGE] ?? RARITY_BADGE.common);
let foil = $derived(FOIL_RARITIES.has(rarity))
let foilOffset = $derived(foil ? `${-(Math.random() * 5).toFixed(2)}s` : '0s');
let super_rare = $derived(rarity == "super_rare");
let epic = $derived(rarity == "epic");
let legendary = $derived(rarity === "legendary");
let colors = $derived(TYPE_COLORS[card.card_type] ?? TYPE_COLORS.other);
let colors = $derived(TYPE_COLORS[card.card_type as keyof typeof TYPE_COLORS] ?? TYPE_COLORS.other);
let typeLabel = $derived(card.card_type.charAt(0).toUpperCase() + card.card_type.slice(1).replace("_", " "));
let wikiUrl = $derived("https://en.wikipedia.org/wiki/" + encodeURIComponent(card.name.replace(/ /g, "_")));
</script>
@@ -47,7 +47,7 @@
<div class="card-image-wrap">
{#if card.image_link}
<img src={card.image_link} alt={card.name} class="card-image" draggable="false"/>
<img src={card.image_link} alt={card.name} class="card-image" draggable="false" loading="lazy"/>
{:else}
<div class="card-image-placeholder">
<span>{card.name[0]}</span>
@@ -56,6 +56,17 @@
<div class="rarity-badge" title={badge.label} style="--rb: {badge.bg}; --rc: {badge.color}">{badge.symbol}</div>
{#if card.willing_to_trade || card.is_favorite}
<div class="card-badges">
{#if card.willing_to_trade}
<div class="wtt-badge" title="Willing to trade"></div>
{/if}
{#if card.is_favorite}
<div class="favorite-badge" title="Favorite"></div>
{/if}
</div>
{/if}
<a href={wikiUrl} target="_blank" rel="noopener noreferrer" class="wiki-link" title="Open Wikipedia article">
<svg viewBox="0 0 50 50" xmlns="http://www.w3.org/2000/svg" width="16" height="16">
<circle cx="25" cy="25" r="24" fill="white" stroke="#888" stroke-width="1"/>
@@ -76,7 +87,7 @@
<div class="card-footer">
<span class="stat">ATK <strong>{card.attack}</strong></span>
<span class="card-date">{new Date(card.created_at).toLocaleDateString()}</span>
<span class="card-date">{new Date(card.generated_at ?? card.created_at).toLocaleDateString('en-GB', { year: 'numeric', month: '2-digit', day: '2-digit' })}</span>
<span class="stat">DEF <strong>{defenseOverride !== null ? defenseOverride : card.defense}</strong></span>
</div>
@@ -84,15 +95,13 @@
</div>
<style>
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;700&family=Crimson+Text:ital,wght@0,400;0,600;1,400&display=swap');
.card {
width: 300px;
border-radius: 12px;
border-radius: var(--radius-xl);
padding: 7px;
background: #111;
border: 2px solid #111;
box-shadow: 0 4px 24px rgba(0,0,0,0.5);
border: 2px solid #000;
box-shadow: var(--shadow-card);
font-family: 'Crimson Text', serif;
position: relative;
transition: transform 0.2s ease, box-shadow 0.2s ease;
@@ -103,14 +112,14 @@
.card:hover {
transform: translateY(-4px) scale(1.02);
box-shadow: 0 12px 40px rgba(0,0,0,0.6);
box-shadow: var(--shadow-elevated);
}
.card.foil::before {
content: "";
position: absolute;
inset: 0;
border-radius: 12px;
border-radius: var(--radius-xl);
animation: foil-shift 2.5s ease-in-out infinite alternate;
animation-delay: var(--foil-offset, 0s);
pointer-events: none;
@@ -182,7 +191,7 @@
.card-name {
font-family: 'Cinzel', serif;
font-size: 13px;
font-size: var(--text-base);
font-weight: 700;
color: #fff;
line-height: 1.3;
@@ -196,7 +205,7 @@
.card-type-badge {
font-family: 'Cinzel', serif;
font-size: 9px;
font-size: var(--text-xs);
color: rgba(255,255,255,0.95);
text-transform: uppercase;
letter-spacing: 0.05em;
@@ -232,7 +241,7 @@
justify-content: center;
background: #ddd;
font-family: 'Cinzel', serif;
font-size: 64px;
font-size: var(--text-3xl);
color: rgba(0,0,0,0.15);
}
@@ -242,12 +251,12 @@
left: 7px;
width: 26px;
height: 26px;
border-radius: 50%;
border-radius: var(--radius-full);
background: var(--rb);
border: 2.5px solid #000;
color: var(--rc);
font-family: 'Cinzel', serif;
font-size: 9px;
font-size: var(--text-xs);
font-weight: 700;
display: flex;
align-items: center;
@@ -256,13 +265,44 @@
letter-spacing: -0.02em;
}
.card-badges {
position: absolute;
bottom: 7px;
right: 7px;
display: flex;
gap: 4px;
z-index: 3;
}
.wtt-badge, .favorite-badge {
width: 28px;
height: 28px;
border-radius: var(--radius-full);
border: 2px solid #000;
display: flex;
align-items: center;
justify-content: center;
}
.wtt-badge {
background: rgba(0, 160, 160, 0.85);
color: #fff;
font-size: var(--text-base);
}
.favorite-badge {
background: rgba(255, 200, 0, 0.92);
color: #5a3a00;
font-size: var(--text-md);
}
.wiki-link {
position: absolute;
top: 7px;
right: 7px;
width: 26px;
height: 26px;
border-radius: 50%;
border-radius: var(--radius-full);
background: rgba(255,255,255,0.92);
border: 1.5px solid #000;
display: flex;
@@ -284,7 +324,7 @@
.card-text {
padding: 10px 12px;
font-size: 13px;
font-size: var(--text-base);
line-height: 1.55;
color: #1a1208;
font-style: italic;
@@ -304,21 +344,21 @@
.stat {
font-family: 'Cinzel', serif;
font-size: 11px;
font-size: var(--text-sm);
color: #2a2010;
letter-spacing: 0.03em;
}
.stat strong {
color: #000;
font-size: 15px;
font-size: var(--text-md);
}
.card-date {
font-size: 10px;
color: rgba(0,0,0,0.5);
font-size: var(--text-sm);
color: rgba(0,0,0,0.4);
font-style: italic;
font-family: 'Crimson Text', serif;
font-family: 'Cinzel', serif;
}
.cost-bubbles {
@@ -335,15 +375,15 @@
.cost-bubble {
width: 16px;
height: 16px;
border-radius: 50%;
background: #6ea0ec;
border-radius: var(--radius-full);
background: var(--color-energy);
border: 2.5px solid #000;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
color: #08152c;
font-size: 12px;
font-size: var(--text-sm);
font-weight: 700;
font-family: 'Cinzel', serif;
line-height: 1;
@@ -351,6 +391,6 @@
.card.no-hover:hover {
transform: none;
box-shadow: 0 4px 24px rgba(0,0,0,0.5);
box-shadow: var(--shadow-card);
}
</style>
+308 -108
View File
@@ -1,26 +1,24 @@
<script>
<script lang="ts">
import Card from '$lib/Card.svelte';
import { apiFetch, API_URL } from '$lib/api.js';
import { onMount } from 'svelte';
const RARITIES = ['common', 'uncommon', 'rare', 'super_rare', 'epic', 'legendary'];
const TYPES = ['person', 'location', 'artwork', 'life_form', 'event', 'group', 'science_thing', 'vehicle', 'organization', 'other'];
const RARITY_ORDER = Object.fromEntries(RARITIES.map((r, i) => [r, i]));
let {
allCards = [],
staticCards = null as any[] | null, // if provided, use this list instead of self-fetching (e.g. another user's WTT cards)
selectedIds = $bindable(new Set()),
selectedCards = $bindable([]),
selectedCost = $bindable(0),
costMap = $bindable(new Map()),
inDeckIds = new Set(),
onclose = null,
costLimit = null, // if set, prevents selecting cards that would exceed it
showFooter = true, // set false to hide the Done button (e.g. inline deck builder)
} = $props();
const selectedCost = $derived(
costLimit !== null
? allCards.filter(c => selectedIds.has(c.id)).reduce((sum, c) => sum + c.cost, 0)
: 0
);
function label(str) {
function label(str: string) {
return str.charAt(0).toUpperCase() + str.slice(1).replace(/_/g, ' ');
}
@@ -32,40 +30,159 @@
let costMax = $state(10);
let filtersOpen = $state(false);
let searchQuery = $state('');
let willingToTradeOnly = $state(false);
let filtered = $derived.by(() => {
const RARITY_ORDER = Object.fromEntries(RARITIES.map((r, i) => [r, i]));
// In static mode (staticCards provided), cards are filtered client-side.
// In self-fetch mode, they come from the server with all filtering applied.
const PAGE_SIZE = 40;
let fetchedCards: any[] = $state([]);
let total = $state(0);
let loadingMore = $state(false);
let hasMore = $derived(fetchedCards.length < total);
// Must be $state so the IntersectionObserver $effect re-runs when the element is bound
let sentinel: HTMLElement | undefined = $state();
// .grid has overflow-y: auto — it is the scroll container, not the viewport.
let gridEl: HTMLElement | undefined = $state();
// In static mode, apply client-side filter/sort to staticCards
let cards = $derived.by(() => {
if (staticCards === null) return fetchedCards;
const q = searchQuery.trim().toLowerCase();
let result = allCards.filter(c =>
let result = staticCards.filter((c: any) =>
selectedRarities.has(c.card_rarity) &&
selectedTypes.has(c.card_type) &&
c.cost >= costMin &&
c.cost <= costMax &&
(!q || c.name.toLowerCase().includes(q))
(!q || c.name.toLowerCase().includes(q)) &&
(!willingToTradeOnly || c.willing_to_trade)
);
result = result.slice().sort((a, b) => {
result = result.slice().sort((a: any, b: any) => {
let cmp = 0;
if (sortBy === 'name') cmp = a.name.localeCompare(b.name);
else if (sortBy === 'cost') cmp = b.cost - a.cost || a.name.localeCompare(b.name);
else if (sortBy === 'attack') cmp = b.attack - a.attack || a.name.localeCompare(b.name);
else if (sortBy === 'defense') cmp = b.defense - a.defense || a.name.localeCompare(b.name);
else if (sortBy === 'rarity') cmp = RARITY_ORDER[b.card_rarity] - RARITY_ORDER[a.card_rarity] || a.name.localeCompare(b.name);
else if (sortBy === 'date_generated') cmp = (b.generated_at ?? '').localeCompare(a.generated_at ?? '');
else if (sortBy === 'date_received') cmp = (b.received_at ?? b.generated_at ?? '').localeCompare(a.received_at ?? a.generated_at ?? '');
return sortAsc ? cmp : -cmp;
});
return result;
});
function toggleSort(val) {
// Keep bindable selectedCards in sync with loaded set.
$effect(() => {
selectedCards = cards.filter((c: any) => selectedIds.has(c.id));
});
// Update costMap as new cards load so we know costs even after they scroll away.
$effect(() => {
let changed = false;
for (const c of cards) {
if (!costMap.has(c.id)) changed = true;
}
if (changed) {
const m = new Map(costMap);
for (const c of cards) m.set(c.id, c.cost);
costMap = m;
}
});
// Compute cost from costMap so it includes cards not yet scrolled into view.
$effect(() => {
let sum = 0;
for (const id of selectedIds) {
sum += costMap.get(id) ?? 0;
}
selectedCost = sum;
});
// For non-name sorts the "natural" first click should show the highest/newest/rarest values.
// This matches old client-side behaviour where numeric sorts used b - a (descending first).
function sortDir() {
return sortBy === 'name'
? (sortAsc ? 'asc' : 'desc')
: (sortAsc ? 'desc' : 'asc');
}
async function fetchCards(reset = false) {
if (staticCards !== null) return; // static mode: nothing to fetch
if (reset) { fetchedCards = []; total = 0; }
if (loadingMore) return;
loadingMore = true;
const params = new URLSearchParams({
skip: String(reset ? 0 : fetchedCards.length),
limit: String(PAGE_SIZE),
search: searchQuery.trim(),
cost_min: String(costMin),
cost_max: String(costMax),
favorites_only: 'false',
wtt_only: String(willingToTradeOnly),
sort_by: sortBy,
sort_dir: sortDir(),
});
for (const r of selectedRarities) params.append('rarities', r);
for (const t of selectedTypes) params.append('types', t);
const res = await apiFetch(`${API_URL}/cards?${params}`);
if (!res.ok) { loadingMore = false; return; }
const data = await res.json();
fetchedCards = reset ? data.cards : [...fetchedCards, ...data.cards];
total = data.total;
loadingMore = false;
}
// Exposed so parents (e.g. shatter page) can trigger a full refetch after mutations
export function refresh() { fetchCards(true); }
// Debounced refetch on any filter/sort change (only in self-fetch mode).
// Skip the initial run — the component mounts and triggers the first fetch directly
// via the onMount-equivalent $effect below, avoiding a double-fetch flash.
let mounted = false;
let fetchTimer: number;
$effect(() => {
searchQuery; sortBy; sortAsc; selectedRarities; selectedTypes;
costMin; costMax; willingToTradeOnly;
if (staticCards !== null || !mounted) return;
clearTimeout(fetchTimer);
fetchTimer = setTimeout(() => fetchCards(true), 300);
});
// onMount runs once and doesn't track reactive dependencies — safe for the initial fetch.
// Using $effect here causes re-runs whenever loadingMore changes, resetting state infinitely.
onMount(() => {
if (staticCards === null) fetchCards(true).then(() => { mounted = true; });
else mounted = true;
});
// IntersectionObserver: load next page when sentinel scrolls into view (self-fetch mode only).
// Uses gridEl as root because .grid has overflow-y: auto — it is the scroll container.
// Both sentinel and gridEl are $state so this effect re-runs once both are bound.
$effect(() => {
if (!sentinel || !gridEl || staticCards !== null) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasMore && !loadingMore) fetchCards(false);
},
{ root: gridEl, rootMargin: '200px' }
);
observer.observe(sentinel);
return () => observer.disconnect();
});
function toggleSort(val: string) {
if (sortBy === val) sortAsc = !sortAsc;
else { sortBy = val; sortAsc = true; }
}
function toggleRarity(r) {
function toggleRarity(r: string) {
const s = new Set(selectedRarities);
s.has(r) ? s.delete(r) : s.add(r);
selectedRarities = s;
}
function toggleType(t) {
function toggleType(t: string) {
const s = new Set(selectedTypes);
s.has(t) ? s.delete(t) : s.add(t);
selectedTypes = s;
@@ -76,14 +193,17 @@
function toggleAllRarities() { selectedRarities = allRaritiesSelected() ? new Set() : new Set(RARITIES); }
function toggleAllTypes() { selectedTypes = allTypesSelected() ? new Set() : new Set(TYPES); }
function toggleCard(id) {
function toggleCard(id: string | number) {
const s = new Set(selectedIds);
if (s.has(id)) {
s.delete(id);
} else {
if (costLimit !== null) {
const card = allCards.find(c => c.id === id);
if (card && selectedCost + card.cost > costLimit) return;
const card = cards.find((c: any) => c.id === id);
if (costLimit !== null && card && selectedCost + card.cost > costLimit) return;
if (card && !costMap.has(id)) {
const m = new Map(costMap);
m.set(id, card.cost);
costMap = m;
}
s.add(id);
}
@@ -101,7 +221,7 @@
<div class="toolbar">
<div class="sort-row">
<span class="toolbar-label">Sort by</span>
{#each [['name','Name'],['cost','Cost'],['attack','Attack'],['defense','Defense'],['rarity','Rarity']] as [val, lbl]}
{#each [['name','Name'],['cost','Cost'],['attack','Attack'],['defense','Defense'],['rarity','Rarity'],['date_generated','Generated'],['date_received','Received']] as [val, lbl]}
<button class="sort-btn" class:active={sortBy === val} onclick={() => toggleSort(val)}>
{lbl}
{#if sortBy === val}<span class="sort-arrow">{sortAsc ? '↑' : '↓'}</span>{/if}
@@ -115,12 +235,21 @@
bind:value={searchQuery}
/>
<button class="filter-toggle" onclick={() => filtersOpen = !filtersOpen}>
{filtersOpen ? 'Hide filters' : 'Filter'}
{#if selectedRarities.size < RARITIES.length || selectedTypes.size < TYPES.length || costMin > 1 || costMax < 10}
<span class="filter-dot"></span>
{/if}
</button>
<div class="filter-actions">
<button
class="filter-toggle"
class:active={willingToTradeOnly}
onclick={() => willingToTradeOnly = !willingToTradeOnly}
title="Show willing to trade only"
>⇄</button>
<button class="filter-toggle" class:active={filtersOpen} onclick={() => filtersOpen = !filtersOpen}>
Filters
{#if selectedRarities.size < RARITIES.length || selectedTypes.size < TYPES.length || costMin > 1 || costMax < 10}
<span class="filter-dot"></span>
{/if}
</button>
</div>
</div>
{#if filtersOpen}
@@ -171,36 +300,42 @@
{/if}
</div>
{#if filtered.length === 0}
{#if !loadingMore && cards.length === 0}
<p class="status">No cards match your filters.</p>
{:else}
<div class="grid">
{#each filtered as card (card.id)}
<button
class="card-wrap"
class:selected={selectedIds.has(card.id)}
class:disabled={costLimit !== null && !selectedIds.has(card.id) && selectedCost + card.cost > costLimit}
onclick={() => toggleCard(card.id)}
>
<Card {card} noHover={true} />
{#if selectedIds.has(card.id)}
<div class="selected-badge"></div>
<div class="grid" bind:this={gridEl}>
{#each cards as card (card.id)}
<div class="card-item">
<button
class="card-wrap"
class:selected={selectedIds.has(card.id)}
class:disabled={costLimit !== null && !selectedIds.has(card.id) && selectedCost + card.cost > costLimit}
onclick={() => toggleCard(card.id)}
>
<Card {card} noHover={true} />
{#if selectedIds.has(card.id)}
<div class="selected-badge"></div>
{/if}
{#if inDeckIds.has(card.id)}
<div class="in-deck-badge"></div>
{/if}
</button>
{#if sortBy === 'date_received' && card.received_at}
<span class="received-label">Received {new Date(card.received_at).toLocaleDateString('en-GB', { year: 'numeric', month: '2-digit', day: '2-digit' })}</span>
{/if}
{#if inDeckIds.has(card.id)}
<div class="in-deck-badge" title="In a deck"></div>
{/if}
</button>
</div>
{/each}
<!-- Sentinel inside the scroll container (.grid) for the IntersectionObserver root -->
<div bind:this={sentinel} class="scroll-sentinel"></div>
{#if loadingMore}<p class="status loading-more">Loading...</p>{/if}
</div>
{/if}
</div>
<style>
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;700&family=Crimson+Text:ital,wght@0,400;0,600;1,400&display=swap');
.selector {
background: #0d0a04;
background: var(--color-bg);
width: 100%;
height: 100%;
display: flex;
@@ -211,7 +346,7 @@
.toolbar {
flex-shrink: 0;
padding: 1.5rem 2rem 1rem;
border-bottom: 1px solid rgba(107, 76, 30, 0.3);
border-bottom: 1px solid var(--color-border-dim);
display: flex;
flex-direction: column;
gap: 0.75rem;
@@ -226,23 +361,23 @@
.search-input {
font-family: 'Crimson Text', serif;
font-size: 15px;
background: rgba(255,255,255,0.04);
border: 1px solid rgba(107, 76, 30, 0.4);
border-radius: 4px;
color: #f0d080;
font-size: var(--text-md);
background: var(--color-surface);
border: 1.5px solid var(--color-bronze);
border-radius: var(--radius-md);
color: var(--color-gold);
padding: 5px 10px;
outline: none;
width: 220px;
margin-left: auto;
transition: border-color 0.15s;
}
.search-input:focus { border-color: #c8861a; }
.search-input::placeholder { color: rgba(240, 180, 80, 0.3); }
.search-input:focus { border-color: var(--color-gold); }
.search-input::placeholder { color: var(--color-gold-faint); }
.toolbar-label {
font-family: 'Cinzel', serif;
font-size: 11px;
font-size: var(--text-sm);
color: rgba(240, 180, 80, 0.5);
letter-spacing: 0.08em;
text-transform: uppercase;
@@ -251,41 +386,47 @@
.sort-btn {
font-family: 'Cinzel', serif;
font-size: 11px;
font-size: var(--btn-font-sm);
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
background: none;
border: 1px solid rgba(107, 76, 30, 0.4);
border-radius: 4px;
color: rgba(240, 180, 80, 0.6);
padding: 4px 10px;
border: 1px solid var(--color-border-subtle);
border-radius: var(--radius-sm);
color: var(--color-gold-dim);
padding: var(--btn-padding-sm);
cursor: pointer;
transition: all 0.15s;
}
.sort-btn:hover { border-color: #c8861a; color: #f0d080; }
.sort-btn.active { background: #3d2507; border-color: #c8861a; color: #f0d080; }
.sort-arrow { font-size: 10px; margin-left: 3px; }
.sort-btn:hover { border-color: var(--color-bronze); color: var(--color-gold); }
.sort-btn.active { background: var(--color-surface-raised); border-color: var(--color-bronze); color: var(--color-gold); }
.filter-toggle.active { background: var(--color-surface-raised); border-color: var(--color-bronze); color: var(--color-gold); }
.sort-arrow { font-size: var(--text-xs); margin-left: 3px; }
.filter-toggle {
font-family: 'Cinzel', serif;
font-size: 11px;
font-size: var(--btn-font-sm);
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
background: none;
border: 1px solid rgba(107, 76, 30, 0.4);
border-radius: 4px;
color: rgba(240, 180, 80, 0.6);
padding: 4px 10px;
border: 1px solid var(--color-border-subtle);
border-radius: var(--radius-sm);
color: var(--color-gold-dim);
padding: var(--btn-padding-sm);
cursor: pointer;
margin-left: 0.5rem;
position: relative;
transition: all 0.15s;
}
.filter-toggle:hover { border-color: #c8861a; color: #f0d080; }
.filter-actions {
display: flex;
gap: 0.5rem;
margin-left: 0.75rem;
}
.filter-toggle:hover { border-color: var(--color-bronze); color: var(--color-gold); }
.filter-dot {
position: absolute;
@@ -293,8 +434,8 @@
right: -3px;
width: 7px;
height: 7px;
border-radius: 50%;
background: #c8861a;
border-radius: var(--radius-full);
background: var(--color-bronze);
}
.filters {
@@ -309,7 +450,7 @@
.filter-group-label {
font-family: 'Cinzel', serif;
font-size: 11px;
font-size: var(--text-sm);
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
@@ -318,7 +459,7 @@
.select-all {
font-family: 'Crimson Text', serif;
font-size: 12px;
font-size: var(--text-sm);
font-style: italic;
background: none;
border: none;
@@ -329,7 +470,7 @@
transition: color 0.15s;
}
.select-all:hover { color: #f0d080; }
.select-all:hover { color: var(--color-gold); }
.checkboxes { display: flex; flex-wrap: wrap; gap: 0.4rem 1rem; }
@@ -338,13 +479,13 @@
align-items: center;
gap: 0.4rem;
font-family: 'Crimson Text', serif;
font-size: 14px;
color: rgba(240, 180, 80, 0.8);
font-size: var(--text-base);
color: var(--color-gold-muted);
cursor: pointer;
}
.checkbox-label input {
accent-color: #c8861a;
accent-color: var(--color-bronze);
width: 14px;
height: 14px;
cursor: pointer;
@@ -354,13 +495,13 @@
.range-label {
font-family: 'Cinzel', serif;
font-size: 11px;
font-size: var(--text-sm);
color: rgba(240, 180, 80, 0.7);
min-width: 60px;
}
input[type=range] {
accent-color: #c8861a;
accent-color: var(--color-bronze);
width: 160px;
}
@@ -374,22 +515,46 @@
padding: 2rem 2rem 0;
}
.card-item {
position: relative;
}
.received-label {
position: absolute;
top: 44px;
left: 50%;
transform: translateX(-50%);
white-space: nowrap;
pointer-events: none;
background: rgba(13, 10, 4, 0.88);
backdrop-filter: blur(6px);
border: 1px solid rgba(200, 134, 26, 0.55);
border-radius: 20px;
padding: 3px 12px;
font-size: var(--text-sm);
font-family: 'Cinzel', serif;
font-weight: 600;
letter-spacing: 0.07em;
color: var(--color-gold);
text-transform: uppercase;
}
.card-wrap {
all: unset;
position: relative;
cursor: pointer;
display: block;
border-radius: 12px;
border-radius: var(--radius-xl);
transition: transform 0.15s, box-shadow 0.15s;
}
.card-wrap:hover {
transform: translateY(-4px) scale(1.02);
box-shadow: 0 12px 40px rgba(0,0,0,0.6);
box-shadow: var(--shadow-elevated);
}
.card-wrap.selected {
box-shadow: 0 0 0 3px #c8861a, 0 0 20px rgba(200, 134, 26, 0.4);
box-shadow: 0 0 0 3px var(--color-bronze), var(--shadow-glow);
}
.card-wrap.disabled {
@@ -402,69 +567,104 @@
top: 80px;
left: 50%;
transform: translateX(-50%);
background: #c8861a;
color: #fff8e0;
background: var(--color-bronze);
color: var(--color-btn-text);
font-family: 'Cinzel', serif;
font-size: 23.875px;
font-weight: 1000;
font-size: var(--text-xl);
font-weight: 900;
padding: 4px 10px;
border-radius: 23px;
border: black 3px solid;
pointer-events: none;
z-index: 10;
z-index: var(--z-card);
}
.in-deck-badge::after {
content: 'In deck';
position: absolute;
top: calc(100% + 6px);
right: 0;
background: #000;
color: #7ecfcf;
font-size: var(--text-sm);
font-weight: 600;
white-space: nowrap;
padding: 4px 7px;
border-radius: var(--radius-sm);
border: 1px solid rgba(126, 207, 207, 0.5);
pointer-events: none;
opacity: 0;
transition: opacity 0.15s;
z-index: var(--z-toast);
}
.in-deck-badge:hover::after {
opacity: 1;
}
.in-deck-badge {
position: absolute;
top: 6px;
right: 6px;
background: rgba(13, 8, 2, 0.75);
background: #000;
color: #7ecfcf;
font-size: 16px;
line-height: 1;
padding: 3px 5px;
border-radius: 6px;
border: 1px solid rgba(126, 207, 207, 0.5);
pointer-events: none;
z-index: 10;
font-size: var(--text-lg);
font-weight: 700;
width: 34px;
height: 34px;
padding: 0;
border-radius: var(--radius-md);
border: 1px solid rgba(126, 207, 207, 0.9);
box-shadow: 0 0 6px rgba(126, 207, 207, 0.4);
display: flex;
align-items: center;
justify-content: center;
z-index: var(--z-card);
}
.scroll-sentinel { height: 1px; width: 100%; flex-basis: 100%; }
.status {
font-family: 'Crimson Text', serif;
font-size: 16px;
font-size: var(--text-md);
font-style: italic;
color: rgba(240, 180, 80, 0.5);
text-align: center;
margin-top: 4rem;
}
.loading-more {
flex-basis: 100%;
margin-top: 1rem;
}
.top-bar {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 2rem;
border-bottom: 1px solid rgba(107, 76, 30, 0.3);
background: #0d0a04;
border-bottom: 1px solid var(--color-border-dim);
background: var(--color-bg);
}
.counter {
font-family: 'Cinzel', serif;
font-size: 13px;
color: rgba(240, 180, 80, 0.6);
font-size: var(--text-base);
color: var(--color-gold-dim);
}
.done-btn {
font-family: 'Cinzel', serif;
font-size: 11px;
font-size: var(--btn-font-md);
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
background: #3d2507;
border: 1px solid #c8861a;
border-radius: 4px;
color: #f0d080;
padding: 8px 24px;
background: var(--color-surface-raised);
border: 1px solid var(--color-bronze);
border-radius: var(--radius-md);
color: var(--color-gold);
padding: var(--btn-padding-md);
cursor: pointer;
transition: background 0.15s;
}
+3 -2
View File
@@ -11,11 +11,12 @@
<style>
.type-badge {
font-family: 'Cinzel', serif;
font-size: 10px;
font-size: var(--text-xs);
font-weight: 700;
letter-spacing: 0.05em;
padding: 3px 8px;
border-radius: 3px;
white-space: nowrap;
border-radius: var(--radius-sm);
cursor: default;
display: inline-block;
}
+5
View File
@@ -1,4 +1,5 @@
let isRefreshing = false;
/** @type {Promise<string> | null} */
let refreshPromise = null;
import { PUBLIC_API_URL } from '$env/static/public';
export const API_URL = PUBLIC_API_URL;
@@ -27,6 +28,10 @@ async function refreshTokens() {
return data.access_token;
}
/**
* @param {string} url
* @param {RequestInit} [options]
*/
export async function apiFetch(url, options = {}) {
const token = localStorage.getItem('token');
+32
View File
@@ -0,0 +1,32 @@
const cache = {};
const FILES = {
cardFlip: '/sounds/card-flip.mp3',
packOpen: '/sounds/pack-open.mp3',
packRip: '/sounds/pack-rip.mp3',
cardPlay: '/sounds/card-play.mp3',
attack: '/sounds/attack.mp3',
defend: '/sounds/defend.mp3',
cardDestroy: '/sounds/card-destroy.mp3',
cardShatter: '/sounds/card-shatter.mp3',
win: '/sounds/win.mp3',
loss: '/sounds/loss.mp3',
buttonClick: '/sounds/button-click.mp3',
};
const VOLUMES = {
cardShatter: 0.1,
attack: 0.1,
defend: 0.1,
};
const DEFAULT_VOLUME = 0.3;
export function play(name) {
if (!FILES[name]) return;
if (!cache[name]) cache[name] = new Audio(FILES[name]);
// cloneNode allows the same sound to overlap itself
const audio = cache[name].cloneNode();
audio.volume = VOLUMES[name] ?? DEFAULT_VOLUME;
audio.play().catch(() => {});
}
+802 -34
View File
@@ -1,21 +1,224 @@
<script>
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { onMount, onDestroy } from 'svelte';
import { apiFetch, WS_URL, API_URL } from '$lib/api.js';
let menuOpen = $state(false);
let socialOpen = $state(false);
let shardsOpen = $state(false);
let notifOpen = $state(false);
let notifications = $state([]);
let notifErrors = $state({});
let notifWs = null;
let reconnectTimer = null;
let notifReconnectDelay = 1000;
let notifReconnecting = $state(false);
let unreadCount = $derived(notifications.filter(n => !n.read).length);
const links = [
{ href: '/', label: 'Booster Packs' },
{ href: '/cards', label: 'Cards' },
{ href: '/decks', label: 'Decks' },
{ href: '/play', label: 'Play' },
{ href: '/trade', label: 'Trade' },
{ href: '/store', label: 'Store' },
{ href: '/', label: 'Booster Packs' },
{ href: '/cards', label: 'Cards' },
{ href: '/decks', label: 'Decks' },
{ href: '/play', label: 'Play' },
];
const socialLinks = [
{ href: '/trade', label: 'Trade' },
{ href: '/users', label: 'Users' },
];
const shardsLinks = [
{ href: '/store', label: 'Store' },
{ href: '/shatter', label: 'Shatter' },
];
function close() { menuOpen = false; }
function closeDropdowns() {
socialOpen = false;
shardsOpen = false;
notifOpen = false;
}
function handleWindowClick(e) {
if (!e.target.closest('.dropdown')) {
closeDropdowns();
}
}
function connectNotificationWS() {
const token = typeof localStorage !== 'undefined' ? localStorage.getItem('token') : null;
if (!token) return;
notifWs = new WebSocket(`${WS_URL}/ws/notifications`);
notifWs.onopen = () => {
notifWs.send(token);
notifReconnecting = false;
notifReconnectDelay = 1000;
};
notifWs.onmessage = (e) => {
const msg = JSON.parse(e.data);
if (msg.type === 'flush') {
notifications = msg.notifications;
} else if (msg.type === 'push') {
notifications = [...notifications, msg.notification];
} else if (msg.type === 'delete') {
notifications = notifications.filter(n => n.id !== msg.notification_id);
}
};
notifWs.onclose = () => {
notifReconnecting = true;
reconnectTimer = setTimeout(() => {
notifReconnectDelay = Math.min(notifReconnectDelay * 2, 30000);
connectNotificationWS();
}, notifReconnectDelay);
};
}
onMount(() => {
connectNotificationWS();
});
onDestroy(() => {
clearTimeout(reconnectTimer);
notifWs?.close();
});
async function markAllRead() {
const unread = notifications.filter(n => !n.read);
await Promise.all(
unread.map(n => apiFetch(`${API_URL}/notifications/${n.id}/read`, { method: 'POST' }))
);
notifications = notifications.map(n => ({ ...n, read: true }));
}
async function deleteNotification(id) {
await apiFetch(`${API_URL}/notifications/${id}`, { method: 'DELETE' });
notifications = notifications.filter(n => n.id !== id);
}
async function markRead(notif) {
if (notif.read) return;
await apiFetch(`${API_URL}/notifications/${notif.id}/read`, { method: 'POST' });
notifications = notifications.map(n => n.id === notif.id ? { ...n, read: true } : n);
}
// Countdown state: map of notif.id → remaining seconds
let countdowns = $state({});
let countdownInterval = null;
function startCountdowns() {
if (countdownInterval) clearInterval(countdownInterval);
countdownInterval = setInterval(() => {
const now = Date.now();
const updated = {};
for (const notif of notifications) {
if (notif.type === 'game_challenge' && notif.expires_at && !notif.payload.status) {
const secs = Math.max(0, Math.floor((new Date(notif.expires_at).getTime() - now) / 1000));
updated[notif.id] = secs;
}
}
countdowns = updated;
}, 1000);
}
$effect(() => {
const hasChallenges = notifications.some(n => n.type === 'game_challenge' && !n.payload.status);
if (hasChallenges) startCountdowns();
else if (countdownInterval) { clearInterval(countdownInterval); countdownInterval = null; }
});
// Load decks when the notification panel opens and there are incoming challenges
$effect(() => {
if (notifOpen && notifications.some(n => n.type === 'game_challenge' && !n.payload.status)) {
loadChallengeDecks();
}
});
// Per-challenge deck selection state
let challengeDeckSelections = $state({}); // notif.id → deck_id
let challengeDecks = $state([]);
let challengeDecksLoaded = $state(false);
async function loadChallengeDecks() {
if (challengeDecksLoaded) return;
const res = await apiFetch(`${API_URL}/decks`);
if (res.ok) {
const data = await res.json();
challengeDecks = data.filter(d => !d.deleted);
if (challengeDecks.length) {
// Set default deck for all pending incoming challenges
const updated = { ...challengeDeckSelections };
for (const n of notifications) {
if (n.type === 'game_challenge' && !n.payload.status && !updated[n.id]) {
updated[n.id] = challengeDecks[0].id;
}
}
challengeDeckSelections = updated;
}
challengeDecksLoaded = true;
}
}
async function acceptChallenge(notif) {
const deckId = challengeDeckSelections[notif.id];
if (!deckId) return;
const res = await apiFetch(`${API_URL}/challenges/${notif.payload.challenge_id}/accept`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ deck_id: deckId }),
});
if (res.ok) {
const data = await res.json();
closeDropdowns();
goto(`/play?game_id=${data.game_id}`);
} else {
notifErrors = { ...notifErrors, [notif.id]: 'Failed to accept challenge.' };
}
}
async function declineChallenge(notif) {
const res = await apiFetch(`${API_URL}/challenges/${notif.payload.challenge_id}/decline`, { method: 'POST' });
if (!res.ok) { notifErrors = { ...notifErrors, [notif.id]: 'Failed to decline.' }; return; }
notifications = notifications.filter(n => n.id !== notif.id);
}
async function acceptFriendRequest(notif) {
const res = await apiFetch(`${API_URL}/friendships/${notif.payload.friendship_id}/accept`, { method: 'POST' });
if (!res.ok) { notifErrors = { ...notifErrors, [notif.id]: 'Failed to accept.' }; return; }
await apiFetch(`${API_URL}/notifications/${notif.id}/read`, { method: 'POST' });
notifications = notifications.filter(n => n.id !== notif.id);
}
async function declineFriendRequest(notif) {
const res = await apiFetch(`${API_URL}/friendships/${notif.payload.friendship_id}/decline`, { method: 'POST' });
if (!res.ok) { notifErrors = { ...notifErrors, [notif.id]: 'Failed to decline.' }; return; }
notifications = notifications.filter(n => n.id !== notif.id);
}
function relativeTime(isoString) {
const diff = Date.now() - new Date(isoString).getTime();
const mins = Math.floor(diff / 60000);
if (mins < 1) return 'just now';
if (mins < 60) return `${mins}m ago`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h ago`;
return `${Math.floor(hrs / 24)}d ago`;
}
function typeLabel(type) {
return { friend_request: 'Friend Request', trade_offer: 'Trade Offer', trade_response: 'Trade Response', game_challenge: 'Game Challenge' }[type] ?? type;
}
</script>
<svelte:window onclick={handleWindowClick} />
<header>
<a href="/" class="logo" onclick={close}>WikiTCG</a>
@@ -23,7 +226,168 @@
{#each links as link}
<a href={link.href} class:active={$page.url.pathname === link.href}>{link.label}</a>
{/each}
<a href="/profile" class:active={$page.url.pathname === '/profile'}>Profile</a>
<!-- Social dropdown: Trade + Users -->
<div class="dropdown">
<button
class="dropdown-trigger"
class:active={$page.url.pathname.startsWith('/trade') || $page.url.pathname.startsWith('/users')}
class:open={socialOpen}
onclick={(e) => { e.stopPropagation(); shardsOpen = false; notifOpen = false; socialOpen = !socialOpen; }}
>
Social <span class="chevron" class:open={socialOpen}>▾</span>
</button>
{#if socialOpen}
<div class="dropdown-menu">
{#each socialLinks as link}
<a href={link.href} onclick={closeDropdowns}>{link.label}</a>
{/each}
</div>
{/if}
</div>
<!-- Shards dropdown: Store + Shatter -->
<div class="dropdown">
<button
class="dropdown-trigger"
class:active={$page.url.pathname === '/store' || $page.url.pathname === '/shatter'}
class:open={shardsOpen}
onclick={(e) => { e.stopPropagation(); socialOpen = false; notifOpen = false; shardsOpen = !shardsOpen; }}
>
Shards <span class="chevron" class:open={shardsOpen}>▾</span>
</button>
{#if shardsOpen}
<div class="dropdown-menu">
{#each shardsLinks as link}
<a href={link.href} onclick={closeDropdowns}>{link.label}</a>
{/each}
</div>
{/if}
</div>
<!-- Profile icon -->
<a href="/profile" class="profile-icon" class:active={$page.url.pathname === '/profile'} aria-label="Profile">
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="12" cy="8" r="4" fill="currentColor"/>
<path d="M4 20c0-4 3.6-7 8-7s8 3 8 7" fill="currentColor"/>
</svg>
</a>
<!-- Notification bell -->
<div class="dropdown">
<button
class="bell-btn"
class:active={notifOpen}
aria-label="Notifications"
onclick={(e) => { e.stopPropagation(); socialOpen = false; shardsOpen = false; notifOpen = !notifOpen; }}
>
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" class="bell-icon">
<path d="M12 2a7 7 0 0 0-7 7v4l-2 3h18l-2-3V9a7 7 0 0 0-7-7z" fill="currentColor"/>
<path d="M10 19a2 2 0 0 0 4 0" fill="currentColor"/>
</svg>
{#if unreadCount > 0}
<span class="badge">{unreadCount > 9 ? '9+' : unreadCount}</span>
{/if}
{#if notifReconnecting}
<span class="reconnecting-dot" title="Reconnecting..."></span>
{/if}
</button>
{#if notifOpen}
<div class="notif-panel">
<div class="notif-header">
<span class="notif-title">Notifications</span>
{#if unreadCount > 0}
<button class="mark-all-btn" onclick={markAllRead}>Mark all read</button>
{/if}
</div>
{#if notifications.length === 0}
<div class="notif-empty">No notifications</div>
{:else}
<ul class="notif-list">
{#each notifications as notif (notif.id)}
<li class="notif-item" class:unread={!notif.read}>
<div class="notif-top">
<span class="notif-type">{typeLabel(notif.type)}</span>
<span class="notif-time">{relativeTime(notif.created_at)}</span>
<button class="dismiss-btn" onclick={() => deleteNotification(notif.id)} aria-label="Dismiss"></button>
</div>
{#if notif.type === 'friend_request'}
<p class="notif-body"><strong>{notif.payload.from_username ?? 'Someone'}</strong> sent you a friend request.</p>
<div class="notif-actions">
<button class="action-btn accept" onclick={() => acceptFriendRequest(notif)}>Accept</button>
<button class="action-btn decline" onclick={() => declineFriendRequest(notif)}>Decline</button>
</div>
{:else if notif.type === 'trade_offer'}
<p class="notif-body">
<strong>{notif.payload.from_username ?? 'Someone'}</strong> sent you a trade offer
({notif.payload.offered_count ?? 0} offered, {notif.payload.requested_count ?? 0} requested).
</p>
<div class="notif-actions">
<button class="action-btn accept" onclick={() => { markRead(notif); closeDropdowns(); goto('/profile'); }}>View Proposals</button>
</div>
{:else if notif.type === 'trade_response'}
<p class="notif-body">
<strong>{notif.payload.from_username ?? 'Someone'}</strong>
{notif.payload.status === 'accepted' ? 'accepted' : 'declined'} your trade offer.
</p>
<div class="notif-actions">
<a href="/trade/proposal/{notif.payload.proposal_id}" class="action-btn accept" onclick={() => { markRead(notif); closeDropdowns(); }}>View Trade</a>
</div>
{:else if notif.type === 'game_challenge'}
{#if notif.payload.status === 'accepted'}
<!-- Response notification sent to challenger -->
<p class="notif-body"><strong>{notif.payload.from_username ?? 'Someone'}</strong> accepted your challenge!</p>
<div class="notif-actions">
<a href="/play?game_id={notif.payload.game_id}" class="action-btn accept" onclick={() => { markRead(notif); closeDropdowns(); }}>Join Game</a>
</div>
{:else}
<!-- Incoming challenge for the challenged player -->
{@const secs = countdowns[notif.id] ?? Math.max(0, Math.floor((new Date(notif.expires_at ?? 0).getTime() - Date.now()) / 1000))}
{@const expired = secs <= 0}
<p class="notif-body">
<strong>{notif.payload.from_username ?? 'Someone'}</strong> challenged you with <em>{notif.payload.deck_name ?? 'a deck'}</em>.
</p>
{#if expired}
<span class="challenge-expired">Expired</span>
{:else}
<span class="challenge-countdown" class:urgent={secs <= 60}>{Math.floor(secs / 60)}:{String(secs % 60).padStart(2, '0')} remaining</span>
<div class="notif-actions">
{#if challengeDecks.length > 0}
<select
class="challenge-deck-select"
value={challengeDeckSelections[notif.id] ?? ''}
onchange={(e) => challengeDeckSelections = { ...challengeDeckSelections, [notif.id]: e.target.value }}
>
{#each challengeDecks as deck}
<option value={deck.id}>{deck.name}</option>
{/each}
</select>
{/if}
<button class="action-btn accept" onclick={() => acceptChallenge(notif)} disabled={!challengeDeckSelections[notif.id]}>Accept</button>
<button class="action-btn decline" onclick={() => declineChallenge(notif)}>Decline</button>
</div>
{/if}
{/if}
{:else}
<p class="notif-body">{notif.payload.message ?? ''}</p>
{/if}
{#if notifErrors[notif.id]}
<p class="notif-error">{notifErrors[notif.id]}</p>
{/if}
</li>
{/each}
</ul>
{/if}
</div>
{/if}
</div>
</nav>
<button class="hamburger" onclick={() => menuOpen = !menuOpen} aria-label="Toggle menu">
@@ -39,56 +403,460 @@
{#each links as link}
<a href={link.href} class:active={$page.url.pathname === link.href} onclick={close}>{link.label}</a>
{/each}
{#each socialLinks as link}
<a href={link.href} class:active={$page.url.pathname === link.href} onclick={close}>{link.label}</a>
{/each}
{#each shardsLinks as link}
<a href={link.href} class:active={$page.url.pathname === link.href} onclick={close}>{link.label}</a>
{/each}
<a href="/profile" class:active={$page.url.pathname === '/profile'} onclick={close}>
Profile{unreadCount > 0 ? ` (${unreadCount})` : ''}
</a>
</nav>
{/if}
<style>
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;700&family=Crimson+Text:ital,wght@0,400;0,600;1,400&display=swap');
header {
position: sticky;
top: 0;
z-index: 100;
z-index: var(--z-header);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 2rem;
padding: 0 var(--space-xl);
height: 56px;
background: #1a1008;
border-bottom: 2px solid #6b4c1e;
background: var(--color-surface);
border-bottom: 2px solid var(--color-border);
}
.logo {
font-family: 'Cinzel', serif;
font-size: 18px;
font-size: var(--text-lg);
font-weight: 700;
color: #f0d080;
color: var(--color-gold);
text-decoration: none;
letter-spacing: 0.05em;
}
nav.desktop {
display: flex;
gap: 2rem;
align-items: center;
gap: var(--space-lg);
}
nav.desktop a {
nav.desktop > a {
font-family: 'Cinzel', serif;
font-size: 13px;
font-size: var(--text-base);
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: rgba(240, 180, 80, 0.8);
color: var(--color-gold-muted);
text-decoration: none;
transition: color 0.15s;
padding: 4px 0;
border-bottom: 1.5px solid transparent;
}
nav.desktop a:hover,
nav.desktop a.active {
color: #f0d080;
border-bottom-color: #f0d080;
nav.desktop > a:hover,
nav.desktop > a.active {
color: var(--color-gold);
border-bottom-color: var(--color-gold);
}
/* Dropdown container */
.dropdown {
position: relative;
display: flex;
align-items: center;
}
.dropdown-trigger {
font-family: 'Cinzel', serif;
font-size: var(--text-base);
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--color-gold-muted);
background: none;
border: none;
border-bottom: 1.5px solid transparent;
cursor: pointer;
padding: 4px 0;
display: flex;
align-items: center;
gap: 3px;
transition: color 0.15s;
width: auto;
}
.dropdown-trigger:hover,
.dropdown-trigger.active {
color: var(--color-gold);
border-bottom-color: var(--color-gold);
}
.chevron {
font-size: var(--text-xs);
line-height: 1;
transition: transform 0.15s;
display: inline-block;
}
.chevron.open {
transform: rotate(180deg);
}
.dropdown-menu {
position: absolute;
top: calc(100% + 8px);
left: 0;
background: var(--color-surface);
border: 2px solid var(--color-border);
border-radius: var(--radius-md);
padding: 0.4rem 0;
min-width: 130px;
z-index: var(--z-dropdown);
}
.dropdown-menu a {
display: block;
padding: 0.55rem 1rem;
font-family: 'Cinzel', serif;
font-size: var(--text-sm);
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--color-gold-muted);
text-decoration: none;
transition: color 0.15s, background 0.15s;
}
.dropdown-menu a:hover {
color: var(--color-gold);
background: var(--color-border-dim);
}
/* Profile icon link */
.profile-icon {
display: flex;
align-items: center;
color: var(--color-gold-muted);
text-decoration: none;
padding: 4px 0;
border-bottom: 1.5px solid transparent;
transition: color 0.15s;
}
.profile-icon:hover,
.profile-icon.active {
color: var(--color-gold);
border-bottom-color: var(--color-gold);
}
.profile-icon svg {
width: 20px;
height: 20px;
}
/* Bell button */
.bell-btn {
position: relative;
display: flex;
align-items: center;
justify-content: center;
background: none;
border: none;
border-bottom: 1.5px solid transparent;
cursor: pointer;
padding: 4px 0;
color: var(--color-gold-muted);
transition: color 0.15s;
width: auto;
}
.bell-btn:hover,
.bell-btn.active {
color: var(--color-gold);
border-bottom-color: var(--color-gold);
}
.bell-icon {
width: 20px;
height: 20px;
}
.badge {
position: absolute;
top: -2px;
right: -6px;
background: var(--color-bronze);
color: var(--color-btn-text);
font-family: 'Cinzel', serif;
font-size: var(--text-xs);
font-weight: 700;
line-height: 1;
min-width: 16px;
height: 16px;
border-radius: var(--radius-full);
display: flex;
align-items: center;
justify-content: center;
padding: 0 3px;
pointer-events: none;
}
/* Notification panel */
.notif-panel {
position: absolute;
top: calc(100% + 8px);
right: 0;
width: min(320px, 90vw);
background: var(--color-surface);
border: 2px solid var(--color-border);
border-radius: var(--radius-md);
z-index: var(--z-dropdown);
overflow: hidden;
}
.notif-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.6rem 1rem;
border-bottom: 1px solid var(--color-border-subtle);
}
.notif-title {
font-family: 'Cinzel', serif;
font-size: var(--text-sm);
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--color-gold-dim);
}
.mark-all-btn {
font-family: 'Cinzel', serif;
font-size: var(--text-xs);
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--color-bronze);
background: none;
border: none;
cursor: pointer;
padding: 0;
transition: color 0.15s;
width: auto;
}
.mark-all-btn:hover {
color: var(--color-gold);
}
.notif-empty {
padding: 1.5rem 1rem;
font-family: 'Cinzel', serif;
font-size: var(--text-sm);
color: var(--color-gold-faint);
text-align: center;
letter-spacing: 0.06em;
}
.notif-list {
list-style: none;
margin: 0;
padding: 0;
max-height: 380px;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: var(--color-border) transparent;
}
.notif-list::-webkit-scrollbar {
width: 4px;
}
.notif-list::-webkit-scrollbar-track {
background: transparent;
}
.notif-list::-webkit-scrollbar-thumb {
background: var(--color-border);
border-radius: var(--radius-md);
}
.notif-item {
padding: 0.7rem 1rem;
border-bottom: 1px solid var(--color-border-dim);
transition: background 0.15s;
}
.notif-item:last-child {
border-bottom: none;
}
.notif-item.unread {
background: var(--color-border-dim);
}
.notif-item:hover {
background: var(--color-border-dim);
}
.notif-top {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.35rem;
}
.notif-type {
font-family: 'Cinzel', serif;
font-size: var(--text-xs);
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--color-bronze);
flex: 1;
}
.notif-time {
font-size: var(--text-sm);
color: var(--color-gold-faint);
font-family: 'Crimson Text', serif;
white-space: nowrap;
}
.dismiss-btn {
background: none;
border: none;
cursor: pointer;
color: var(--color-gold-faint);
font-size: var(--text-sm);
padding: 0;
line-height: 1;
transition: color 0.15s;
width: auto;
flex-shrink: 0;
}
.dismiss-btn:hover {
color: var(--color-error);
}
.notif-body {
font-family: 'Crimson Text', serif;
font-size: var(--text-base);
color: var(--color-gold-muted);
margin: 0 0 0.5rem 0;
line-height: 1.4;
}
.notif-body strong {
color: var(--color-gold);
font-weight: 600;
}
.notif-actions {
display: flex;
gap: 0.5rem;
}
.notif-error {
font-size: var(--text-sm);
color: var(--color-error);
margin: 4px 0 0;
}
.action-btn {
font-family: 'Cinzel', serif;
font-size: var(--btn-font-sm);
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
padding: var(--btn-padding-sm);
border-radius: var(--radius-sm);
cursor: pointer;
border: 1px solid var(--color-border-subtle);
transition: background 0.15s, color 0.15s;
text-decoration: none;
display: inline-flex;
align-items: center;
}
.action-btn.accept {
background: var(--color-surface-raised);
color: var(--color-gold);
}
.action-btn.accept:hover {
background: var(--color-bronze);
color: var(--color-btn-text);
border-color: var(--color-bronze);
}
.action-btn.decline {
background: transparent;
color: var(--color-gold-dim);
}
.action-btn.decline:hover {
background: rgba(180, 40, 40, 0.3);
color: var(--color-gold);
border-color: rgba(180, 40, 40, 0.5);
}
.challenge-countdown {
font-family: 'Cinzel', serif;
font-size: var(--text-xs);
font-weight: 700;
letter-spacing: 0.05em;
color: var(--color-gold-dim);
margin-bottom: 0.4rem;
display: block;
}
.challenge-countdown.urgent { color: var(--color-error); }
.challenge-expired {
font-family: 'Cinzel', serif;
font-size: var(--text-xs);
font-weight: 700;
letter-spacing: 0.05em;
color: rgba(180, 80, 80, 0.5);
text-transform: uppercase;
}
.challenge-deck-select {
background: var(--color-bg);
border: 1px solid var(--color-border-subtle);
border-radius: var(--radius-sm);
color: var(--color-gold);
font-family: 'Crimson Text', serif;
font-size: var(--text-base);
padding: 4px 8px;
cursor: pointer;
flex-shrink: 1;
min-width: 0;
max-width: 140px;
}
.challenge-deck-select:focus { outline: none; border-color: var(--color-bronze); }
.reconnecting-dot {
position: absolute;
bottom: 1px;
right: -3px;
width: 6px;
height: 6px;
background: var(--color-bronze);
border-radius: var(--radius-full);
pointer-events: none;
animation: pulse-dot 1.2s ease-in-out infinite;
}
@keyframes pulse-dot {
0%, 100% { opacity: 0.4; }
50% { opacity: 1; }
}
.hamburger {
@@ -106,7 +874,7 @@
display: block;
width: 22px;
height: 2px;
background: #f0d080;
background: var(--color-gold);
border-radius: 2px;
transition: transform 0.2s, opacity 0.2s;
}
@@ -118,8 +886,8 @@
.mobile-backdrop {
position: fixed;
inset: 56px 0 0 0;
z-index: 99;
background: rgba(0,0,0,0.5);
z-index: var(--z-header);
background: var(--color-overlay);
}
nav.mobile {
@@ -128,30 +896,30 @@
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) {
+162
View File
@@ -0,0 +1,162 @@
<script>
import { page } from '$app/stores';
$: is404 = $page.status === 404;
$: title = is404 ? 'Page Not Found' : 'Something Went Wrong';
$: message = is404 ? null : ($page.error?.message || 'An unexpected error occurred. Please try again.');
</script>
<div class="error-page">
<div class="card-ghost" aria-hidden="true"></div>
<div class="content">
<div class="rune-divider" aria-hidden="true">
<span class="rune"></span>
<span class="line"></span>
<span class="rune"></span>
</div>
<p class="status-code">{$page.status}</p>
<h1 class="title">{title}</h1>
{#if message}<p class="message">{message}</p>{/if}
<a href="/" class="btn-home">Return Home</a>
</div>
</div>
<style>
.error-page {
min-height: calc(100vh - 56px);
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
padding: 40px 24px;
}
/* Ghost card decorative background element */
.card-ghost {
position: absolute;
right: 12%;
top: 50%;
transform: translateY(-50%) rotate(8deg);
width: 180px;
height: 260px;
border: 2px solid #f0d080;
border-radius: 10px;
opacity: 0.04;
pointer-events: none;
animation: ghost-float 6s ease-in-out infinite;
}
@keyframes ghost-float {
0%, 100% { transform: translateY(-50%) rotate(8deg); }
50% { transform: translateY(calc(-50% - 12px)) rotate(8deg); }
}
/* Second ghost card, mirror left */
.card-ghost::before {
content: '';
position: absolute;
left: calc(-100% - 60vw + 180px);
top: 0;
width: 100%;
height: 100%;
border: 2px solid #f0d080;
border-radius: 10px;
transform: rotate(-12deg);
}
.content {
position: relative;
z-index: 1;
max-width: 480px;
width: 100%;
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
gap: 0;
}
.rune-divider {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 32px;
width: 200px;
}
.rune {
font-size: var(--text-xs);
color: rgba(240, 180, 80, 0.3);
flex-shrink: 0;
}
.line {
flex: 1;
height: 1px;
background: rgba(107, 76, 30, 0.5);
}
.status-code {
font-family: 'Cinzel', serif;
font-size: clamp(6rem, 18vw, 9rem);
font-weight: 900;
color: #f0d080;
line-height: 0.9;
margin: 0 0 12px;
letter-spacing: -0.02em;
/* Faint inner glow */
text-shadow:
0 0 60px rgba(200, 134, 26, 0.2),
0 0 120px rgba(200, 134, 26, 0.1);
}
.title {
font-family: 'Cinzel', serif;
font-size: clamp(1rem, 3.5vw, 1.4rem);
font-weight: 700;
color: rgba(240, 180, 80, 0.7);
text-transform: uppercase;
letter-spacing: 0.12em;
margin: 0 0 28px;
}
.message {
font-family: 'Crimson Text', serif;
font-size: var(--text-lg);
font-style: italic;
color: rgba(240, 180, 80, 0.5);
line-height: 1.6;
margin: 0 0 40px;
}
.btn-home {
display: inline-block;
background: #c8861a;
color: #fff8e0;
font-family: 'Cinzel', serif;
font-size: var(--btn-font-lg);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
text-decoration: none;
padding: var(--btn-padding-lg);
border-radius: var(--radius-md);
border: none;
transition: background 0.15s ease, transform 0.15s ease, box-shadow 0.15s ease;
box-shadow: 0 2px 12px rgba(200, 134, 26, 0.3);
}
.btn-home:hover {
background: #e09820;
transform: translateY(-1px);
box-shadow: 0 4px 20px rgba(200, 134, 26, 0.45);
}
.btn-home:active {
transform: translateY(0);
}
</style>
+11 -8
View File
@@ -1,17 +1,20 @@
<script lang="ts">
import '../app.css';
import Header from "$lib/header.svelte";
import favicon from '$lib/assets/favicon.svg';
import { page } from '$app/state';
import '../app.css';
import Header from "$lib/header.svelte";
import favicon from '$lib/assets/favicon.svg';
import { page } from '$app/state';
let { children } = $props();
let { children } = $props();
const showHeader = $derived(!['auth', 'forgot-password'].some(p => page.url.pathname.startsWith(`/${p}`)));
const showHeader = $derived(
!['auth', 'forgot-password'].some(p => page.url.pathname.startsWith(`/${p}`)) &&
!/^\/decks\/.+/.test(page.url.pathname)
);
</script>
<svelte:head>
<title>WikiTCG</title>
<link rel="icon" href={favicon} />
<title>WikiTCG</title>
<link rel="icon" href={favicon} />
</svelte:head>
<div class="layout">
+241 -36
View File
@@ -1,27 +1,28 @@
<script>
<script lang="ts">
import { API_URL, WS_URL } from '$lib/api.js';
import { apiFetch } from '$lib/api.js';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import Card from '$lib/Card.svelte';
import { play } from '$lib/audio.js';
let cards = $state([]);
let cards: any[] = $state([]);
let loading = $state(false);
let boosters = $state(null);
let countdown = $state(null);
let boosters: number | null = $state(null);
let countdown: Date | null = $state(null);
let emailVerified = $state(true);
let countdownDisplay = $state('');
let countdownInterval = null;
let countdownInterval: number | undefined = undefined;
let phase = $state('idle');
let flippedCards = $state([]);
let flippedCards: boolean[] = $state([]);
let fanVisible = $state(false);
let packRef = $state(null);
let overlayPackRef = $state(null);
let packRef: HTMLDivElement | null = $state(null);
let overlayPackRef: HTMLElement | null = $state(null);
onMount(async () => {
onMount(() => {
if (!localStorage.getItem('token')) { goto('/auth'); return; }
await fetchBoosters();
fetchBoosters();
return () => clearInterval(countdownInterval);
});
@@ -37,10 +38,11 @@
function startCountdown() {
clearInterval(countdownInterval);
if (!countdown || boosters >= 5) return;
const cd = countdown;
if (!cd || boosters === null || boosters >= 5) return;
countdownInterval = setInterval(() => {
const nextTick = new Date(countdown.getTime() + 5 * 60 * 60 * 1000);
const diff = nextTick - Date.now();
const nextTick = new Date(cd.getTime() + 5 * 60 * 60 * 1000);
const diff = nextTick.getTime() - Date.now();
if (diff <= 0) { clearInterval(countdownInterval); fetchBoosters(); return; }
const h = Math.floor(diff / 3600000);
const m = Math.floor((diff % 3600000) / 60000);
@@ -49,7 +51,7 @@
}, 1000);
}
function delay(ms) { return new Promise(r => setTimeout(r, ms)); }
function delay(ms: number) { return new Promise(r => setTimeout(r, ms)); }
// Get the screen position of the idle pack so the overlay pack starts there
function getPackRect() {
@@ -71,6 +73,7 @@
await delay(600);
phase = 'ripping';
play('packRip');
await delay(900);
phase = 'dropping';
@@ -86,12 +89,14 @@
if (!res.ok) { phase = 'idle'; loading = false; return; }
cards = await res.json();
flippedCards = new Array(cards.length).fill(false);
boosters -= 1;
if (boosters < 5 && !countdown) { countdown = new Date(); startCountdown(); }
cardActions = cards.map(() => ({ favorited: false, tradeListed: false, shattered: false, shardGain: 0 }));
if (boosters !== null) boosters -= 1;
if (boosters !== null && boosters < 5 && !countdown) { countdown = new Date(); startCountdown(); }
phase = 'fanning';
await delay(50);
fanVisible = true;
play('packOpen');
await delay(800);
phase = 'flipping';
@@ -102,6 +107,7 @@
for (let i of indices) {
await delay(350);
play('cardFlip');
flippedCards = flippedCards.map((v, idx) => idx === i ? true : v);
}
@@ -113,13 +119,51 @@
phase = 'idle';
cards = [];
flippedCards = [];
cardActions = [];
fanVisible = false;
}
const FOIL_RARITIES = new Set(['super_rare', 'epic', 'legendary']);
// Per-card action states for the pack reveal
let cardActions: { favorited: boolean; tradeListed: boolean; shattered: boolean; shardGain: number }[] = $state([]);
async function packToggleFavorite(i: number) {
const card = cards[i];
const res = await apiFetch(`${API_URL}/cards/${card.id}/favorite`, { method: 'POST' });
if (res.ok) {
const data = await res.json();
cards[i] = { ...cards[i], is_favorite: data.is_favorite };
cardActions[i] = { ...cardActions[i], favorited: data.is_favorite };
}
}
async function packToggleTrade(i: number) {
const card = cards[i];
const res = await apiFetch(`${API_URL}/cards/${card.id}/willing-to-trade`, { method: 'POST' });
if (res.ok) {
const data = await res.json();
cards[i] = { ...cards[i], willing_to_trade: data.willing_to_trade };
cardActions[i] = { ...cardActions[i], tradeListed: data.willing_to_trade };
}
}
async function packShatter(i: number) {
const card = cards[i];
const res = await apiFetch(`${API_URL}/shards/shatter`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ card_ids: [card.id] }),
});
if (res.ok) {
const data = await res.json();
play('cardShatter');
cardActions[i] = { ...cardActions[i], shattered: true, shardGain: data.gained };
}
}
// Compute fan positions for each card
function fanStyle(i, total) {
function fanStyle(i: number, total: number) {
const isMobile = window.innerWidth <= 640;
if (isMobile) {
return `--tx: 0px; --ty: 0px;`;
@@ -136,9 +180,9 @@
<h1 class="pack-count">
{#if boosters !== null}{boosters}/5 BOOSTER PACKS REMAINING{/if}
</h1>
{#if boosters !== null && boosters < 5 && countdownDisplay}
<p class="countdown">{countdownDisplay} until next pack</p>
{/if}
<p class="countdown" class:invisible={!(boosters !== null && boosters < 5 && countdownDisplay)}>
{countdownDisplay || ''} until next pack
</p>
</div>
<!-- Idle pack -->
@@ -157,6 +201,11 @@
</div>
</button>
</div>
{:else if boosters !== null && boosters === 0 && phase === 'idle'}
<div class="no-packs">
<p class="no-packs-msg">No packs remaining</p>
<a href="/store" class="btn-buy-packs">Buy more packs</a>
</div>
{/if}
{#if phase !== 'idle'}
@@ -180,16 +229,44 @@
{#each cards as card, i}
{@const flipped = flippedCards[i]}
{@const foil = FOIL_RARITIES.has(card.card_rarity)}
{@const action = cardActions[i] ?? {}}
<div
class="fan-card"
class:fan-visible={fanVisible}
class:foil-reveal={flipped && foil}
style="--i: {i};"
>
<div class="card-flipper" class:flipped>
<div class="card-face back"><div class="card-back-face"></div></div>
<div class="card-face front"><Card {card} /></div>
<div class="card-shatter-wrap" class:shattered={action.shattered}>
<div class="card-flipper" class:flipped>
{#if !action.shattered}
<div class="card-face back"><div class="card-back-face"></div></div>
{/if}
<div class="card-face front"><Card {card} /></div>
</div>
</div>
<div class="pack-card-actions" class:actions-visible={phase === 'done' && flipped}>
{#if action.shattered}
<span class="shard-gained">+{action.shardGain}</span>
{:else}
<button
class="pack-action-btn fav"
class:active={action.favorited}
onclick={() => packToggleFavorite(i)}
title="Favorite"
>★</button>
<button
class="pack-action-btn trade"
class:active={action.tradeListed}
onclick={() => packToggleTrade(i)}
title="Mark for Trade"
>⇄</button>
<button
class="pack-action-btn shatter"
onclick={() => packShatter(i)}
title="Shatter for shards"
>◈</button>
{/if}
</div>
</div>
{/each}
</div>
@@ -204,12 +281,10 @@
</main>
<style>
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;700;900&family=Crimson+Text:ital,wght@0,400;0,600;1,400&display=swap');
main {
height: 100vh;
overflow: hidden;
background: #0d0a04;
background: var(--color-bg);
padding: 2rem;
display: flex;
flex-direction: column;
@@ -225,28 +300,67 @@
font-family: 'Cinzel', serif;
font-size: clamp(16px, 3vw, 26px);
font-weight: 900;
color: #f0d080;
color: var(--color-gold);
letter-spacing: 0.1em;
margin: 0 0 0.4rem;
}
.countdown {
font-family: 'Crimson Text', serif;
font-size: 15px;
font-size: var(--text-md);
font-style: italic;
color: rgba(240, 180, 80, 0.6);
color: var(--color-gold-dim);
margin: 0;
}
.invisible {
visibility: hidden;
}
.verify-notice {
font-family: 'Crimson Text', serif;
font-size: 18px;
font-size: var(--text-lg);
font-style: italic;
color: rgba(240, 180, 80, 0.55);
color: var(--color-gold-dim);
text-align: center;
margin-top: 4rem;
}
.no-packs {
display: flex;
flex-direction: column;
align-items: center;
gap: 1.2rem;
margin-top: 4rem;
}
.no-packs-msg {
font-family: 'Crimson Text', serif;
font-size: var(--text-lg);
font-style: italic;
color: var(--color-gold-dim);
margin: 0;
}
.btn-buy-packs {
font-family: 'Cinzel', serif;
font-size: var(--btn-font-lg);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--color-btn-text);
background: var(--color-bronze);
border: none;
border-radius: var(--radius-md);
padding: var(--btn-padding-lg);
text-decoration: none;
transition: background 0.15s ease;
}
.btn-buy-packs:hover {
background: var(--color-bronze-hover);
}
.pack-wrap {
display: flex;
justify-content: center;
@@ -340,7 +454,7 @@
position: fixed;
overflow: hidden;
inset: 0;
z-index: 200;
z-index: var(--z-dropdown);
background: rgba(0,0,0,0);
transition: background 0.6s ease;
display: flex;
@@ -432,6 +546,9 @@
transform: translateY(80vh);
transition: opacity 0.5s ease, transform 0.7s cubic-bezier(0.2, 0.8, 0.3, 1);
transition-delay: calc(var(--i) * 0.1s);
display: flex;
flex-direction: column;
align-items: center;
}
.fan-card.fan-visible {
@@ -463,6 +580,18 @@
transform: rotateY(180deg);
}
/* Wrapper holds the animation — kept separate from card-flipper so that
CSS filter doesn't conflict with transform-style: preserve-3d */
.card-shatter-wrap.shattered {
animation: shatter-fade 0.7s ease-out forwards;
}
@keyframes shatter-fade {
0% { opacity: 1; filter: brightness(1); transform: scale(1); }
30% { opacity: 1; filter: brightness(2.5) saturate(3) hue-rotate(160deg); transform: scale(1.04); }
100% { opacity: 0; filter: brightness(1); transform: scale(0.97); }
}
.card-face {
position: absolute;
inset: 0;
@@ -501,12 +630,13 @@
transform: translateX(-50%);
padding: 10px 32px;
background: rgba(60,30,5,0.85);
color: #f0d080;
border: 1.5px solid #c8861a;
border-radius: 6px;
color: var(--color-gold);
border: 1.5px solid var(--color-bronze);
border-radius: var(--radius-md);
font-family: 'Cinzel', serif;
font-size: 13px;
font-size: var(--text-base);
font-weight: 700;
text-transform: uppercase;
cursor: pointer;
letter-spacing: 0.08em;
transition: background 0.15s;
@@ -517,6 +647,81 @@
background: rgba(100,60,10,0.9);
}
/* ── Pack card actions ── */
.pack-card-actions {
display: flex;
gap: 8px;
justify-content: center;
align-items: center;
margin-top: 8px;
height: 44px;
visibility: hidden;
}
.pack-card-actions.actions-visible {
visibility: visible;
}
.pack-action-btn {
font-family: 'Cinzel', serif;
font-size: var(--text-lg);
font-weight: 700;
line-height: 1;
width: 44px;
height: 44px;
border-radius: var(--radius-full);
border: 1.5px solid;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s;
}
.pack-action-btn.fav {
background: rgba(30, 20, 0, 0.7);
border-color: rgba(200, 160, 0, 0.5);
color: rgba(240, 200, 0, 0.6);
}
.pack-action-btn.fav:hover, .pack-action-btn.fav.active {
background: rgba(60, 45, 0, 0.9);
border-color: #c8a000;
color: #f0c800;
}
.pack-action-btn.trade {
background: rgba(0, 25, 25, 0.7);
border-color: rgba(0, 150, 150, 0.5);
color: rgba(0, 190, 190, 0.6);
}
.pack-action-btn.trade:hover, .pack-action-btn.trade.active {
background: rgba(0, 50, 50, 0.9);
border-color: #00a0a0;
color: var(--color-cyan);
}
.pack-action-btn.shatter {
background: rgba(0, 20, 30, 0.7);
border-color: rgba(100, 200, 200, 0.4);
color: rgba(126, 207, 207, 0.6);
}
.pack-action-btn.shatter:hover {
background: rgba(0, 40, 50, 0.9);
border-color: var(--color-cyan);
color: var(--color-cyan);
}
.shard-gained {
font-family: 'Cinzel', serif;
font-size: var(--text-md);
font-weight: 700;
color: var(--color-cyan);
padding: 8px 16px;
background: rgba(0, 40, 50, 0.8);
border: 1.5px solid rgba(126, 207, 207, 0.5);
border-radius: 22px;
}
/* ── Mobile ── */
@media (max-width: 640px) {
.fan-wrap {
+116 -40
View File
@@ -1,4 +1,4 @@
<script>
<script lang="ts">
import { API_URL, WS_URL } from '$lib/api.js';
import { goto } from '$app/navigation';
@@ -12,6 +12,7 @@
function validate() {
if (!username.trim()) return 'Username is required';
if (username.length < 2) return 'Username must be at least 2 characters';
if (username.length > 16) return 'Username must be 16 characters or fewer';
if (mode === 'register') {
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return 'Please enter a valid email';
@@ -40,20 +41,18 @@
return;
}
const form = new FormData();
form.append('username', username);
form.append('password', password);
const res = await fetch(`${API_URL}/login`, {
method: 'POST',
body: form,
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ username, password }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.detail);
localStorage.setItem('token', data.access_token);
localStorage.setItem('refresh_token', data.refresh_token);
goto('/');
} catch (e) {
error = e.message;
} catch (e: any) {
error = e.message || 'Connection failed — check your network and try again';
} finally {
loading = false;
}
@@ -61,7 +60,15 @@
</script>
<main>
<div class="card-ghost" aria-hidden="true"></div>
<div class="card">
<div class="wordmark">WikiTCG</div>
<div class="rune-divider" aria-hidden="true">
<span class="line"></span>
<span class="rune"></span>
<span class="line"></span>
</div>
<h1>{mode === 'login' ? 'Sign In' : 'Register'}</h1>
<div class="fields">
@@ -103,33 +110,99 @@
</main>
<style>
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;700&family=Crimson+Text:ital,wght@0,400;0,600;1,400&display=swap');
main {
min-height: 100vh;
background: #0d0a04;
background: var(--color-bg);
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
}
/* Faint ghost card silhouettes in the background */
.card-ghost {
position: absolute;
right: 12%;
top: 50%;
transform: translateY(-50%) rotate(8deg);
width: 180px;
height: 260px;
border: 2px solid var(--color-gold);
border-radius: var(--radius-lg);
opacity: 0.04;
pointer-events: none;
animation: ghost-float 6s ease-in-out infinite;
}
.card-ghost::before {
content: '';
position: absolute;
left: calc(-100% - 60vw + 180px);
top: 0;
width: 100%;
height: 100%;
border: 2px solid var(--color-gold);
border-radius: var(--radius-lg);
transform: rotate(-12deg);
}
@keyframes ghost-float {
0%, 100% { transform: translateY(-50%) rotate(8deg); }
50% { transform: translateY(calc(-50% - 12px)) rotate(8deg); }
}
.card {
position: relative;
z-index: var(--z-base);
width: 340px;
background: #2e1c05;
border: 2px solid #6b4c1e;
border-radius: 12px;
background: var(--color-surface);
border: 2px solid var(--color-bronze);
border-radius: var(--radius-xl);
padding: 2rem;
display: flex;
flex-direction: column;
gap: 1.2rem;
}
.wordmark {
font-family: 'Cinzel', serif;
font-size: var(--text-xl);
font-weight: 900;
color: var(--color-gold);
text-align: center;
letter-spacing: 0.12em;
text-transform: uppercase;
margin-bottom: -0.4rem;
}
.rune-divider {
display: flex;
align-items: center;
gap: 10px;
}
.rune-divider .line {
flex: 1;
height: 1px;
background: var(--color-border-subtle);
}
.rune-divider .rune {
font-size: var(--text-xs);
color: rgba(240, 180, 80, 0.35);
flex-shrink: 0;
}
h1 {
font-family: 'Cinzel', serif;
font-size: 20px;
color: #f0d080;
font-size: var(--text-md);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--color-gold-dim);
text-align: center;
margin: 0;
margin: -0.4rem 0 0;
}
.fields {
@@ -141,39 +214,42 @@
input {
width: 100%;
padding: 9px 12px;
background: #1a1008;
border: 1.5px solid #8b6420;
border-radius: 6px;
color: #f0d080;
background: var(--color-surface);
border: 1.5px solid var(--color-border);
border-radius: var(--radius-md);
color: var(--color-gold);
font-family: 'Crimson Text', serif;
font-size: 15px;
font-size: var(--text-md);
box-sizing: border-box;
outline: none;
}
input::placeholder {
color: rgba(240, 180, 80, 0.4);
color: var(--color-gold-faint);
}
input:focus {
border-color: #f0d080;
border-color: var(--color-bronze);
}
button {
width: 100%;
padding: 10px;
background: #6b4c1e;
color: #f0d080;
border: 1.5px solid #8b6420;
border-radius: 6px;
padding: var(--btn-padding-lg);
background: var(--color-bronze);
color: var(--color-btn-text);
border: none;
border-radius: var(--radius-md);
font-family: 'Cinzel', serif;
font-size: 13px;
font-size: var(--btn-font-lg);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
cursor: pointer;
transition: background 0.15s;
}
button:hover:not(:disabled) {
background: #8b6420;
background: var(--color-bronze-hover);
}
button:disabled {
@@ -182,44 +258,44 @@
}
.error {
color: #c84040;
color: var(--color-error);
font-family: 'Crimson Text', serif;
font-size: 13px;
font-size: var(--text-base);
text-align: center;
margin: 0;
}
.toggle {
font-family: 'Crimson Text', serif;
font-size: 13px;
color: rgba(240, 180, 80, 0.7);
font-size: var(--text-base);
color: var(--color-gold-dim);
text-align: center;
margin: 0;
}
.link {
all: unset;
color: #f0d080;
color: var(--color-gold);
cursor: pointer;
text-decoration: underline;
width: auto;
padding: 0;
font-size: 13px;
font-size: var(--text-base);
font-family: 'Crimson Text', serif;
}
.link:hover {
color: #f0d080;
color: var(--color-gold);
}
.forgot-link {
font-family: 'Crimson Text', serif;
font-size: 13px;
color: rgba(245, 208, 96, 0.45);
font-size: var(--text-base);
color: var(--color-gold-faint);
text-align: center;
text-decoration: none;
transition: color 0.15s;
}
.forgot-link:hover { color: rgba(245, 208, 96, 0.8); }
.forgot-link:hover { color: var(--color-gold-muted); }
</style>
+524 -128
View File
@@ -1,11 +1,10 @@
<script>
<script lang="ts">
import { API_URL, WS_URL } from '$lib/api.js';
import { apiFetch } from '$lib/api.js';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import Card from '$lib/Card.svelte';
let allCards = $state([]);
let loading = $state(true);
const token = () => localStorage.getItem('token');
@@ -21,53 +20,106 @@
let filtersOpen = $state(false);
const RARITY_ORDER = Object.fromEntries(RARITIES.map((r, i) => [r, i]));
function label(str) {
function label(str: string) {
return str.charAt(0).toUpperCase() + str.slice(1).replace(/_/g, ' ');
}
let sortAsc = $state(true);
let costMin = $state(1);
let costMax = $state(10);
let searchQuery = $state('');
let favoritesOnly = $state(false);
let willingToTradeOnly = $state(false);
let filtered = $derived.by(() => {
const q = searchQuery.trim().toLowerCase();
let result = allCards.filter(c =>
selectedRarities.has(c.card_rarity) &&
selectedTypes.has(c.card_type) &&
c.cost >= costMin &&
c.cost <= costMax &&
(!q || c.name.toLowerCase().includes(q))
);
// Selection mode for bulk actions
let selectionMode = $state(false);
let selectedIds = $state(new Set<string>());
let bulkLoading = $state(false);
result = result.slice().sort((a, b) => {
let cmp = 0;
if (sortBy === 'name') cmp = a.name.localeCompare(b.name);
else if (sortBy === 'cost') cmp = b.cost - a.cost || a.name.localeCompare(b.name);
else if (sortBy === 'attack') cmp = b.attack - a.attack || a.name.localeCompare(b.name);
else if (sortBy === 'defense') cmp = b.defense - a.defense || a.name.localeCompare(b.name);
else if (sortBy === 'rarity') cmp = RARITY_ORDER[b.card_rarity] - RARITY_ORDER[a.card_rarity] || a.name.localeCompare(b.name);
return sortAsc ? cmp : -cmp;
// Server-side fetch state
const PAGE_SIZE = 40;
let cards: any[] = $state([]);
let total = $state(0);
let loadingMore = $state(false);
let hasMore = $derived(cards.length < total);
// Must be $state so the IntersectionObserver $effect re-runs when the element is bound
let sentinel: HTMLElement | undefined = $state();
// <main> has overflow-y: auto — it is the scroll container, not the viewport.
// The IntersectionObserver root must be the actual scroll container.
let scrollContainer: HTMLElement | undefined = $state();
// For non-name sorts the "natural" first click should show the highest/newest/rarest values,
// which is descending. This matches the old client-side behaviour (b.cost - a.cost etc.).
function sortDir() {
return sortBy === 'name'
? (sortAsc ? 'asc' : 'desc')
: (sortAsc ? 'desc' : 'asc');
}
async function fetchCards(reset = false) {
if (reset) { cards = []; total = 0; }
if (loadingMore) return;
loadingMore = true;
const params = new URLSearchParams({
skip: String(reset ? 0 : cards.length),
limit: String(PAGE_SIZE),
search: searchQuery.trim(),
cost_min: String(costMin),
cost_max: String(costMax),
favorites_only: String(favoritesOnly),
wtt_only: String(willingToTradeOnly),
sort_by: sortBy,
sort_dir: sortDir(),
});
for (const r of selectedRarities) params.append('rarities', r);
for (const t of selectedTypes) params.append('types', t);
const res = await apiFetch(`${API_URL}/cards?${params}`);
if (res.status === 401) { goto('/auth'); loadingMore = false; return; }
const data = await res.json();
cards = reset ? data.cards : [...cards, ...data.cards];
total = data.total;
loadingMore = false;
}
return result;
// Debounced refetch when any filter/sort state changes.
// Skip the initial run on mount — onMount handles the first fetch directly so
// there's no double-fetch (which would cause a visible flash as cards reset).
let mounted = false;
let fetchTimer: number;
$effect(() => {
searchQuery; sortBy; sortAsc; selectedRarities; selectedTypes;
costMin; costMax; favoritesOnly; willingToTradeOnly;
if (!mounted) return;
clearTimeout(fetchTimer);
fetchTimer = setTimeout(() => fetchCards(true), 300);
});
function toggleSort(val) {
// IntersectionObserver: load next page when sentinel scrolls into view.
// Both sentinel and scrollContainer are $state so this effect re-runs once bound.
$effect(() => {
if (!sentinel || !scrollContainer) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasMore && !loadingMore) fetchCards(false);
},
{ root: scrollContainer, rootMargin: '200px' }
);
observer.observe(sentinel);
return () => observer.disconnect();
});
function toggleSort(val: string) {
if (sortBy === val) sortAsc = !sortAsc;
else { sortBy = val; sortAsc = true; }
}
function toggleRarity(r) {
function toggleRarity(r: string) {
const s = new Set(selectedRarities);
s.has(r) ? s.delete(r) : s.add(r);
selectedRarities = s;
}
function toggleType(t) {
function toggleType(t: string) {
const s = new Set(selectedTypes);
s.has(t) ? s.delete(t) : s.add(t);
selectedTypes = s;
@@ -86,35 +138,65 @@
onMount(async () => {
if (!token()) { goto('/auth'); return; }
const res = await apiFetch(`${API_URL}/cards`);
if (res.status === 401) { goto('/auth'); return; }
allCards = await res.json();
await fetchCards(true);
loading = false;
mounted = true;
});
let selectedCard = $state(null);
let refreshStatus = $state(null);
let selectedCard: any = $state(null);
let refreshStatus: { can_refresh: boolean; next_refresh_at: string | null } | null = $state(null);
let countdownDisplay = $state('');
let countdownInterval = null;
let countdownInterval: number | undefined = undefined;
let reportLoading = $state(false);
let refreshLoading = $state(false);
let actionMessage = $state('');
let popupEl: HTMLElement;
let previousFocus: Element | null = null;
// Move focus to first focusable element when modal opens
$effect(() => {
if (selectedCard && popupEl) {
const first = popupEl.querySelector<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
first?.focus();
}
});
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape' && selectedCard) closeCard();
}
function trapFocus(e: KeyboardEvent) {
if (e.key !== 'Tab') return;
const focusable = [...popupEl.querySelectorAll<HTMLElement>(
'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
)];
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey) {
if (document.activeElement === first) { e.preventDefault(); last.focus(); }
} else {
if (document.activeElement === last) { e.preventDefault(); first.focus(); }
}
}
async function fetchRefreshStatus() {
const res = await apiFetch(`${API_URL}/profile/refresh-status`);
refreshStatus = await res.json();
if (!refreshStatus.can_refresh && refreshStatus.next_refresh_at) {
if (refreshStatus && !refreshStatus.can_refresh && refreshStatus.next_refresh_at) {
startRefreshCountdown(new Date(refreshStatus.next_refresh_at));
}
}
function startRefreshCountdown(nextRefreshAt) {
function startRefreshCountdown(nextRefreshAt: Date) {
clearInterval(countdownInterval);
countdownInterval = setInterval(() => {
const diff = nextRefreshAt - Date.now();
const diff = nextRefreshAt.getTime() - Date.now();
if (diff <= 0) {
clearInterval(countdownInterval);
refreshStatus = { can_refresh: true, next_refresh_at: null };
refreshStatus = { can_refresh: true, next_refresh_at: null } as typeof refreshStatus;
countdownDisplay = '';
return;
}
@@ -125,7 +207,54 @@
}, 1000);
}
function openCard(card) {
function toggleSelectionMode() {
selectionMode = !selectionMode;
if (!selectionMode) selectedIds = new Set();
}
function toggleCardSelection(id: string) {
const s = new Set(selectedIds);
s.has(id) ? s.delete(id) : s.add(id);
selectedIds = s;
}
function selectAllLoaded() {
if (cards.every(c => selectedIds.has(c.id))) {
selectedIds = new Set();
} else {
selectedIds = new Set(cards.map((c: any) => c.id));
}
}
async function bulkFavorite(setTo: boolean) {
bulkLoading = true;
const targets = cards.filter(c => selectedIds.has(c.id) && c.is_favorite !== setTo);
await Promise.all(targets.map(async (c: any) => {
const res = await apiFetch(`${API_URL}/cards/${c.id}/favorite`, { method: 'POST' });
if (res.ok) {
const data = await res.json();
cards = cards.map(x => x.id === c.id ? { ...x, is_favorite: data.is_favorite } : x);
}
}));
bulkLoading = false;
}
async function bulkWTT(setTo: boolean) {
bulkLoading = true;
const targets = cards.filter(c => selectedIds.has(c.id) && c.willing_to_trade !== setTo);
await Promise.all(targets.map(async (c: any) => {
const res = await apiFetch(`${API_URL}/cards/${c.id}/willing-to-trade`, { method: 'POST' });
if (res.ok) {
const data = await res.json();
cards = cards.map(x => x.id === c.id ? { ...x, willing_to_trade: data.willing_to_trade } : x);
}
}));
bulkLoading = false;
}
function openCard(card: any) {
if (selectionMode) return;
previousFocus = document.activeElement;
selectedCard = card;
actionMessage = '';
fetchRefreshStatus();
@@ -136,6 +265,8 @@
clearInterval(countdownInterval);
countdownDisplay = '';
actionMessage = '';
(previousFocus as HTMLElement)?.focus();
previousFocus = null;
}
async function reportCard() {
@@ -146,13 +277,31 @@
reportLoading = false;
if (res.ok) {
selectedCard = { ...selectedCard, reported: true };
allCards = allCards.map(c => c.id === selectedCard.id ? { ...c, reported: true } : c);
cards = cards.map(c => c.id === selectedCard.id ? { ...c, reported: true } : c);
actionMessage = 'Card reported. Thank you!';
} else {
actionMessage = 'Failed to report card.';
}
}
async function toggleFavorite() {
const res = await apiFetch(`${API_URL}/cards/${selectedCard.id}/favorite`, { method: 'POST' });
if (res.ok) {
const data = await res.json();
selectedCard = { ...selectedCard, is_favorite: data.is_favorite };
cards = cards.map(c => c.id === selectedCard.id ? { ...c, is_favorite: data.is_favorite } : c);
}
}
async function toggleWillingToTrade() {
const res = await apiFetch(`${API_URL}/cards/${selectedCard.id}/willing-to-trade`, { method: 'POST' });
if (res.ok) {
const data = await res.json();
selectedCard = { ...selectedCard, willing_to_trade: data.willing_to_trade };
cards = cards.map(c => c.id === selectedCard.id ? { ...c, willing_to_trade: data.willing_to_trade } : c);
}
}
async function refreshCard() {
refreshLoading = true;
actionMessage = '';
@@ -162,10 +311,9 @@
refreshLoading = false;
if (res.ok) {
const updated = await res.json();
// Update card in allCards list
allCards = allCards.map(c => c.id === updated.id ? updated : c);
cards = cards.map(c => c.id === updated.id ? updated : c);
selectedCard = updated;
refreshStatus = { can_refresh: false, next_refresh_at: null };
refreshStatus = { can_refresh: false, next_refresh_at: null } as typeof refreshStatus;
await fetchRefreshStatus();
actionMessage = 'Card refreshed!';
} else {
@@ -175,11 +323,13 @@
}
</script>
<main>
<svelte:window onkeydown={handleKeydown} />
<main bind:this={scrollContainer}>
<div class="toolbar">
<div class="sort-row">
<span class="toolbar-label">Sort by</span>
{#each [['name','Name'],['cost','Cost'],['attack','Attack'],['defense','Defense'],['rarity','Rarity']] as [val, lbl]}
{#each [['name','Name'],['cost','Cost'],['attack','Attack'],['defense','Defense'],['rarity','Rarity'],['date_generated','Generated'],['date_received','Received']] as [val, lbl]}
<button
class="sort-btn"
class:active={sortBy === val}
@@ -199,12 +349,32 @@
bind:value={searchQuery}
/>
<button class="filter-toggle" onclick={() => filtersOpen = !filtersOpen}>
{filtersOpen ? 'Hide filters' : 'Filter'}
{#if selectedRarities.size < RARITIES.length || selectedTypes.size < TYPES.length || costMin > 1 || costMax < 10}
<span class="filter-dot"></span>
{/if}
</button>
<div class="filter-actions">
<button
class="filter-toggle"
class:active={favoritesOnly}
onclick={() => favoritesOnly = !favoritesOnly}
title="Show favorites only"
>★</button>
<button
class="filter-toggle"
class:active={willingToTradeOnly}
onclick={() => willingToTradeOnly = !willingToTradeOnly}
title="Show willing to trade only"
>⇄</button>
<button class="filter-toggle" class:active={filtersOpen} onclick={() => filtersOpen = !filtersOpen}>
Filters
{#if selectedRarities.size < RARITIES.length || selectedTypes.size < TYPES.length || costMin > 1 || costMax < 10}
<span class="filter-dot"></span>
{/if}
</button>
<button class="filter-toggle" class:active={selectionMode} onclick={toggleSelectionMode}>
{selectionMode ? 'Done' : 'Select'}
</button>
</div>
</div>
{#if filtersOpen}
@@ -263,21 +433,53 @@
{#if loading}
<p class="status">Loading your cards...</p>
{:else if filtered.length === 0}
{:else if !loadingMore && cards.length === 0}
<p class="status">No cards match your filters.</p>
{:else}
<p class="card-count">{filtered.length} card{filtered.length === 1 ? '' : 's'}</p>
<div class="grid">
{#each filtered as card (card.id)}
<button class="card-btn" onclick={() => openCard(card)}>
<Card {card} />
</button>
<p class="card-count">{total} card{total === 1 ? '' : 's'}</p>
<div class="grid" style={selectionMode ? 'padding-bottom: 100px' : ''}>
{#each cards as card (card.id)}
<div class="card-item">
<button
class="card-btn"
class:selected={selectionMode && selectedIds.has(card.id)}
onclick={() => selectionMode ? toggleCardSelection(card.id) : openCard(card)}
>
<Card {card} noHover={selectionMode} />
{#if selectionMode && selectedIds.has(card.id)}
<div class="selected-badge"></div>
{/if}
</button>
{#if sortBy === 'date_received' && card.received_at}
<span class="received-label" aria-label="Date received">Received {new Date(card.received_at).toLocaleDateString('en-GB', { year: 'numeric', month: '2-digit', day: '2-digit' })}</span>
{/if}
</div>
{/each}
</div>
<div bind:this={sentinel} class="scroll-sentinel"></div>
{#if loadingMore}<p class="status">Loading more...</p>{/if}
{/if}
{#if selectionMode}
<div class="bulk-bar">
<span class="bulk-count">{selectedIds.size} card{selectedIds.size === 1 ? '' : 's'} selected</span>
<div class="bulk-actions">
<button class="bulk-btn" disabled={bulkLoading} onclick={() => bulkFavorite(true)}>★ Favorite</button>
<button class="bulk-btn" disabled={bulkLoading} onclick={() => bulkFavorite(false)}>★ Unfavorite</button>
<button class="bulk-btn" disabled={bulkLoading} onclick={() => bulkWTT(true)}>⇄ Mark WTT</button>
<button class="bulk-btn" disabled={bulkLoading} onclick={() => bulkWTT(false)}>⇄ Unmark WTT</button>
</div>
<div class="bulk-secondary">
<button class="bulk-select-all" onclick={selectAllLoaded}>
{cards.every(c => selectedIds.has(c.id)) ? 'Deselect All' : 'Select All'}
</button>
</div>
</div>
{/if}
{#if selectedCard}
<div class="backdrop" onclick={closeCard}>
<div class="card-popup" onclick={(e) => e.stopPropagation()}>
<div class="card-popup" onclick={(e) => e.stopPropagation()} onkeydown={trapFocus} bind:this={popupEl} role="dialog" aria-modal="true" aria-label="Card details">
<Card card={selectedCard} />
<div class="popup-actions">
<div class="action-col">
@@ -289,6 +491,24 @@
{selectedCard.reported ? 'Already Reported' : reportLoading ? 'Reporting...' : 'Report Error'}
</button>
</div>
<div class="action-col">
<button
class="fav-btn"
class:fav-active={selectedCard.is_favorite}
onclick={toggleFavorite}
>
{selectedCard.is_favorite ? '★ Favorited' : '★ Favorite'}
</button>
</div>
<div class="action-col">
<button
class="wtt-btn"
class:wtt-active={selectedCard.willing_to_trade}
onclick={toggleWillingToTrade}
>
{selectedCard.willing_to_trade ? '⇄ Listed for Trade' : '⇄ Mark for Trade'}
</button>
</div>
<div class="action-col">
<button
class="refresh-btn"
@@ -310,12 +530,10 @@
</main>
<style>
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;700&family=Crimson+Text:ital,wght@0,400;0,600;1,400&display=swap');
main {
height: 100vh;
overflow-y: auto;
background: #0d0a04;
background: var(--color-bg);
padding: 0 2rem 2rem 2rem;
}
@@ -323,9 +541,9 @@
position: sticky;
top: 0px;
z-index: 50;
background: #0d0a04;
background: var(--color-bg);
padding-bottom: 1rem;
border-bottom: 1px solid rgba(107, 76, 30, 0.3);
border-bottom: 1px solid var(--color-border-dim);
margin-bottom: 2rem;
padding-top: 32px;
}
@@ -339,23 +557,23 @@
.search-input {
font-family: 'Crimson Text', serif;
font-size: 15px;
background: rgba(255,255,255,0.04);
border: 1px solid rgba(107, 76, 30, 0.4);
border-radius: 4px;
color: #f0d080;
font-size: var(--text-md);
background: var(--color-surface);
border: 1.5px solid var(--color-bronze);
border-radius: var(--radius-md);
color: var(--color-gold);
padding: 5px 10px;
outline: none;
width: 220px;
margin-left: auto;
transition: border-color 0.15s;
}
.search-input:focus { border-color: #c8861a; }
.search-input:focus { border-color: var(--color-gold); }
.search-input::placeholder { color: rgba(240, 180, 80, 0.3); }
.toolbar-label {
font-family: 'Cinzel', serif;
font-size: 11px;
font-size: var(--text-sm);
color: rgba(240, 180, 80, 0.5);
letter-spacing: 0.08em;
text-transform: uppercase;
@@ -364,50 +582,61 @@
.sort-btn {
font-family: 'Cinzel', serif;
font-size: 11px;
font-size: var(--btn-font-sm);
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
background: none;
border: 1px solid rgba(107, 76, 30, 0.4);
border-radius: 4px;
color: rgba(240, 180, 80, 0.6);
padding: 4px 10px;
border: 1px solid var(--color-border-subtle);
border-radius: var(--radius-sm);
color: var(--color-gold-dim);
padding: var(--btn-padding-sm);
cursor: pointer;
transition: all 0.15s;
}
.sort-btn:hover {
border-color: #c8861a;
color: #f0d080;
border-color: var(--color-bronze);
color: var(--color-gold);
}
.sort-btn.active {
background: #3d2507;
border-color: #c8861a;
color: #f0d080;
background: var(--color-surface-raised);
border-color: var(--color-bronze);
color: var(--color-gold);
}
.filter-toggle {
font-family: 'Cinzel', serif;
font-size: 11px;
font-size: var(--btn-font-sm);
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
background: none;
border: 1px solid rgba(107, 76, 30, 0.4);
border-radius: 4px;
color: rgba(240, 180, 80, 0.6);
padding: 4px 10px;
border: 1px solid var(--color-border-subtle);
border-radius: var(--radius-sm);
color: var(--color-gold-dim);
padding: var(--btn-padding-sm);
cursor: pointer;
margin-left: 0.5rem;
position: relative;
transition: all 0.15s;
}
.filter-actions {
display: flex;
gap: 0.5rem;
margin-left: 0.75rem;
}
.filter-toggle:hover {
border-color: #c8861a;
color: #f0d080;
border-color: var(--color-bronze);
color: var(--color-gold);
}
.filter-toggle.active {
background: var(--color-surface-raised);
border-color: var(--color-bronze);
color: var(--color-gold);
}
.filter-dot {
@@ -416,8 +645,8 @@
right: -3px;
width: 7px;
height: 7px;
border-radius: 50%;
background: #c8861a;
border-radius: var(--radius-full);
background: var(--color-bronze);
}
.filters {
@@ -441,7 +670,7 @@
.filter-group-label {
font-family: 'Cinzel', serif;
font-size: 11px;
font-size: var(--text-sm);
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
@@ -450,7 +679,7 @@
.select-all {
font-family: 'Crimson Text', serif;
font-size: 12px;
font-size: var(--text-sm);
font-style: italic;
background: none;
border: none;
@@ -462,7 +691,7 @@
}
.select-all:hover {
color: #f0d080;
color: var(--color-gold);
}
.checkboxes {
@@ -476,23 +705,25 @@
align-items: center;
gap: 0.4rem;
font-family: 'Crimson Text', serif;
font-size: 14px;
color: rgba(240, 180, 80, 0.8);
font-size: var(--text-base);
color: var(--color-gold-muted);
cursor: pointer;
}
.checkbox-label input {
accent-color: #c8861a;
accent-color: var(--color-bronze);
width: 14px;
height: 14px;
cursor: pointer;
}
.scroll-sentinel { height: 1px; }
.card-count {
font-family: 'Crimson Text', serif;
font-size: 16px;
font-size: var(--text-md);
font-style: italic;
color: rgba(240, 180, 80, 0.4);
color: var(--color-gold-faint);
margin-bottom: 1.5rem;
}
@@ -506,7 +737,7 @@
.status {
font-family: 'Crimson Text', serif;
font-size: 16px;
font-size: var(--text-md);
font-style: italic;
color: rgba(240, 180, 80, 0.5);
text-align: center;
@@ -514,7 +745,7 @@
}
.sort-arrow {
font-size: 10px;
font-size: var(--text-xs);
margin-left: 3px;
}
@@ -526,21 +757,25 @@
.range-label {
font-family: 'Cinzel', serif;
font-size: 11px;
color: rgba(240, 180, 80, 0.7);
font-size: var(--text-sm);
color: var(--color-gold-dim);
min-width: 60px;
}
input[type=range] {
accent-color: #c8861a;
accent-color: var(--color-bronze);
width: 160px;
}
.card-item {
position: relative;
}
.card-btn {
all: unset;
cursor: pointer;
display: block;
border-radius: 12px;
border-radius: var(--radius-xl);
transition: transform 0.15s;
}
@@ -548,6 +783,26 @@
transform: translateY(-4px);
}
.received-label {
position: absolute;
top: 44px;
left: 50%;
transform: translateX(-50%);
white-space: nowrap;
pointer-events: none;
background: rgba(13, 10, 4, 0.88);
backdrop-filter: blur(6px);
border: 1px solid rgba(200, 134, 26, 0.55);
border-radius: 20px;
padding: 3px 12px;
font-size: var(--text-sm);
font-family: 'Cinzel', serif;
font-weight: 600;
letter-spacing: 0.07em;
color: var(--color-gold);
text-transform: uppercase;
}
.backdrop {
position: fixed;
inset: 0;
@@ -555,7 +810,7 @@
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
z-index: var(--z-header);
backdrop-filter: blur(6px);
}
@@ -565,12 +820,17 @@
flex-direction: column;
align-items: center;
gap: 1rem;
padding-top: 5rem;
}
.popup-actions {
display: flex;
gap: 1rem;
justify-content: center;
flex-direction: column;
gap: 0.5rem;
align-items: stretch;
width: 100%;
max-width: 260px;
margin: 0 auto;
}
.action-col {
@@ -578,37 +838,70 @@
flex-direction: column;
align-items: center;
gap: 0.4rem;
min-height: 60px;
}
.report-btn, .refresh-btn {
.fav-btn, .wtt-btn, .report-btn, .refresh-btn {
font-family: 'Cinzel', serif;
font-size: 11px;
font-size: var(--btn-font-md);
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
border-radius: 4px;
padding: 8px 18px;
border-radius: var(--radius-sm);
padding: var(--btn-padding-md);
cursor: pointer;
transition: background 0.15s;
width: 100%;
}
.fav-btn {
background: rgba(30, 20, 0, 0.6);
border: 1px solid rgba(200, 160, 0, 0.4);
color: rgba(240, 200, 0, 0.6);
}
.fav-btn:hover {
border-color: rgba(200, 160, 0, 0.8);
color: #f0c800;
}
.fav-btn.fav-active {
background: rgba(60, 45, 0, 0.8);
border-color: #c8a000;
color: #f0c800;
}
.wtt-btn {
background: rgba(0, 30, 30, 0.6);
border: 1px solid rgba(0, 160, 160, 0.4);
color: rgba(0, 200, 200, 0.6);
}
.wtt-btn:hover {
border-color: rgba(0, 180, 180, 0.8);
color: #7ecfcf;
}
.wtt-btn.wtt-active {
background: rgba(0, 50, 50, 0.8);
border-color: #00a0a0;
color: #7ecfcf;
}
.report-btn {
background: rgba(180, 60, 60, 0.5);
border: 1px solid rgba(240,250,240,0.8);
border: 1px solid rgba(200, 60, 60, 0.5);
color: white;
}
.report-btn:hover:not(:disabled) {
/* border-color: #c84040; */
color: #E0E0E0;
background: rgba(180, 40, 40, 0.9);
}
.refresh-btn {
background: #3d2507;
border: 1px solid #c8861a;
color: #f0d080;
background: var(--color-surface-raised);
border: 1px solid var(--color-bronze);
color: var(--color-gold);
}
.refresh-btn:hover:not(:disabled) {
@@ -622,16 +915,16 @@
.refresh-countdown {
font-family: 'Crimson Text', serif;
font-size: 12px;
font-size: var(--text-sm);
font-style: italic;
color: rgba(240, 180, 80, 0.5);
}
.action-message {
font-family: 'Crimson Text', serif;
font-size: 14px;
font-size: var(--text-base);
font-style: italic;
color: rgba(240, 180, 80, 0.7);
color: var(--color-gold-dim);
margin: 0;
min-height: 1.4em;
text-align: center;
@@ -639,15 +932,15 @@
.close-btn {
position: absolute;
top: -12px;
top: calc(5rem - 14px);
right: -12px;
width: 28px;
height: 28px;
border-radius: 50%;
background: #1a1008;
border: 1px solid #6b4c1e;
color: rgba(240, 180, 80, 0.7);
font-size: 12px;
border-radius: var(--radius-full);
background: var(--color-surface);
border: 1px solid var(--color-border);
color: var(--color-gold-dim);
font-size: var(--text-sm);
display: flex;
align-items: center;
justify-content: center;
@@ -656,7 +949,110 @@
}
.close-btn:hover {
border-color: #c8861a;
color: #f0d080;
border-color: var(--color-bronze);
color: var(--color-gold);
}
.card-btn.selected {
box-shadow: 0 0 0 3px var(--color-bronze), var(--shadow-glow);
}
.selected-badge {
position: absolute;
top: 80px;
left: 50%;
transform: translateX(-50%);
background: var(--color-bronze);
color: var(--color-btn-text);
font-family: 'Cinzel', serif;
font-size: var(--text-xl);
font-weight: 900;
padding: 4px 10px;
border-radius: 23px;
border: black 3px solid;
pointer-events: none;
z-index: var(--z-card);
}
.bulk-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 60;
background: var(--color-surface);
border-top: 1px solid var(--color-border);
padding: 12px 2rem;
display: flex;
align-items: center;
gap: 1rem;
flex-wrap: wrap;
box-shadow: 0 -4px 20px rgba(0,0,0,0.6);
}
.bulk-count {
font-family: 'Cinzel', serif;
font-size: var(--text-sm);
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--color-gold);
white-space: nowrap;
min-width: 100px;
}
.bulk-actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
flex: 1;
}
.bulk-btn {
font-family: 'Cinzel', serif;
font-size: var(--btn-font-md);
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
background: var(--color-surface-raised);
border: 1px solid rgba(107, 76, 30, 0.6);
border-radius: var(--radius-sm);
color: var(--color-gold);
padding: var(--btn-padding-md);
cursor: pointer;
transition: all 0.15s;
}
.bulk-btn:hover:not(:disabled) {
border-color: var(--color-bronze);
background: #5a3510;
color: var(--color-btn-text);
}
.bulk-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.bulk-secondary {
margin-left: auto;
}
.bulk-select-all {
font-family: 'Crimson Text', serif;
font-size: var(--text-base);
font-style: italic;
background: none;
border: none;
color: rgba(240, 180, 80, 0.5);
cursor: pointer;
padding: 0;
text-decoration: underline;
transition: color 0.15s;
white-space: nowrap;
}
.bulk-select-all:hover {
color: var(--color-gold);
}
</style>
+72 -91
View File
@@ -1,15 +1,15 @@
<script>
<script lang="ts">
import { API_URL, WS_URL } from '$lib/api.js';
import { apiFetch } from '$lib/api.js';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import DeckTypeBadge from '$lib/DeckTypeBadge.svelte';
let decks = $state([]);
let decks: any[] = $state([]);
let loading = $state(true);
let editConfirm = $state(null); // deck object pending edit confirmation
let deleteConfirm = $state(null); // deck object pending delete confirmation
let editConfirm: any = $state(null); // deck object pending edit confirmation
let deleteConfirm: any = $state(null); // deck object pending delete confirmation
const token = () => localStorage.getItem('token');
@@ -33,7 +33,7 @@
goto(`/decks/${deck.id}`);
}
function clickEdit(deck) {
function clickEdit(deck: any) {
if (deck.times_played > 0) {
editConfirm = deck;
} else {
@@ -41,7 +41,7 @@
}
}
function clickDelete(deck) {
function clickDelete(deck: any) {
deleteConfirm = deck;
}
@@ -53,7 +53,7 @@
deleteConfirm = null;
}
function winRate(deck) {
function winRate(deck: any) {
if (deck.times_played === 0) return null;
return Math.round((deck.wins / deck.times_played) * 100);
}
@@ -160,12 +160,10 @@
</main>
<style>
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;700&family=Crimson+Text:ital,wght@0,400;0,600;1,400&display=swap');
main {
height: 100vh;
overflow-y: auto;
background: #0d0a04;
background: var(--color-bg);
padding: 2rem;
}
@@ -174,29 +172,31 @@
align-items: baseline;
justify-content: space-between;
margin-bottom: 2rem;
border-bottom: 1px solid rgba(107, 76, 30, 0.3);
border-bottom: 1px solid var(--color-border-dim);
padding-bottom: 1rem;
}
.title {
font-family: 'Cinzel', serif;
font-size: 22px;
font-weight: 700;
color: #f0d080;
font-size: var(--text-xl);
font-weight: 900;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--color-gold);
margin: 0;
}
.new-btn {
font-family: 'Cinzel', serif;
font-size: 11px;
font-size: var(--btn-font-md);
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
background: #3d2507;
border: 1px solid #c8861a;
border-radius: 4px;
color: #f0d080;
padding: 6px 14px;
background: var(--color-surface-raised);
border: 1px solid var(--color-bronze);
border-radius: var(--radius-md);
color: var(--color-gold);
padding: var(--btn-padding-md);
cursor: pointer;
transition: background 0.15s;
}
@@ -210,16 +210,16 @@
}
thead tr {
border-bottom: 1px solid rgba(107, 76, 30, 0.5);
border-bottom: 1px solid var(--color-border-subtle);
}
th {
font-family: 'Cinzel', serif;
font-size: 10px;
font-size: var(--text-xs);
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: rgba(240, 180, 80, 0.4);
color: var(--color-gold-faint);
padding: 0 1rem 0.75rem 0;
text-align: left;
}
@@ -237,62 +237,43 @@
}
.deck-name {
font-size: 17px;
color: #e8d090;
font-size: var(--text-lg);
color: var(--color-gold);
}
.deck-count {
font-family: 'Cinzel', serif;
font-size: 13px;
font-size: var(--text-base);
font-weight: 700;
color: rgba(240, 180, 80, 0.6);
color: var(--color-gold-dim);
width: 60px;
}
.deck-cost {
font-family: 'Cinzel', serif;
font-size: 13px;
font-size: var(--text-base);
font-weight: 700;
color: #6aaa6a;
color: var(--color-success);
width: 60px;
}
.deck-cost.over-budget { color: #c85050; }
.deck-cost.over-budget { color: var(--color-error); }
.deck-type { width: 90px; }
.type-badge {
font-family: 'Cinzel', serif;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.05em;
padding: 3px 8px;
border-radius: 3px;
cursor: default;
}
.type-wall { background: rgba(58, 104, 120, 0.3); color: #a0d4e8; border: 1px solid rgba(58, 104, 120, 0.5); }
.type-aggro { background: rgba(139, 32, 32, 0.3); color: #e89090; border: 1px solid rgba(139, 32, 32, 0.5); }
.type-god-card { background: rgba(184, 120, 32, 0.3); color: #f5d880; border: 1px solid rgba(184, 120, 32, 0.5); }
.type-rush { background: rgba(74, 122, 80, 0.3); color: #a8dca8; border: 1px solid rgba(74, 122, 80, 0.5); }
.type-control { background: rgba(122, 80, 144, 0.3); color: #d0a0e8; border: 1px solid rgba(122, 80, 144, 0.5); }
.type-unplayable { background: rgba(60, 60, 60, 0.3); color: #909090; border: 1px solid rgba(60, 60, 60, 0.5); }
.type-pantheon { background: rgba(184, 150, 60, 0.3); color: #fce8a0; border: 1px solid rgba(184, 150, 60, 0.5); }
.type-balanced { background: rgba(106, 104, 96, 0.3); color: #c8c6c0; border: 1px solid rgba(106, 104, 96, 0.5); }
.deck-type { white-space: nowrap; }
.deck-stat {
font-family: 'Cinzel', serif;
font-size: 13px;
color: rgba(240, 180, 80, 0.6);
font-size: var(--text-base);
color: var(--color-gold-dim);
width: 60px;
}
.wins { color: #6aaa6a; }
.losses { color: #c85050; }
.wins { color: var(--color-success); }
.losses { color: var(--color-error); }
.separator { color: rgba(240, 180, 80, 0.3); }
.good-wr { color: #6aaa6a; }
.bad-wr { color: #c85050; }
.good-wr { color: var(--color-success); }
.bad-wr { color: var(--color-error); }
.no-data {
color: rgba(240, 180, 80, 0.2);
@@ -306,25 +287,25 @@
.edit-btn, .delete-btn {
font-family: 'Cinzel', serif;
font-size: 10px;
font-size: var(--btn-font-sm);
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
border-radius: 4px;
padding: 4px 10px;
border-radius: var(--radius-sm);
padding: var(--btn-padding-sm);
cursor: pointer;
transition: background 0.15s;
}
.edit-btn {
background: none;
border: 1px solid rgba(107, 76, 30, 0.5);
color: rgba(240, 180, 80, 0.7);
border: 1px solid var(--color-border-subtle);
color: var(--color-gold-dim);
}
.edit-btn:hover {
border-color: #c8861a;
color: #f0d080;
border-color: var(--color-bronze);
color: var(--color-gold);
}
.delete-btn {
@@ -334,13 +315,13 @@
}
.delete-btn:hover {
border-color: #c84040;
color: #e05050;
border-color: var(--color-error);
color: var(--color-error);
}
.status {
font-family: 'Crimson Text', serif;
font-size: 16px;
font-size: var(--text-md);
font-style: italic;
color: rgba(240, 180, 80, 0.5);
text-align: center;
@@ -354,13 +335,13 @@
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
z-index: var(--z-header);
}
.popup {
background: #1a1008;
border: 1px solid #6b4c1e;
border-radius: 10px;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: 2rem;
max-width: 400px;
width: calc(100% - 2rem);
@@ -371,22 +352,22 @@
.popup-title {
font-family: 'Cinzel', serif;
font-size: 18px;
font-size: var(--text-lg);
font-weight: 700;
color: #f0d080;
color: var(--color-gold);
margin: 0;
}
.popup-body {
font-family: 'Crimson Text', serif;
font-size: 15px;
color: rgba(240, 180, 80, 0.7);
font-size: var(--text-md);
color: var(--color-gold-dim);
margin: 0;
line-height: 1.6;
}
.popup-body strong {
color: #f0d080;
color: var(--color-gold);
}
.popup-actions {
@@ -397,52 +378,52 @@
.popup-cancel {
font-family: 'Cinzel', serif;
font-size: 11px;
font-size: var(--btn-font-md);
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
background: none;
border: 1px solid rgba(107, 76, 30, 0.4);
border-radius: 4px;
color: rgba(240, 180, 80, 0.6);
padding: 7px 16px;
border: 1px solid var(--color-border-subtle);
border-radius: var(--radius-sm);
color: var(--color-gold-dim);
padding: var(--btn-padding-md);
cursor: pointer;
transition: all 0.15s;
}
.popup-cancel:hover {
border-color: #c8861a;
color: #f0d080;
border-color: var(--color-bronze);
color: var(--color-gold);
}
.popup-confirm {
font-family: 'Cinzel', serif;
font-size: 11px;
font-size: var(--btn-font-md);
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
background: #c8861a;
background: var(--color-bronze);
border: none;
border-radius: 4px;
color: #fff8e0;
padding: 7px 16px;
border-radius: var(--radius-sm);
color: var(--color-btn-text);
padding: var(--btn-padding-md);
cursor: pointer;
transition: background 0.15s;
}
.popup-confirm:hover { background: #e09820; }
.popup-confirm:hover { background: var(--color-bronze-hover); }
.popup-delete {
font-family: 'Cinzel', serif;
font-size: 11px;
font-size: var(--btn-font-md);
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
background: rgba(180, 40, 40, 0.8);
border: none;
border-radius: 4px;
border-radius: var(--radius-sm);
color: #fff;
padding: 7px 16px;
padding: var(--btn-padding-md);
cursor: pointer;
transition: background 0.15s;
}
+88 -51
View File
@@ -1,4 +1,4 @@
<script>
<script lang="ts">
import { API_URL } from '$lib/api.js';
import { apiFetch } from '$lib/api.js';
import { goto } from '$app/navigation';
@@ -9,17 +9,18 @@
const deckId = $derived($page.params.id);
const token = () => localStorage.getItem('token');
let allCards = $state([]);
let selectedIds = $state(new Set());
let selectedCost = $state(0);
let costMap: Map<string, number> = $state(new Map());
let deckName = $state('');
let editingName = $state(false);
let nameInput = $state('');
let loading = $state(true);
let saving = $state(false);
let nameError = $state('');
let saveError = $state('');
const selectedCost = $derived(
allCards.filter(c => selectedIds.has(c.id)).reduce((sum, c) => sum + c.cost, 0)
);
const MAX_NAME = 64;
function startEditName() {
nameInput = deckName;
@@ -27,13 +28,20 @@
}
function commitName() {
if (nameInput.trim()) deckName = nameInput.trim();
const trimmed = nameInput.trim();
if (trimmed && trimmed.length <= MAX_NAME) {
deckName = trimmed;
nameError = '';
} else if (trimmed.length > MAX_NAME) {
nameError = `Name must be ${MAX_NAME} characters or fewer`;
}
editingName = false;
}
async function save() {
saving = true;
await apiFetch(`${API_URL}/decks/${deckId}`, {
saveError = '';
const res = await apiFetch(`${API_URL}/decks/${deckId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -42,26 +50,29 @@
}),
});
saving = false;
if (!res.ok) {
saveError = 'Failed to save deck. Please try again.';
return;
}
goto('/decks');
}
onMount(async () => {
if (!token()) { goto('/auth'); return; }
const [cardsRes, deckCardsRes] = await Promise.all([
apiFetch(`${API_URL}/cards`),
const [deckCardsRes, decksRes] = await Promise.all([
apiFetch(`${API_URL}/decks/${deckId}/cards`),
apiFetch(`${API_URL}/decks`),
]);
if (cardsRes.status === 401) { goto('/auth'); return; }
if (deckCardsRes.status === 401) { goto('/auth'); return; }
allCards = await cardsRes.json();
const currentCardIds = await deckCardsRes.json();
selectedIds = new Set(currentCardIds);
const deckCards = await deckCardsRes.json();
selectedIds = new Set(deckCards.map((c: any) => c.id));
costMap = new Map(deckCards.map((c: any) => [c.id, c.cost]));
const decksRes = await apiFetch(`${API_URL}/decks`);
const decks = await decksRes.json();
const deck = decks.find(d => d.id === deckId);
const deck = decks.find((d: any) => d.id === deckId);
deckName = deck?.name ?? 'Untitled Deck';
loading = false;
@@ -71,25 +82,31 @@
<main>
<div class="toolbar">
<div class="deck-header">
{#if editingName}
<input
class="name-input"
bind:value={nameInput}
onblur={commitName}
onkeydown={e => e.key === 'Enter' && commitName()}
autofocus
/>
{:else}
<button class="name-btn" onclick={startEditName}>{deckName} ✎</button>
{/if}
<div class="name-area">
{#if editingName}
<input
class="name-input"
bind:value={nameInput}
onblur={commitName}
onkeydown={e => e.key === 'Enter' && commitName()}
autofocus
/>
{:else}
<button class="name-btn" onclick={startEditName}>{deckName} ✎</button>
{/if}
<p class="field-error">{nameError}</p>
</div>
<div class="header-right">
<span class="card-counter" class:full={selectedCost === 50} class:over={selectedCost > 50} class:empty={selectedIds.size === 0}>
{selectedIds.size} cards · {selectedCost}/50
</span>
<button class="done-btn" onclick={save} disabled={saving}>
{saving ? 'Saving...' : 'Done'}
</button>
<div class="save-area">
<button class="done-btn" onclick={save} disabled={saving}>
{saving ? 'Saving...' : 'Done'}
</button>
<p class="field-error">{saveError}</p>
</div>
</div>
</div>
</div>
@@ -98,8 +115,9 @@
<p class="status">Loading...</p>
{:else}
<CardSelector
allCards={allCards}
bind:selectedIds={selectedIds}
bind:selectedCost={selectedCost}
bind:costMap={costMap}
costLimit={50}
showFooter={false}
/>
@@ -107,21 +125,19 @@
</main>
<style>
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;700&family=Crimson+Text:ital,wght@0,400;0,600;1,400&display=swap');
main {
height: 100vh;
height: 100%;
overflow: hidden;
background: #0d0a04;
background: var(--color-bg);
display: flex;
flex-direction: column;
}
.toolbar {
flex-shrink: 0;
background: #0d0a04;
background: var(--color-bg);
padding: 1.5rem 2rem 1rem;
border-bottom: 1px solid rgba(107, 76, 30, 0.3);
border-bottom: 1px solid var(--color-border-dim);
}
.deck-header {
@@ -133,9 +149,9 @@
.name-btn {
font-family: 'Cinzel', serif;
font-size: 18px;
font-size: var(--text-lg);
font-weight: 700;
color: #f0d080;
color: var(--color-gold);
background: none;
border: none;
cursor: pointer;
@@ -148,12 +164,12 @@
.name-input {
font-family: 'Cinzel', serif;
font-size: 18px;
font-size: var(--text-lg);
font-weight: 700;
color: #f0d080;
color: var(--color-gold);
background: transparent;
border: none;
border-bottom: 1.5px solid #c8861a;
border-bottom: 1.5px solid var(--color-bronze);
outline: none;
padding: 0 0 2px 0;
min-width: 200px;
@@ -167,27 +183,27 @@
.card-counter {
font-family: 'Cinzel', serif;
font-size: 14px;
font-size: var(--text-base);
font-weight: 700;
color: rgba(240, 180, 80, 0.7);
transition: color 0.2s;
}
.card-counter.full { color: #6aaa6a; }
.card-counter.over { color: #c85050; }
.card-counter.full { color: var(--color-success); }
.card-counter.over { color: var(--color-error); }
.card-counter.empty { color: rgba(240, 180, 80, 0.3); }
.done-btn {
font-family: 'Cinzel', serif;
font-size: 11px;
font-size: var(--btn-font-md);
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
background: #3d2507;
border: 1px solid #c8861a;
border-radius: 4px;
color: #f0d080;
padding: 6px 16px;
background: var(--color-surface-raised);
border: 1px solid var(--color-bronze);
border-radius: var(--radius-md);
color: var(--color-gold);
padding: var(--btn-padding-md);
cursor: pointer;
transition: background 0.15s;
}
@@ -195,9 +211,30 @@
.done-btn:hover:not(:disabled) { background: #5a3510; }
.done-btn:disabled { opacity: 0.5; cursor: default; }
.name-area {
display: flex;
flex-direction: column;
gap: 2px;
}
.save-area {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 2px;
}
.field-error {
font-family: 'Crimson Text', serif;
font-size: var(--text-sm);
color: var(--color-error);
margin: 0;
min-height: 1em;
}
.status {
font-family: 'Crimson Text', serif;
font-size: 16px;
font-size: var(--text-md);
font-style: italic;
color: rgba(240, 180, 80, 0.5);
text-align: center;
@@ -48,11 +48,9 @@
</main>
<style>
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;700&family=Crimson+Text:ital,wght@0,400;0,600;1,400&display=swap');
main {
min-height: 100vh;
background: #0d0a04;
background: var(--color-bg);
display: flex;
align-items: center;
justify-content: center;
@@ -61,9 +59,9 @@
.card {
width: 340px;
background: #2e1c05;
border: 2px solid #6b4c1e;
border-radius: 12px;
background: var(--color-surface);
border: 2px solid var(--color-bronze);
border-radius: var(--radius-xl);
padding: 2rem;
display: flex;
flex-direction: column;
@@ -72,16 +70,16 @@
.title {
font-family: 'Cinzel', serif;
font-size: 20px;
color: #f0d080;
font-size: var(--text-xl);
color: var(--color-gold);
text-align: center;
margin: 0;
}
.hint {
font-family: 'Crimson Text', serif;
font-size: 15px;
color: rgba(245, 208, 96, 0.7);
font-size: var(--text-md);
color: var(--color-gold-dim);
margin: 0;
text-align: center;
line-height: 1.6;
@@ -90,38 +88,41 @@
input {
width: 100%;
padding: 9px 12px;
background: #1a1008;
border: 1.5px solid #8b6420;
border-radius: 6px;
color: #f0d080;
background: var(--color-surface);
border: 1.5px solid var(--color-border);
border-radius: var(--radius-md);
color: var(--color-gold);
font-family: 'Crimson Text', serif;
font-size: 15px;
font-size: var(--text-md);
box-sizing: border-box;
outline: none;
}
input::placeholder {
color: rgba(240, 180, 80, 0.4);
color: var(--color-gold-faint);
}
input:focus { border-color: #f5d060; }
input:focus { border-color: var(--color-bronze); }
button {
width: 100%;
padding: 10px;
background: #6b4c1e;
color: #f0d080;
border: 1.5px solid #8b6420;
border-radius: 6px;
padding: var(--btn-padding-lg);
background: var(--color-bronze);
color: var(--color-btn-text);
border: none;
border-radius: var(--radius-md);
font-family: 'Cinzel', serif;
font-size: 13px;
font-size: var(--btn-font-lg);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
cursor: pointer;
transition: background 0.15s;
}
button:hover:not(:disabled) {
background: #8b6420;
background: var(--color-bronze-hover);
}
button:disabled {
@@ -131,19 +132,19 @@
.back-link {
font-family: 'Crimson Text', serif;
font-size: 14px;
color: rgba(245, 208, 96, 0.5);
font-size: var(--text-base);
color: var(--color-gold-faint);
text-align: center;
text-decoration: none;
transition: color 0.15s;
}
.back-link:hover { color: #f5d060; }
.back-link:hover { color: var(--color-gold); }
.error {
font-family: 'Crimson Text', serif;
font-size: 14px;
color: #f06060;
font-size: var(--text-base);
color: var(--color-error);
margin: 0;
min-height: 1.4em;
text-align: center;
@@ -71,11 +71,9 @@
</main>
<style>
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;700&family=Crimson+Text:ital,wght@0,400;0,600;1,400&display=swap');
main {
min-height: 100vh;
background: #0d0a04;
background: var(--color-bg);
display: flex;
align-items: center;
justify-content: center;
@@ -84,9 +82,9 @@
.card {
width: 380px;
background: #3d2507;
border: 2px solid #c8861a;
border-radius: 12px;
background: var(--color-surface);
border: 2px solid var(--color-bronze);
border-radius: var(--radius-xl);
padding: 2rem;
display: flex;
flex-direction: column;
@@ -95,17 +93,17 @@
.title {
font-family: 'Cinzel', serif;
font-size: 20px;
font-size: var(--text-xl);
font-weight: 700;
color: #f5d060;
color: var(--color-gold);
margin: 0;
text-align: center;
}
.hint {
font-family: 'Crimson Text', serif;
font-size: 15px;
color: rgba(245, 208, 96, 0.7);
font-size: var(--text-md);
color: var(--color-gold-dim);
margin: 0;
text-align: center;
line-height: 1.6;
@@ -119,52 +117,54 @@
.field-label {
font-family: 'Cinzel', serif;
font-size: 10px;
font-size: var(--text-xs);
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: rgba(245, 208, 96, 0.5);
color: var(--color-gold-faint);
}
input {
width: 100%;
padding: 9px 12px;
background: #221508;
border: 1.5px solid #c8861a;
border-radius: 6px;
color: #f5d060;
background: var(--color-surface);
border: 1.5px solid var(--color-border);
border-radius: var(--radius-md);
color: var(--color-gold);
font-family: 'Crimson Text', serif;
font-size: 15px;
font-size: var(--text-md);
box-sizing: border-box;
outline: none;
margin-bottom: 0.4rem;
}
input:focus { border-color: #f5d060; }
input::placeholder { color: rgba(245, 208, 96, 0.35); }
input:focus { border-color: var(--color-bronze); }
input::placeholder { color: var(--color-gold-faint); }
.btn {
width: 100%;
padding: 10px;
background: #c8861a;
color: #fff8e0;
padding: var(--btn-padding-lg);
background: var(--color-bronze);
color: var(--color-btn-text);
border: none;
border-radius: 6px;
border-radius: var(--radius-md);
font-family: 'Cinzel', serif;
font-size: 13px;
font-size: var(--btn-font-lg);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
cursor: pointer;
transition: background 0.15s;
display: block;
}
.btn:hover:not(:disabled) { background: #e09820; }
.btn:hover:not(:disabled) { background: var(--color-bronze-hover); }
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.error {
font-family: 'Crimson Text', serif;
font-size: 14px;
color: #f06060;
font-size: var(--text-base);
color: var(--color-error);
margin: 0;
min-height: 1.4em;
text-align: center;
+37 -236
View File
@@ -1,5 +1,5 @@
<script>
import { onMount } from 'svelte';
import Card from '$lib/Card.svelte';
// A fake card for display purposes
const exampleCard = {
@@ -12,7 +12,7 @@
attack: 351,
defense: 222,
cost: 5,
created_at: new Date().toISOString(),
generated_at: new Date().toISOString(),
reported: false,
};
@@ -48,34 +48,7 @@
<div class="card-explainer">
<div class="card-annotated">
<div class="card-display">
<!-- Inline card rendering matching Card.svelte visuals -->
<div class="demo-card">
<div class="demo-inner" style="--bg: #f0e0c8; --header: #b87830">
<div class="demo-header">
<span class="demo-name">{exampleCard.name}</span>
<span class="demo-type-badge">Person</span>
</div>
<div class="demo-image-wrap">
<img src={exampleCard.image_link} alt={exampleCard.name} class="demo-image" />
<div class="demo-rarity" style="background: #2a5a9b; color: #fff">R</div>
<a href="https://en.wikipedia.org/wiki/Harald_Bluetooth" target="_blank" rel="noopener" class="demo-wiki">
<svg viewBox="0 0 50 50" width="14" height="14"><circle cx="25" cy="25" r="24" fill="white" stroke="#888" stroke-width="1"/><text x="25" y="33" text-anchor="middle" font-family="serif" font-size="28" font-weight="bold" fill="#000">W</text></svg>
</a>
<div class="demo-cost-bubbles">
{#each { length: exampleCard.cost } as _}
<div class="demo-cost-bubble"></div>
{/each}
</div>
</div>
<div class="demo-divider"></div>
<div class="demo-text">{exampleCard.text}</div>
<div class="demo-footer" style="background: #e8d8b8">
<span class="demo-stat">ATK <strong>{exampleCard.attack}</strong></span>
<span class="demo-date">{new Date(exampleCard.created_at).toLocaleDateString()}</span>
<span class="demo-stat">DEF <strong>{exampleCard.defense}</strong></span>
</div>
</div>
</div>
<Card card={exampleCard} noHover={true} />
</div>
<!-- Annotation markers -->
@@ -195,12 +168,10 @@
</main>
<style>
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;700&family=Crimson+Text:ital,wght@0,400;0,600;1,400&display=swap');
main {
height: 100vh;
overflow-y: auto;
background: #0d0a04;
background: var(--color-bg);
}
.content {
@@ -211,11 +182,12 @@
.page-title {
font-family: 'Cinzel', serif;
font-size: 28px;
font-weight: 700;
color: #f0d080;
font-size: clamp(22px, 4vw, 32px);
font-weight: 900;
color: var(--color-gold);
margin: 0 0 2rem;
letter-spacing: 0.08em;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.section {
@@ -224,14 +196,14 @@
.section-title {
font-family: 'Cinzel', serif;
font-size: 14px;
font-size: var(--text-base);
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: #f0d080AA;
color: var(--color-gold-dim);
margin: 0 0 1.25rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid #f0d08055;
border-bottom: 1px solid var(--color-gold-faint);
}
.body-text ul {
@@ -240,8 +212,8 @@
.body-text {
font-family: 'Crimson Text', serif;
font-size: 17px;
color: rgba(240, 180, 80, 0.75);
font-size: var(--text-lg);
color: var(--color-gold-muted);
line-height: 1.7;
margin: 0 0 1rem;
}
@@ -272,23 +244,23 @@
.marker {
position: absolute;
transform: translate(-50%, -50%);
z-index: 10;
z-index: var(--z-card);
}
.marker-bubble {
width: 22px;
height: 22px;
border-radius: 50%;
background: #c8861a;
border: 2px solid #fff;
color: #fff;
background: var(--color-bronze);
border: 2px solid var(--color-btn-text);
color: var(--color-btn-text);
font-family: 'Cinzel', serif;
font-size: 11px;
font-size: var(--text-sm);
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 8px rgba(0,0,0,0.6);
box-shadow: var(--shadow-subtle);
}
/* ── Annotation list ── */
@@ -313,10 +285,10 @@
width: 22px;
height: 22px;
border-radius: 50%;
background: #c8861a;
color: #fff;
background: var(--color-bronze);
color: var(--color-btn-text);
font-family: 'Cinzel', serif;
font-size: 11px;
font-size: var(--text-sm);
font-weight: 700;
display: flex;
align-items: center;
@@ -333,16 +305,16 @@
.annotation-label {
font-family: 'Cinzel', serif;
font-size: 12px;
font-size: var(--text-sm);
font-weight: 700;
color: #f0d080;
color: var(--color-gold);
letter-spacing: 0.04em;
}
.annotation-desc {
font-family: 'Crimson Text', serif;
font-size: 14px;
color: rgba(240, 180, 80, 0.6);
font-size: var(--text-base);
color: var(--color-gold-dim);
line-height: 1.5;
}
@@ -350,13 +322,13 @@
.rules-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 12px;
gap: 0.75rem;
}
.rule-card {
background: #1a1008;
border: 1px solid rgba(107, 76, 30, 0.3);
border-radius: 8px;
background: var(--color-surface);
border: 1px solid var(--color-border-dim);
border-radius: var(--radius-lg);
padding: 1.25rem;
display: flex;
flex-direction: column;
@@ -364,198 +336,27 @@
}
.rule-icon {
color: #f0d080AA;
font-size: 20px;
color: var(--color-gold-dim);
font-size: var(--text-xl);
line-height: 1;
}
.rule-title {
font-family: 'Cinzel', serif;
font-size: 12px;
font-size: var(--text-sm);
font-weight: 700;
letter-spacing: 0.06em;
color: #f0d080;
color: var(--color-gold);
margin: 0;
}
.rule-body {
font-family: 'Crimson Text', serif;
font-size: 14px;
color: rgba(240, 180, 80, 0.6);
font-size: var(--text-base);
color: var(--color-gold-dim);
line-height: 1.55;
margin: 0;
}
/* ── Demo card ── */
.demo-card {
width: 300px;
border-radius: 12px;
padding: 7px;
background: #111;
border: 2px solid #111;
box-shadow: 0 4px 24px rgba(0,0,0,0.5);
font-family: 'Crimson Text', serif;
position: relative;
user-select: none;
}
.demo-inner {
border-radius: 8px;
overflow: hidden;
background: var(--bg);
border: 2px solid #000;
display: flex;
flex-direction: column;
}
.demo-header {
padding: 9px 12px 7px;
background: var(--header);
border-bottom: 2px solid #000;
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 8px;
}
.demo-name {
font-family: 'Cinzel', serif;
font-size: 13px;
font-weight: 700;
color: #fff;
text-shadow: 0 1px 3px rgba(0,0,0,0.6);
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.demo-type-badge {
font-family: 'Cinzel', serif;
font-size: 9px;
color: rgba(255,255,255,0.95);
text-transform: uppercase;
letter-spacing: 0.05em;
background: rgba(0,0,0,0.25);
padding: 1px 5px;
border-radius: 3px;
white-space: nowrap;
}
.demo-image-wrap {
position: relative;
width: 100%;
aspect-ratio: 4/3;
overflow: hidden;
border-bottom: 2px solid #000;
}
.demo-image {
width: 100%;
height: 100%;
object-fit: cover;
object-position: top;
display: block;
}
.demo-rarity {
position: absolute;
top: 7px;
left: 7px;
width: 26px;
height: 26px;
border-radius: 50%;
border: 2.5px solid #000;
font-family: 'Cinzel', serif;
font-size: 9px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
z-index: 3;
}
.demo-wiki {
position: absolute;
top: 7px;
right: 7px;
width: 26px;
height: 26px;
border-radius: 50%;
background: rgba(255,255,255,0.92);
border: 1.5px solid #000;
display: flex;
align-items: center;
justify-content: center;
z-index: 3;
}
.demo-cost-bubbles {
position: absolute;
bottom: 6px;
left: 8px;
display: flex;
gap: 3px;
flex-wrap: wrap;
max-width: calc(100% - 16px);
}
.demo-cost-bubble {
width: 16px;
height: 16px;
border-radius: 50%;
background: #6ea0ec;
border: 2.5px solid #000;
display: flex;
align-items: center;
justify-content: center;
color: #08152c;
font-size: 12px;
font-weight: 700;
font-family: 'Cinzel', serif;
line-height: 1;
}
.demo-divider {
height: 2px;
background: #000;
}
.demo-text {
padding: 10px 12px;
font-size: 13px;
line-height: 1.55;
color: #1a1208;
font-style: italic;
background: #f0e6cc;
border-bottom: 2px solid #000;
height: 110px;
overflow: hidden;
}
.demo-footer {
padding: 7px 12px;
display: flex;
justify-content: space-between;
align-items: center;
}
.demo-stat {
font-family: 'Cinzel', serif;
font-size: 11px;
color: #2a2010;
letter-spacing: 0.03em;
}
.demo-stat strong {
color: #000;
font-size: 15px;
}
.demo-date {
font-size: 10px;
color: rgba(0,0,0,0.5);
font-style: italic;
font-family: 'Crimson Text', serif;
}
</style>
+219 -120
View File
@@ -1,24 +1,30 @@
<script>
<script lang="ts">
import { API_URL, WS_URL } from '$lib/api.js';
import { apiFetch } from '$lib/api.js';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { get } from 'svelte/store';
import { onMount, onDestroy } from 'svelte';
import Card from '$lib/Card.svelte';
import DeckTypeBadge from '$lib/DeckTypeBadge.svelte';
import { play } from '$lib/audio.js';
const token = () => localStorage.getItem('token');
let queueWs = null;
let gameWs = null;
let queueWs: WebSocket | null = null;
let gameWs: WebSocket | null = null;
let phase = $state('idle');
let error = $state('');
let reconnecting = $state(false);
let gameReconnectDelay = 1000;
let gameReconnectTimer: number | undefined = undefined;
let decks = $state([]);
let decks: any[] = $state([]);
let selectedDeckId = $state('');
let selectedDeck = $derived(decks.find(d => d.id === selectedDeckId));
let gameId = $state('');
let gameState = $state(null);
let gameState: any = $state(null);
let myId = $state('');
let viewingBoard = $state(false);
@@ -34,7 +40,7 @@
'Expert'
);
let selectedHandIndex = $state(null);
let selectedHandIndex: number | null = $state(null);
let combatAnimating = $state(false);
let lunging = $state(new Set());
let lungingDown = $state(new Set());
@@ -46,24 +52,25 @@
let gameOver = $derived(!!gameState?.result);
let sacrificeMode = $state(false);
let displayedDefense = $state({});
let displayedDefense: Record<string, number> = $state({});
let destroying = $state(new Set());
let destroyed = $state(new Set());
let displayedLife = $state({});
let displayedLife: Record<string, number> = $state({});
const TURN_TIME_LIMIT = 120; // seconds
const TIMER_WARNING = 30; // show timer when this many seconds remain
let turnStartedAt = $state(null);
let turnStartedAt: Date | null = $state(null);
let secondsRemaining = $state(TURN_TIME_LIMIT);
let timerInterval = null
let timerInterval: number | undefined = undefined;
$effect(() => {
if (!gameState?.turn_started_at) return;
turnStartedAt = new Date(gameState.turn_started_at);
const ts = new Date(gameState.turn_started_at);
turnStartedAt = ts;
clearInterval(timerInterval);
timerInterval = setInterval(async () => {
const elapsed = (Date.now() - turnStartedAt) / 1000;
const elapsed = (Date.now() - ts.getTime()) / 1000;
secondsRemaining = Math.max(0, TURN_TIME_LIMIT - elapsed);
if (secondsRemaining <= 0 && !isMyTurn && gameState && !gameState.result) {
@@ -75,6 +82,7 @@
onDestroy(() => {
clearInterval(timerInterval);
clearTimeout(gameReconnectTimer);
});
async function claimTimeoutWin() {
@@ -103,7 +111,7 @@
...(gameState.you.board.filter(Boolean) || []),
...(gameState.opponent.board.filter(Boolean) || []),
];
const next = {};
const next: Record<string, number> = {};
for (const card of all) next[card.instance_id] = card.defense;
displayedDefense = next;
});
@@ -115,6 +123,21 @@
onMount(async () => {
if (!token()) { goto('/auth'); return; }
// Support joining a direct challenge game via ?game_id=... query param
const challengeGameId = get(page).url.searchParams.get('game_id');
if (challengeGameId) {
gameId = challengeGameId;
phase = 'playing';
connectToGame();
// Load decks in the background so the lobby is ready if the connection fails
apiFetch(`${API_URL}/decks`).then(r => r.json()).then(data => {
decks = data;
if (decks.length > 0) selectedDeckId = decks[0].id;
});
return;
}
const res = await apiFetch(`${API_URL}/decks`);
decks = await res.json();
if (decks.length > 0) selectedDeckId = decks[0].id;
@@ -130,12 +153,12 @@
error = '';
phase = 'queuing';
queueWs = new WebSocket(`${WS_URL}/ws/queue?deck_id=${selectedDeckId}`);
queueWs.onopen = () => queueWs.send(token());
queueWs.onopen = () => queueWs!.send(token()!);
queueWs.onmessage = (e) => {
const msg = JSON.parse(e.data);
if (msg.type === 'game_start') {
gameId = msg.game_id;
queueWs.close();
queueWs!.close();
connectToGame();
} else if (msg.type === 'error') {
error = msg.message;
@@ -147,7 +170,11 @@
function connectToGame() {
gameWs = new WebSocket(`${WS_URL}/ws/game/${gameId}`);
gameWs.onopen = () => gameWs.send(token());
gameWs.onopen = () => {
gameWs!.send(token()!);
reconnecting = false;
gameReconnectDelay = 1000;
};
gameWs.onmessage = async (e) => {
const msg = JSON.parse(e.data);
if (msg.type === 'state') {
@@ -165,6 +192,7 @@
phase = newState.result ? 'ended' : 'playing';
} else if (msg.type === 'sacrifice_animation') {
const id = msg.instance_id;
play('cardShatter');
destroying = new Set([...destroying, id]);
await delay(600);
destroying = new Set([...destroying].filter(i => i !== id));
@@ -174,10 +202,26 @@
setTimeout(() => error = '', 3000);
}
};
gameWs.onclose = (e) => {
// 1008 = Policy Violation — server rejects unknown/expired game_id.
// Fall back to the normal lobby and strip the stale query param.
if (e.code === 1008 && phase !== 'ended') {
phase = 'idle';
history.replaceState({}, '', '/play');
return;
}
if (phase === 'playing') {
reconnecting = true;
gameReconnectTimer = setTimeout(() => {
gameReconnectDelay = Math.min(gameReconnectDelay * 2, 30000);
connectToGame();
}, gameReconnectDelay);
}
};
gameWs.onerror = () => { error = 'Connection lost'; };
}
async function animateCombat(newState) {
async function animateCombat(newState: any) {
combatAnimating = true;
// The attacker is whoever was active when end_turn was called.
@@ -187,7 +231,7 @@
// active_player_id hasn't switched yet.
const attackerId = newState.result
? newState.active_player_id
: newState.player_order.find(id => id !== newState.active_player_id);
: newState.player_order.find((id: string) => id !== newState.active_player_id);
const attackerIsMe = attackerId === myId;
@@ -206,7 +250,11 @@
} else {
lungingDown = new Set([...lungingDown, attacker.instance_id]);
}
if (defender) shaking = new Set([...shaking, defender.instance_id]);
play('attack');
if (defender) {
shaking = new Set([...shaking, defender.instance_id]);
play('defend');
}
await delay(220);
if (defender) {
const newDefense = Math.max(0, (displayedDefense[defender.instance_id] ?? defender.defense) - attacker.attack);
@@ -222,6 +270,7 @@
lungingDown = new Set([...lungingDown].filter(id => id !== attacker.instance_id));
if (defender) shaking = new Set([...shaking].filter(id => id !== defender.instance_id));
if (defender && (displayedDefense[defender.instance_id] ?? defender.defense) <= 0) {
play('cardShatter');
destroying = new Set([...destroying, defender.instance_id]);
await delay(600);
destroying = new Set([...destroying].filter(id => id !== defender.instance_id));
@@ -234,25 +283,27 @@
combatAnimating = false;
}
function delay(ms) { return new Promise(r => setTimeout(r, ms)); }
function delay(ms: number) { return new Promise(r => setTimeout(r, ms)); }
function send(msg) { gameWs?.send(JSON.stringify(msg)); }
function send(msg: unknown) { gameWs?.send(JSON.stringify(msg)); }
function selectHandCard(index) {
function selectHandCard(index: number) {
if (!isMyTurn || combatAnimating) return;
selectedHandIndex = selectedHandIndex === index ? null : index;
}
function clickSlot(slot) {
function clickSlot(slot: number) {
if (!isMyTurn || combatAnimating || selectedHandIndex === null) return;
play('cardPlay');
send({ type: 'play_card', hand_index: selectedHandIndex, slot });
selectedHandIndex = null;
}
async function sacrifice(slot) {
async function sacrifice(slot: number) {
if (!isMyTurn || combatAnimating) return;
const card = me.board[slot];
if (!card) return;
play('cardShatter');
destroying = new Set([...destroying, card.instance_id]);
await delay(600);
destroying = new Set([...destroying].filter(id => id !== card.instance_id));
@@ -267,7 +318,7 @@
send({ type: 'end_turn' });
}
function handleHandCardMouseMove(e, node) {
function handleHandCardMouseMove(e: MouseEvent, node: HTMLElement) {
const rect = node.getBoundingClientRect();
const cy = rect.top + rect.height / 2;
const dy = (e.clientY - cy) / (rect.height / 2);
@@ -275,7 +326,7 @@
node.style.setProperty('--peek-y', `${ty}px`);
}
function handleHandCardMouseLeave(node) {
function handleHandCardMouseLeave(node: HTMLElement) {
node.style.setProperty('--peek-y', '0px');
}
@@ -337,10 +388,17 @@
{:else if (phase === 'playing' || (phase === 'ended' && viewingBoard)) && gameState}
<div class="game">
{#if reconnecting}
<div class="reconnecting-banner">Reconnecting...</div>
{/if}
<div class="sidebar left-sidebar">
<div class="sidebar-section top-section">
<div class="sidebar-name opp-name">{opp.username}</div>
{#if opp.user_id === 'ai'}
<span class="sidebar-name opp-name">{opp.username}</span>
{:else}
<a href="/profile/{opp.username}" target="_blank" class="sidebar-name opp-name opp-profile-link">{opp.username}</a>
{/if}
<DeckTypeBadge deckType={opp.deck_type} />
<div class="sidebar-life">{displayedLife[opp.user_id] ?? opp.life}</div>
<div class="sidebar-deck">Deck: {opp.deck_size}</div>
@@ -389,7 +447,7 @@
<div class="divider">
<span class="turn-indicator" class:my-turn={isMyTurn}>
{phase === 'ended' ? 'Game Ended' : isMyTurn ? 'Your turn' : `${opp.username}'s turn`}
{#if phase === 'ended'}Game Ended{:else if isMyTurn}Your turn{:else}{opp.username}'s turn{/if}
</span>
{#if secondsRemaining <= TIMER_WARNING}
<span class="turn-timer" class:urgent={secondsRemaining <= 10}>
@@ -436,7 +494,7 @@
<div class="sidebar right-sidebar">
{#if phase === 'ended'}
<button class="end-turn-btn" onclick={() => viewingBoard = false}>Go Back</button>
<button class="end-turn-btn" onclick={() => { viewingBoard = false; history.replaceState({}, '', '/play'); }}>Go Back</button>
{:else if isMyTurn && !combatAnimating}
<button class="end-turn-btn" onclick={endTurn}>End Turn</button>
{/if}
@@ -472,7 +530,7 @@
<p class="lobby-hint">{gameState.result.reason}</p>
<div class="lobby-buttons">
<button class="play-btn" onclick={() => viewingBoard = true}>View Board</button>
<button class="play-btn" onclick={() => { gameState = null; phase = 'idle'; }}>Go Back</button>
<button class="play-btn" onclick={() => { gameState = null; phase = 'idle'; history.replaceState({}, '', '/play'); }}>Go Back</button>
</div>
</div>
{/if}
@@ -506,12 +564,10 @@
</main>
<style>
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;700;900&family=Crimson+Text:ital,wght@0,400;0,600;1,400&display=swap');
main {
height: 100vh;
height: 100dvh;
overflow: hidden;
background: #0d0a04;
background: var(--color-bg);
display: flex;
flex-direction: column;
}
@@ -529,21 +585,21 @@
.lobby-title {
font-family: 'Cinzel', serif;
font-size: 32px;
font-size: var(--text-3xl);
font-weight: 900;
color: #f0d080;
color: var(--color-gold);
margin: 0;
letter-spacing: 0.1em;
}
.lobby-title.win { color: #6aaa6a; }
.lobby-title.lose { color: #c85050; }
.lobby-title.win { color: var(--color-success); }
.lobby-title.lose { color: var(--color-error); }
.how-to-play-link {
font-family: 'Crimson Text', serif;
font-size: 14px;
font-size: var(--text-base);
font-style: italic;
color: rgba(240, 180, 80, 0.4);
color: var(--color-gold-faint);
text-decoration: underline;
transition: color 0.15s;
margin-top: -1rem;
@@ -553,24 +609,24 @@
.lobby-hint {
font-family: 'Crimson Text', serif;
font-size: 16px;
font-size: var(--text-md);
font-style: italic;
color: rgba(240, 180, 80, 0.6);
color: var(--color-gold-dim);
margin: 0;
text-align: center;
}
.lobby-hint a { color: #f0d080; }
.lobby-hint a { color: var(--color-gold); }
.final-life {
font-family: 'Cinzel', serif;
font-size: 13px;
font-size: var(--text-base);
color: rgba(240, 180, 80, 0.5);
}
.deck-label {
font-family: 'Cinzel', serif;
font-size: 11px;
font-size: var(--text-sm);
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
@@ -583,11 +639,11 @@
select {
font-family: 'Crimson Text', serif;
font-size: 15px;
color: #f0d080;
background: #1a1008;
border: 1.5px solid #6b4c1e;
border-radius: 6px;
font-size: var(--text-md);
color: var(--color-gold);
background: var(--color-surface);
border: 1.5px solid var(--color-border);
border-radius: var(--radius-md);
padding: 8px 12px;
outline: none;
cursor: pointer;
@@ -596,27 +652,28 @@
.play-btn, .cancel-btn {
font-family: 'Cinzel', serif;
font-size: 13px;
font-size: var(--btn-font-lg);
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
padding: 10px 32px;
border-radius: 6px;
padding: var(--btn-padding-lg);
border-radius: var(--radius-md);
cursor: pointer;
transition: background 0.15s;
}
.play-btn {
background: #c8861a;
color: #fff8e0;
border: none;
background: var(--color-surface-raised);
color: var(--color-gold);
border: 1.5px solid var(--color-bronze);
}
.play-btn:hover:not(:disabled) { background: #e09820; }
.play-btn:hover:not(:disabled) { background: #5a3510; }
.play-btn:disabled {
background: #6b4c1e;
color: rgba(255, 248, 224, 0.4);
background: var(--color-surface-raised);
color: rgba(240, 180, 80, 0.3);
border-color: rgba(200, 134, 26, 0.3);
cursor: not-allowed;
}
@@ -626,34 +683,35 @@
}
.solo-btn {
background: #2a3d20;
border: 1px solid #5a8a40;
color: #a8d880;
background: none;
border: 1px solid rgba(107, 76, 30, 0.5);
color: rgba(240, 180, 80, 0.7);
}
.solo-btn:hover:not(:disabled) {
background: #3a5a2a;
border-color: var(--color-bronze);
color: var(--color-gold);
}
.solo-btn:disabled {
background: #1a2510;
color: rgba(168, 216, 128, 0.3);
background: none;
color: rgba(240, 180, 80, 0.25);
cursor: not-allowed;
border-color: #3a5a2a;
border-color: rgba(107, 76, 30, 0.2);
}
.cancel-btn {
background: none;
color: rgba(240, 180, 80, 0.6);
border: 1px solid rgba(107, 76, 30, 0.4);
color: var(--color-gold-dim);
border: 1px solid var(--color-border-subtle);
}
.cancel-btn:hover { border-color: #c8861a; color: #f0d080; }
.cancel-btn:hover { border-color: var(--color-bronze); color: var(--color-gold); }
.error {
font-family: 'Crimson Text', serif;
font-size: 14px;
color: #c85050;
font-size: var(--text-base);
color: var(--color-error);
margin: 0;
height: 1.4em;
margin-top: -1rem;
@@ -664,8 +722,8 @@
width: 40px;
height: 40px;
border: 3px solid rgba(200, 134, 26, 0.2);
border-top-color: #c8861a;
border-radius: 50%;
border-top-color: var(--color-bronze);
border-radius: var(--radius-full);
animation: spin 0.8s linear infinite;
}
@@ -673,6 +731,7 @@
/* ── Game layout ── */
.game {
position: relative;
flex: 1;
display: flex;
flex-direction: row;
@@ -717,7 +776,7 @@
.sidebar-name {
font-family: 'Cinzel', serif;
font-size: 14px;
font-size: var(--text-base);
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
@@ -727,18 +786,20 @@
}
.opp-name { color: rgba(200, 80, 80, 0.8); }
.you-name { color: #c8861a; }
.opp-profile-link { text-decoration: none; transition: color 0.15s; }
.opp-profile-link:hover { color: #e05050; }
.you-name { color: var(--color-bronze); }
.sidebar-life {
font-family: 'Cinzel', serif;
font-size: 30px;
font-size: var(--text-2xl);
font-weight: 700;
color: #f0d080;
color: var(--color-gold);
}
.sidebar-deck, .sidebar-hand {
font-family: 'Crimson Text', serif;
font-size: 16px;
font-size: var(--text-md);
font-style: italic;
color: rgba(240, 180, 80, 0.45);
}
@@ -752,14 +813,14 @@
.cost-bubble-display {
width: 22px;
height: 22px;
border-radius: 50%;
background: #6ea0ec;
border-radius: var(--radius-full);
background: var(--color-energy);
border: 2.5px solid #000;
display: flex;
align-items: center;
justify-content: center;
color: #08152c;
font-size: 13px;
font-size: var(--text-base);
font-weight: 700;
font-family: 'Cinzel', serif;
flex-shrink: 0;
@@ -767,9 +828,9 @@
.energy-count {
font-family: 'Cinzel', serif;
font-size: 18px;
font-size: var(--text-lg);
font-weight: 700;
color: #f0d080;
color: var(--color-gold);
}
.sacrifice-mode-btn {
@@ -777,7 +838,7 @@
color: rgba(107, 76, 30, 1);
border: 2px solid rgba(107, 76, 30, 1);
border-radius: 15px;
font-size: 16px;
font-size: var(--text-md);
cursor: pointer;
padding: 6px 5.5px;
line-height: 1;
@@ -786,32 +847,46 @@
}
.sacrifice-mode-btn:hover {
border-color: #c8861a;
border-color: var(--color-bronze);
background: rgba(200, 134, 26, 0.1);
}
.sacrifice-mode-btn.active {
background: rgba(180, 40, 40, 0.3);
border-color: #c84040;
border-color: var(--color-error);
}
.end-turn-btn {
font-family: 'Cinzel', serif;
font-size: 13px;
font-size: var(--btn-font-lg);
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
background: #c8861a;
background: var(--color-bronze);
border: none;
border-radius: 4px;
color: #fff8e0;
border-radius: var(--radius-md);
color: var(--color-btn-text);
padding: 10px 8px;
width: 100%;
cursor: pointer;
transition: background 0.15s;
margin-top: auto;
touch-action: manipulation;
}
.end-turn-btn:hover { background: #e09820; }
.end-turn-btn:hover { background: var(--color-bronze-hover); }
@media (max-width: 768px) {
.hand {
height: calc(400px * 0.55 + 1rem); /* ~221px instead of ~370px on mobile */
}
.end-turn-btn {
min-height: 44px;
font-size: var(--text-sm);
padding: 8px 6px;
}
}
.sacrifice-overlay {
position: absolute;
@@ -821,9 +896,9 @@
justify-content: center;
font-size: 48px;
background: rgba(180, 40, 40, 0.35);
border-radius: 10px;
border-radius: var(--radius-lg);
cursor: pointer;
z-index: 10;
z-index: var(--z-card);
transition: background 0.15s;
}
@@ -937,23 +1012,23 @@
width: calc(300px * 0.55);
height: calc(400px * 0.55);
border: 1.5px dashed rgba(107, 76, 30, 0.25);
border-radius: 10px;
border-radius: var(--radius-lg);
display: flex;
align-items: center;
justify-content: center;
transition: border-color 0.15s, background 0.15s;
z-index: 1;
z-index: var(--z-base);
}
.empty-slot.highlight {
border-color: #c8861a;
border-color: var(--color-bronze);
background: rgba(200, 134, 26, 0.08);
cursor: pointer;
}
.slot-hint {
font-family: 'Cinzel', serif;
font-size: 9px;
font-size: var(--text-xs);
color: rgba(200, 134, 26, 0.7);
text-transform: uppercase;
letter-spacing: 0.05em;
@@ -1010,14 +1085,14 @@
.turn-timer {
font-family: 'Cinzel', serif;
font-size: 11px;
font-size: var(--text-sm);
font-weight: 700;
color: rgba(240, 180, 80, 0.6);
color: var(--color-gold-dim);
transition: color 0.3s;
}
.turn-timer.urgent {
color: #c85050;
color: var(--color-error);
animation: pulse 0.8s ease-in-out infinite;
}
@@ -1028,7 +1103,7 @@
.turn-indicator {
font-family: 'Cinzel', serif;
font-size: 13px;
font-size: var(--text-base);
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
@@ -1036,7 +1111,7 @@
transition: color 0.3s;
}
.turn-indicator.my-turn { color: #c8861a; }
.turn-indicator.my-turn { color: var(--color-bronze); }
/* ── Hand ── */
.hand {
@@ -1048,7 +1123,7 @@
padding: 0.5rem;
overflow-x: auto;
overflow-y: hidden;
border-top: 1px solid rgba(107, 76, 30, 0.3);
border-top: 1px solid var(--color-border-dim);
background: rgba(0,0,0,0.3);
height: calc(400px * 0.9 + 1rem);
}
@@ -1081,19 +1156,19 @@
.hand-card:hover:not(:disabled) :global(.card) {
transform: scale(1.1) translate(-50px, calc(var(--peek-y) - 80px)) !important;
transform-origin: top left !important;
z-index: 50 !important;
z-index: 50;
}
.hand-card.selected {
/* transform: translateY(-16px); */
filter: drop-shadow(0 0 8px rgba(200, 134, 26, 0.9));
z-index: 25 !important;
z-index: 25;
}
.hand-card.selected :global(.card) {
/* transform: scale(1.) translate(-30px, calc(var(--peek-y) - 80px)) !important; */
/* transform-origin: top left !important; */
z-index: 50 !important;
z-index: 50;
filter: drop-shadow(0 0 8px rgba(200, 134, 26, 0.9));
}
@@ -1108,13 +1183,13 @@
display: flex;
align-items: center;
justify-content: center;
z-index: 200;
z-index: var(--z-modal);
}
.modal {
background: #110d04;
border: 1.5px solid #6b4c1e;
border-radius: 10px;
border: 1.5px solid var(--color-border);
border-radius: var(--radius-lg);
padding: 2rem 2.5rem;
display: flex;
flex-direction: column;
@@ -1125,9 +1200,9 @@
.modal-title {
font-family: 'Cinzel', serif;
font-size: 20px;
font-size: var(--text-xl);
font-weight: 700;
color: #f0d080;
color: var(--color-gold);
margin: 0;
letter-spacing: 0.08em;
}
@@ -1143,20 +1218,20 @@
font-family: 'Cinzel', serif;
font-size: 48px;
font-weight: 900;
color: #f0d080;
color: var(--color-gold);
line-height: 1;
}
.difficulty-label {
font-family: 'Crimson Text', serif;
font-size: 16px;
font-size: var(--text-md);
font-style: italic;
color: rgba(240, 180, 80, 0.6);
color: var(--color-gold-dim);
}
.difficulty-slider {
width: 100%;
accent-color: #c8861a;
accent-color: var(--color-bronze);
cursor: pointer;
}
@@ -1165,8 +1240,8 @@
display: flex;
justify-content: space-between;
font-family: 'Cinzel', serif;
font-size: 10px;
color: rgba(240, 180, 80, 0.4);
font-size: var(--text-xs);
color: var(--color-gold-faint);
margin-top: -0.75rem;
}
@@ -1185,10 +1260,34 @@
background: rgba(180, 40, 40, 0.9);
color: #fff;
font-family: 'Cinzel', serif;
font-size: 12px;
font-size: var(--text-sm);
padding: 8px 20px;
border-radius: 6px;
border-radius: var(--radius-md);
pointer-events: none;
z-index: 100;
z-index: var(--z-toast);
}
.reconnecting-banner {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: rgba(13, 10, 4, 0.85);
border: 1px solid rgba(200, 134, 26, 0.5);
border-radius: var(--radius-md);
padding: 10px 24px;
font-family: 'Cinzel', serif;
font-size: var(--text-base);
font-weight: 700;
letter-spacing: 0.08em;
color: var(--color-gold-muted);
z-index: 50;
pointer-events: none;
animation: fade-pulse 1.4s ease-in-out infinite;
}
@keyframes fade-pulse {
0%, 100% { opacity: 0.5; }
50% { opacity: 1; }
}
</style>
+591 -56
View File
@@ -1,11 +1,25 @@
<script>
<script lang="ts">
import { API_URL, WS_URL } from '$lib/api.js';
import { apiFetch } from '$lib/api.js';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
let profile = $state(null);
let profile: any = $state(null);
let loading = $state(true);
let wishlistText = $state('');
let wishlistSaving = $state(false);
let wishlistSaved = $state(false);
let wishlistError = $state('');
let friends: any[] = $state([]);
let proposals: any[] = $state([]);
let challenges: any[] = $state([]);
let confirmingRemove: Set<string> = $state(new Set());
let confirmingWithdraw: Set<string> = $state(new Set());
// Increments every second — passed to formatChallengeExpiry to force re-evaluation
let tick = $state(0);
const token = () => localStorage.getItem('token');
@@ -14,9 +28,81 @@
const res = await apiFetch(`${API_URL}/profile`);
if (res.status === 401) { goto('/auth'); return; }
profile = await res.json();
wishlistText = profile.trade_wishlist || '';
const friendsRes = await apiFetch(`${API_URL}/friends`);
if (friendsRes.ok) friends = await friendsRes.json();
const proposalsRes = await apiFetch(`${API_URL}/trade-proposals`);
if (proposalsRes.ok) proposals = await proposalsRes.json();
const challengesRes = await apiFetch(`${API_URL}/challenges`);
if (challengesRes.ok) challenges = await challengesRes.json();
loading = false;
const tickInterval = setInterval(() => { tick++; }, 1000);
const pollInterval = setInterval(async () => {
const [pRes, cRes] = await Promise.all([
apiFetch(`${API_URL}/trade-proposals`),
apiFetch(`${API_URL}/challenges`),
]);
if (pRes.ok) proposals = await pRes.json();
if (cRes.ok) challenges = await cRes.json();
}, 30_000);
return () => {
clearInterval(tickInterval);
clearInterval(pollInterval);
};
});
function formatExpiry(isoString: string) {
const d = new Date(isoString);
const diff = d.getTime() - Date.now();
if (diff < 0) return 'expired';
const hrs = Math.floor(diff / 3600000);
if (hrs < 1) return 'in < 1h';
if (hrs < 24) return `in ${hrs}h`;
return `in ${Math.floor(hrs / 24)}d`;
}
async function saveWishlist() {
wishlistSaving = true;
wishlistError = '';
wishlistSaved = false;
const res = await apiFetch(`${API_URL}/profile`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ trade_wishlist: wishlistText }),
});
wishlistSaving = false;
if (res.ok) {
wishlistSaved = true;
setTimeout(() => { wishlistSaved = false; }, 2500);
} else {
wishlistError = 'Failed to save.';
}
}
function formatChallengeExpiry(isoString: string, _tick?: number) {
const secs = Math.max(0, Math.floor((new Date(isoString).getTime() - Date.now()) / 1000));
if (secs <= 0) return 'expired';
if (secs < 60) return `${secs}s remaining`;
return `${Math.floor(secs / 60)}m ${secs % 60}s remaining`;
}
async function withdrawChallenge(challengeId: string) {
const res = await apiFetch(`${API_URL}/challenges/${challengeId}/decline`, { method: 'POST' });
if (res.ok) challenges = challenges.filter((c: any) => c.id !== challengeId);
}
async function removeFriend(friendshipId: string) {
await apiFetch(`${API_URL}/friendships/${friendshipId}`, { method: 'DELETE' });
friends = friends.filter((f: any) => f.friendship_id !== friendshipId);
}
function logout() {
localStorage.removeItem('token');
localStorage.removeItem('refresh_token');
@@ -69,7 +155,7 @@
<span class="shards-icon"></span>
<span class="shards-value">{profile.shards}</span>
<span class="shards-label">Shards</span>
<a href="/shards" class="shards-link">shatter cards</a>
<a href="/shatter" class="shards-link">shatter cards</a>
</div>
<div class="section-divider"></div>
@@ -98,6 +184,28 @@
<div class="section-divider"></div>
<div class="wishlist-group">
<h2 class="section-title">Trade Wishlist</h2>
<div class="wishlist-section">
<p class="wishlist-hint">Cards or types you're looking to trade for. Visible on your public profile.</p>
<textarea
class="wishlist-textarea"
bind:value={wishlistText}
placeholder="e.g. Looking for legendary locations, rare scientists..."
rows="3"
></textarea>
<div class="wishlist-actions">
<button class="save-btn" onclick={saveWishlist} disabled={wishlistSaving}>
{wishlistSaving ? 'Saving...' : 'Save'}
</button>
{#if wishlistSaved}<span class="wishlist-ok">Saved ✓</span>{/if}
<p class="wishlist-error" style="min-height: 1.2em">{wishlistError}</p>
</div>
</div>
</div>
<div class="section-divider"></div>
<h2 class="section-title">Highlights</h2>
<div class="highlights">
<div class="highlight-card">
@@ -129,17 +237,161 @@
</div>
</div>
<div class="section-divider"></div>
<h2 class="section-title">Friends</h2>
{#if friends.length === 0}
<p class="no-friends">No friends yet.</p>
{:else}
<ul class="friends-list">
{#each friends as f (f.friendship_id)}
<li class="friend-item">
<a href="/profile/{f.username}" class="friend-name">{f.username}</a>
{#if confirmingRemove.has(f.friendship_id)}
<span class="confirm-label">Remove friend?</span>
<button class="confirm-yes-btn" onclick={() => removeFriend(f.friendship_id)}>Confirm</button>
<button class="confirm-no-btn" onclick={() => { confirmingRemove.delete(f.friendship_id); confirmingRemove = confirmingRemove; }}>Cancel</button>
{:else}
<button class="unfriend-btn" onclick={() => { confirmingRemove.add(f.friendship_id); confirmingRemove = confirmingRemove; }}>Remove</button>
{/if}
</li>
{/each}
</ul>
{/if}
<div class="section-divider"></div>
<h2 class="section-title">Trade Proposals</h2>
{#if proposals.length === 0}
<p class="no-friends">No trade proposals.</p>
{:else}
{@const incoming = proposals.filter((p: any) => p.direction === 'incoming' && p.status === 'pending')}
{@const outgoing = proposals.filter((p: any) => p.direction === 'outgoing' && p.status === 'pending')}
{@const resolved = proposals.filter((p: any) => p.status !== 'pending')}
{#if incoming.length > 0}
<p class="proposal-subhead">Incoming</p>
{#each incoming as p (p.id)}
<div class="proposal-card">
<div class="proposal-meta">
<a href="/profile/{p.proposer_username}" target="_blank" class="friend-name">{p.proposer_username}</a>
<span class="proposal-status pending">Pending</span>
<span class="proposal-expires">{formatExpiry(p.expires_at)}</span>
</div>
<p class="proposal-desc">
{#if p.requested_cards.length > 0}Wants: <strong>{p.requested_cards.map((c: any) => c.name).join(', ')}</strong><br/>{/if}
{#if p.offered_cards.length > 0}Offering: {p.offered_cards.map((c: any) => c.name).join(', ')}{/if}
</p>
<a href="/trade/proposal/{p.id}" class="see-proposal-btn">See Proposal</a>
</div>
{/each}
{/if}
{#if outgoing.length > 0}
<p class="proposal-subhead">Outgoing</p>
{#each outgoing as p (p.id)}
<div class="proposal-card">
<div class="proposal-meta">
<span class="proposal-to">To: <a href="/profile/{p.recipient_username}" target="_blank" class="friend-name">{p.recipient_username}</a></span>
<span class="proposal-status pending">Pending</span>
<span class="proposal-expires">{formatExpiry(p.expires_at)}</span>
</div>
<p class="proposal-desc">
{#if p.requested_cards.length > 0}Requesting: <strong>{p.requested_cards.map((c: any) => c.name).join(', ')}</strong><br/>{/if}
{#if p.offered_cards.length > 0}Offering: {p.offered_cards.map((c: any) => c.name).join(', ')}{/if}
</p>
<a href="/trade/proposal/{p.id}" class="see-proposal-btn">See Proposal</a>
</div>
{/each}
{/if}
{#if resolved.length > 0}
<p class="proposal-subhead">History</p>
{#each resolved as p (p.id)}
<div class="proposal-card resolved">
<div class="proposal-meta">
<span class="proposal-to">{p.direction === 'incoming' ? `From: ${p.proposer_username}` : `To: ${p.recipient_username}`}</span>
<span class="proposal-status {p.status}">{p.status}</span>
</div>
<p class="proposal-desc">{p.requested_cards.length} requested · {p.offered_cards.length} offered</p>
<a href="/trade/proposal/{p.id}" class="see-proposal-btn">See Proposal</a>
</div>
{/each}
{/if}
{/if}
{#if challenges.length > 0}
<div class="section-divider"></div>
<h2 class="section-title">Game Challenges</h2>
{@const pendingOut = challenges.filter((c: any) => c.direction === 'outgoing' && c.status === 'pending')}
{@const pendingIn = challenges.filter((c: any) => c.direction === 'incoming' && c.status === 'pending')}
{@const resolvedC = challenges.filter((c: any) => c.status !== 'pending')}
{#if pendingOut.length > 0}
<p class="proposal-subhead">Sent</p>
{#each pendingOut as c (c.id)}
<div class="proposal-card">
<div class="proposal-meta">
<span class="proposal-to">To: <a href="/profile/{c.challenged_username}" target="_blank" class="friend-name">{c.challenged_username}</a></span>
<span class="proposal-status pending">Awaiting response</span>
<span class="proposal-expires">{formatChallengeExpiry(c.expires_at, tick)}</span>
</div>
<p class="proposal-desc">Deck: <strong>{c.deck_name}</strong></p>
{#if confirmingWithdraw.has(c.id)}
<span class="confirm-label">Withdraw challenge?</span>
<button class="confirm-yes-btn" onclick={() => withdrawChallenge(c.id)}>Confirm</button>
<button class="confirm-no-btn" onclick={() => { confirmingWithdraw.delete(c.id); confirmingWithdraw = confirmingWithdraw; }}>Cancel</button>
{:else}
<button class="withdraw-btn" onclick={() => { confirmingWithdraw.add(c.id); confirmingWithdraw = confirmingWithdraw; }}>Withdraw</button>
{/if}
</div>
{/each}
{/if}
{#if pendingIn.length > 0}
<p class="proposal-subhead">Incoming</p>
{#each pendingIn as c (c.id)}
<div class="proposal-card">
<div class="proposal-meta">
<a href="/profile/{c.challenger_username}" target="_blank" class="friend-name">{c.challenger_username}</a>
<span class="proposal-status pending">Pending</span>
<span class="proposal-expires">{formatChallengeExpiry(c.expires_at, tick)}</span>
</div>
<p class="proposal-desc">Their deck: <strong>{c.deck_name}</strong></p>
<p class="proposal-desc">Check your notification bell to accept.</p>
</div>
{/each}
{/if}
{#if resolvedC.length > 0}
<p class="proposal-subhead">History</p>
{#each resolvedC as c (c.id)}
<div class="proposal-card resolved">
<div class="proposal-meta">
<span class="proposal-to">
{c.direction === 'outgoing' ? `To: ${c.challenged_username}` : `From: ${c.challenger_username}`}
</span>
<span class="proposal-status {c.status}">{c.status}</span>
</div>
</div>
{/each}
{/if}
{/if}
</div>
{/if}
</main>
<style>
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;700&family=Crimson+Text:ital,wght@0,400;0,600;1,400&display=swap');
main {
height: 100vh;
overflow-y: auto;
background: #0d0a04;
background: var(--color-bg);
padding: 2rem;
}
@@ -149,6 +401,7 @@
display: flex;
flex-direction: column;
gap: 1.5rem;
padding-bottom: 5rem;
}
.profile-header {
@@ -160,16 +413,16 @@
.avatar {
width: 64px;
height: 64px;
border-radius: 50%;
background: #3d2507;
border: 2px solid #c8861a;
border-radius: var(--radius-full);
background: var(--color-surface-raised);
border: 2px solid var(--color-bronze);
display: flex;
align-items: center;
justify-content: center;
font-family: 'Cinzel', serif;
font-size: 28px;
font-size: var(--text-2xl);
font-weight: 700;
color: #f0d080;
color: var(--color-gold);
flex-shrink: 0;
}
@@ -182,22 +435,22 @@
.username {
font-family: 'Cinzel', serif;
font-size: 24px;
font-size: var(--text-xl);
font-weight: 700;
color: #f0d080;
color: var(--color-gold);
margin: 0;
}
.email {
font-family: 'Crimson Text', serif;
font-size: 15px;
font-size: var(--text-md);
color: rgba(240, 180, 80, 0.5);
margin: 0;
}
.unverified-badge {
font-family: 'Cinzel', serif;
font-size: 9px;
font-size: var(--text-xs);
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
@@ -211,7 +464,7 @@
.resend-btn {
font-family: 'Crimson Text', serif;
font-size: 13px;
font-size: var(--text-base);
font-style: italic;
color: rgba(240, 180, 80, 0.5);
background: none;
@@ -228,7 +481,7 @@
.joined {
font-family: 'Crimson Text', serif;
font-size: 13px;
font-size: var(--text-base);
font-style: italic;
color: rgba(240, 180, 80, 0.35);
margin: 0;
@@ -236,29 +489,29 @@
.logout-btn {
font-family: 'Cinzel', serif;
font-size: 11px;
font-size: var(--btn-font-md);
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
background: none;
border: 1px solid rgba(180, 60, 60, 0.4);
border-radius: 4px;
border-radius: var(--radius-sm);
color: rgba(200, 80, 80, 0.7);
padding: 8px 16px;
padding: var(--btn-padding-md);
cursor: pointer;
transition: all 0.15s;
align-self: flex-start;
}
.logout-btn:hover {
border-color: #c84040;
border-color: var(--color-error);
color: #e05050;
background: rgba(180, 40, 40, 0.1);
}
.reset-link {
font-family: 'Crimson Text', serif;
font-size: 14px;
font-size: var(--text-base);
font-style: italic;
color: rgba(240, 180, 80, 0.4);
text-decoration: underline;
@@ -276,38 +529,39 @@
.shards-link {
font-family: 'Cinzel', serif;
font-size: 10px;
font-size: var(--text-xs);
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: rgba(126, 207, 207, 0.6);
border: 1px solid rgba(126, 207, 207, 0.3);
border-radius: 4px;
border-radius: var(--radius-sm);
padding: 3px 8px;
text-decoration: none;
margin-top: 4px;
margin-left: 0.5rem;
transition: color 0.15s, border-color 0.15s;
}
.shards-link:hover { color: #7ecfcf; border-color: rgba(126, 207, 207, 0.7); }
.shards-link:hover { color: var(--color-cyan); border-color: rgba(126, 207, 207, 0.7); }
.shards-icon {
font-size: 22px;
color: #7ecfcf;
font-size: var(--text-xl);
color: var(--color-cyan);
position: relative;
top: -0.1em;
animation: shard-pulse 3s ease-in-out infinite;
}
.shards-value {
font-family: 'Cinzel', serif;
font-size: 28px;
font-size: var(--text-2xl);
font-weight: 700;
color: #7ecfcf;
color: var(--color-cyan);
}
.shards-label {
font-family: 'Cinzel', serif;
font-size: 11px;
font-size: var(--text-sm);
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
@@ -317,16 +571,16 @@
.section-divider {
height: 1px;
background: rgba(107, 76, 30, 0.3);
background: var(--color-border-dim);
}
.section-title {
font-family: 'Cinzel', serif;
font-size: 13px;
font-size: var(--text-base);
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: rgba(240, 180, 80, 0.4);
color: var(--color-gold-faint);
margin: 0;
}
@@ -337,9 +591,9 @@
}
.stat-card {
background: #1a1008;
border: 1px solid rgba(107, 76, 30, 0.3);
border-radius: 8px;
background: var(--color-surface);
border: 1px solid var(--color-border-dim);
border-radius: var(--radius-lg);
padding: 1rem;
display: flex;
flex-direction: column;
@@ -348,24 +602,24 @@
.stat-label {
font-family: 'Cinzel', serif;
font-size: 9px;
font-size: var(--text-xs);
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: rgba(240, 180, 80, 0.4);
color: var(--color-gold-faint);
}
.stat-value {
font-family: 'Cinzel', serif;
font-size: 28px;
font-size: var(--text-2xl);
font-weight: 700;
color: #f0d080;
color: var(--color-gold);
}
.wins { color: #6aaa6a; }
.losses { color: #c85050; }
.good-wr { color: #6aaa6a; }
.bad-wr { color: #c85050; }
.wins { color: var(--color-success); }
.losses { color: var(--color-error); }
.good-wr { color: var(--color-success); }
.bad-wr { color: var(--color-error); }
.highlights {
display: grid;
@@ -374,9 +628,9 @@
}
.highlight-card {
background: #1a1008;
border: 1px solid rgba(107, 76, 30, 0.3);
border-radius: 8px;
background: var(--color-surface);
border: 1px solid var(--color-border-dim);
border-radius: var(--radius-lg);
padding: 1rem;
display: flex;
flex-direction: column;
@@ -385,23 +639,23 @@
.highlight-label {
font-family: 'Cinzel', serif;
font-size: 9px;
font-size: var(--text-xs);
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: rgba(240, 180, 80, 0.4);
color: var(--color-gold-faint);
}
.highlight-value {
font-family: 'Cinzel', serif;
font-size: 16px;
font-size: var(--text-md);
font-weight: 700;
color: #f0d080;
color: var(--color-gold);
}
.highlight-sub {
font-family: 'Crimson Text', serif;
font-size: 13px;
font-size: var(--text-base);
font-style: italic;
color: rgba(240, 180, 80, 0.45);
}
@@ -418,8 +672,8 @@
height: 48px;
object-fit: cover;
object-position: top;
border-radius: 4px;
border: 1px solid rgba(107, 76, 30, 0.4);
border-radius: var(--radius-sm);
border: 1px solid var(--color-border-subtle);
flex-shrink: 0;
}
@@ -429,22 +683,303 @@
gap: 0.2rem;
}
.wishlist-group {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.wishlist-section {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.wishlist-hint {
font-family: 'Crimson Text', serif;
font-size: var(--text-base);
font-style: italic;
color: rgba(240, 180, 80, 0.35);
margin: 0;
}
.wishlist-textarea {
width: 100%;
background: var(--color-surface);
border: 1px solid var(--color-border-subtle);
border-radius: var(--radius-md);
color: var(--color-gold);
font-family: 'Crimson Text', serif;
font-size: var(--text-md);
padding: 0.6rem 0.75rem;
resize: vertical;
box-sizing: border-box;
transition: border-color 0.15s;
outline: none;
}
.wishlist-textarea:focus { border-color: var(--color-bronze); }
.wishlist-textarea::placeholder { color: rgba(240, 180, 80, 0.25); }
.wishlist-actions {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.save-btn {
font-family: 'Cinzel', serif;
font-size: var(--btn-font-md);
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
background: var(--color-surface-raised);
border: 1px solid var(--color-bronze);
border-radius: var(--radius-sm);
color: var(--color-btn-text);
padding: var(--btn-padding-md);
cursor: pointer;
transition: all 0.15s;
}
.save-btn:hover:not(:disabled) { background: #4d3010; }
.save-btn:disabled { opacity: 0.5; cursor: default; }
.wishlist-ok {
font-family: 'Crimson Text', serif;
font-size: var(--text-base);
color: var(--color-success);
}
.wishlist-error {
font-family: 'Crimson Text', serif;
font-size: var(--text-base);
color: var(--color-error);
margin: 0;
}
.no-friends {
font-family: 'Crimson Text', serif;
font-size: var(--text-base);
font-style: italic;
color: rgba(240, 180, 80, 0.25);
margin: 0;
}
.friends-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.friend-item {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.5rem 0.75rem;
background: var(--color-surface);
border: 1px solid var(--color-border-dim);
border-radius: var(--radius-md);
}
.friend-name {
font-family: 'Cinzel', serif;
font-size: var(--text-base);
font-weight: 700;
color: var(--color-gold);
text-decoration: none;
flex: 1;
transition: color 0.15s;
}
.friend-name:hover { color: var(--color-bronze); }
.unfriend-btn {
font-family: 'Cinzel', serif;
font-size: var(--btn-font-md);
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
background: rgba(180, 40, 40, 0.8);
border: none;
border-radius: var(--radius-md);
color: #fff;
padding: var(--btn-padding-md);
cursor: pointer;
transition: background 0.15s;
}
.unfriend-btn:hover { background: rgba(210, 50, 50, 0.9); }
.no-data {
font-family: 'Crimson Text', serif;
font-size: 14px;
font-size: var(--text-base);
font-style: italic;
color: rgba(240, 180, 80, 0.25);
}
/* ── Trade Proposals ── */
.proposal-subhead {
font-family: 'Cinzel', serif;
font-size: var(--text-xs);
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: rgba(240, 180, 80, 0.3);
margin: 0.5rem 0 0;
}
.proposal-card {
background: var(--color-surface);
border: 1px solid var(--color-border-dim);
border-radius: var(--radius-lg);
padding: 0.85rem 1rem;
display: flex;
flex-direction: column;
gap: 0.45rem;
}
.proposal-card.resolved { opacity: 0.5; }
.proposal-meta {
display: flex;
align-items: center;
gap: 0.6rem;
flex-wrap: wrap;
}
.proposal-to {
font-family: 'Crimson Text', serif;
font-size: var(--text-base);
color: rgba(240, 180, 80, 0.5);
}
.proposal-status {
font-family: 'Cinzel', serif;
font-size: var(--text-xs);
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
border-radius: 3px;
padding: 1px 5px;
border: 1px solid;
}
.proposal-status.pending { color: var(--color-bronze); border-color: rgba(200, 134, 26, 0.4); }
.proposal-status.accepted { color: var(--color-success); border-color: rgba(106, 170, 106, 0.4); }
.proposal-status.declined { color: var(--color-error); border-color: rgba(200, 64, 64, 0.4); }
.proposal-status.expired { color: rgba(240, 180, 80, 0.3); border-color: var(--color-border-dim); }
.proposal-status.withdrawn { color: rgba(240, 180, 80, 0.3); border-color: var(--color-border-dim); }
.proposal-expires {
font-family: 'Crimson Text', serif;
font-size: var(--text-sm);
font-style: italic;
color: rgba(240, 180, 80, 0.3);
margin-left: auto;
}
.proposal-desc {
font-family: 'Crimson Text', serif;
font-size: var(--text-base);
color: rgba(240, 180, 80, 0.65);
margin: 0;
line-height: 1.5;
}
.proposal-desc strong { color: var(--color-gold); font-style: normal; font-weight: 600; }
.see-proposal-btn {
font-family: 'Cinzel', serif;
font-size: var(--btn-font-md);
font-weight: 700;
letter-spacing: 0.07em;
text-transform: uppercase;
background: var(--color-surface-raised);
border: 1px solid var(--color-border-subtle);
border-radius: var(--radius-md);
color: var(--color-gold);
padding: var(--btn-padding-md);
text-decoration: none;
display: inline-block;
transition: all 0.15s;
margin-top: 0.25rem;
align-self: flex-start;
}
.see-proposal-btn:hover { border-color: var(--color-bronze); background: #4d3010; }
.withdraw-btn {
font-family: 'Cinzel', serif;
font-size: var(--btn-font-sm);
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
background: none;
border: 1px solid rgba(180, 60, 60, 0.4);
border-radius: var(--radius-sm);
color: rgba(200, 80, 80, 0.6);
padding: var(--btn-padding-sm);
cursor: pointer;
transition: all 0.15s;
align-self: flex-start;
}
.withdraw-btn:hover { border-color: var(--color-error); color: #e05050; }
.status {
font-family: 'Crimson Text', serif;
font-size: 16px;
font-size: var(--text-md);
font-style: italic;
color: rgba(240, 180, 80, 0.5);
text-align: center;
margin-top: 4rem;
}
.confirm-label {
font-family: 'Crimson Text', serif;
font-size: var(--text-base);
font-style: italic;
color: rgba(200, 80, 80, 0.8);
}
.confirm-yes-btn {
font-family: 'Cinzel', serif;
font-size: var(--btn-font-sm);
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
background: rgba(180, 40, 40, 0.8);
border: none;
border-radius: var(--radius-sm);
color: #fff;
padding: var(--btn-padding-sm);
cursor: pointer;
transition: background 0.15s;
}
.confirm-yes-btn:hover { background: rgba(210, 50, 50, 0.9); }
.confirm-no-btn {
font-family: 'Cinzel', serif;
font-size: var(--btn-font-sm);
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
background: none;
border: 1px solid var(--color-border-subtle);
border-radius: var(--radius-sm);
color: var(--color-gold-faint);
padding: var(--btn-padding-sm);
cursor: pointer;
transition: all 0.15s;
}
.confirm-no-btn:hover { border-color: rgba(107, 76, 30, 0.7); color: rgba(240, 180, 80, 0.7); }
@media (max-width: 640px) {
.stats-grid { grid-template-columns: repeat(2, 1fr); }
.highlights { grid-template-columns: 1fr; }
@@ -0,0 +1,832 @@
<script lang="ts">
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import { get } from 'svelte/store';
import { API_URL, apiFetch } from '$lib/api.js';
import Card from '$lib/Card.svelte';
// Cards shown when collapsed (1 visual row at scale 0.62)
const ROW_SIZE = 4;
function formatLastActive(iso: string | null): string {
if (!iso) return '';
const diff = Math.floor((Date.now() - new Date(iso + 'Z').getTime()) / 1000);
if (diff < 60) return 'Active just now';
if (diff < 3600) return `Active ${Math.floor(diff / 60)}m ago`;
if (diff < 86400) return `Active ${Math.floor(diff / 3600)}h ago`;
const days = Math.floor(diff / 86400);
if (days < 30) return `Active ${days}d ago`;
if (days < 365) return `Active ${Math.floor(days / 30)}mo ago`;
return `Active ${Math.floor(days / 365)}y ago`;
}
let profile: any = $state(null);
let loading = $state(true);
let notFound = $state(false);
let favExpanded = $state(false);
let wttExpanded = $state(false);
// 'idle' | 'pending' | 'pending_received' | 'friends' — friendship status with this user
let friendStatus: 'idle' | 'pending' | 'pending_received' | 'friends' = $state('idle');
let friendshipId: string | null = $state(null);
let isLoggedIn = $state(false);
let sendingFriendRequest = $state(false);
// Challenge state
let showChallengeModal = $state(false);
let challengeDecks: any[] = $state([]);
let selectedDeckId = $state('');
let challengeStatus: 'idle' | 'sending' | 'sent' | 'error' = $state('idle');
let challengeError = $state('');
let favSectionEl: HTMLElement | null = $state(null);
let wttSectionEl: HTMLElement | null = $state(null);
const visibleFav = $derived(
profile ? (favExpanded ? profile.favorite_cards : profile.favorite_cards.slice(0, ROW_SIZE)) : []
);
const visibleWtt = $derived(
profile ? (wttExpanded ? profile.wtt_cards : profile.wtt_cards.slice(0, ROW_SIZE)) : []
);
// Collapse without the page jumping: lock the section's viewport position before and after
function collapseSection(sectionEl: HTMLElement | null, setter: () => void) {
if (!sectionEl) { setter(); return; }
const beforeTop = sectionEl.getBoundingClientRect().top;
setter();
requestAnimationFrame(() => {
const afterTop = sectionEl.getBoundingClientRect().top;
window.scrollBy(0, afterTop - beforeTop);
});
}
async function sendFriendRequest() {
sendingFriendRequest = true;
try {
const res = await apiFetch(`${API_URL}/users/${profile.username}/friend-request`, { method: 'POST' });
if (res.ok) friendStatus = 'pending';
} finally {
sendingFriendRequest = false;
}
}
async function openChallengeModal() {
if (!challengeDecks.length) {
const res = await apiFetch(`${API_URL}/decks`);
if (res.ok) {
const data = await res.json();
challengeDecks = data.filter((d: any) => !d.deleted);
if (challengeDecks.length) selectedDeckId = challengeDecks[0].id;
}
}
challengeStatus = 'idle';
challengeError = '';
showChallengeModal = true;
}
async function sendChallenge() {
if (!selectedDeckId) return;
challengeStatus = 'sending';
challengeError = '';
const res = await apiFetch(`${API_URL}/users/${profile.username}/challenge`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ deck_id: selectedDeckId }),
});
if (res.ok) {
challengeStatus = 'sent';
} else {
const data = await res.json().catch(() => ({}));
challengeError = data.detail || 'Failed to send challenge';
challengeStatus = 'error';
}
}
async function removeFriendFromProfile() {
if (!friendshipId) return;
await apiFetch(`${API_URL}/friendships/${friendshipId}`, { method: 'DELETE' });
friendStatus = 'idle';
friendshipId = null;
}
onMount(async () => {
const username = get(page).params.username;
const token = localStorage.getItem('token');
const res = await fetch(`${API_URL}/users/${username}`);
if (res.status === 404) { notFound = true; loading = false; return; }
const data = await res.json();
// Redirect to own profile if logged-in user visits their own public page
if (token) {
try {
const meRes = await fetch(`${API_URL}/profile`, {
headers: { Authorization: `Bearer ${token}` }
});
if (meRes.ok) {
const me = await meRes.json();
if (me.username === username) { goto('/profile'); return; }
isLoggedIn = true;
// Check existing friendship status
const statusRes = await apiFetch(`${API_URL}/friendship-status/${username}`);
if (statusRes.ok) {
const s = await statusRes.json();
if (s.status === 'friends') { friendStatus = 'friends'; friendshipId = s.friendship_id; }
else if (s.status === 'pending_sent') { friendStatus = 'pending'; }
else if (s.status === 'pending_received') { friendStatus = 'pending_received'; friendshipId = s.friendship_id; }
}
}
} catch { /* non-critical */ }
}
profile = data;
loading = false;
});
</script>
{#if loading}
<main class="page">
<p class="status-text">Loading...</p>
</main>
{:else if notFound}
<main class="page">
<div class="not-found">
<div class="not-found-sigil"></div>
<h1 class="not-found-title">Unknown Adventurer</h1>
<p class="not-found-sub">No record found for this username.</p>
<a href="/" class="btn-secondary">Return Home</a>
</div>
</main>
{:else if profile}
<main class="page">
<div class="profile-wrap">
<!-- ═══ HEADER ═══ -->
<header class="profile-header">
<div class="avatar-col">
<div class="avatar">{profile.username[0].toUpperCase()}</div>
</div>
<div class="header-body">
<div class="ornament-line"></div>
<h1 class="username">{profile.username}</h1>
<div class="ornament-line"></div>
{#if profile.last_active_at}
<p class="last-active">{formatLastActive(profile.last_active_at)}</p>
{/if}
<div class="stats-row">
<div class="stat">
<span class="stat-num wins">{profile.wins}</span>
<span class="stat-label">Wins</span>
</div>
<div class="stat-sep">·</div>
<div class="stat">
<span class="stat-num losses">{profile.losses}</span>
<span class="stat-label">Losses</span>
</div>
<div class="stat-sep">·</div>
<div class="stat">
<span class="stat-num" class:good-wr={profile.win_rate !== null && profile.win_rate >= 50} class:bad-wr={profile.win_rate !== null && profile.win_rate < 50}>
{profile.win_rate !== null ? `${profile.win_rate}%` : '—'}
</span>
<span class="stat-label">Win Rate</span>
</div>
</div>
{#if isLoggedIn}
<div class="friend-actions">
{#if friendStatus === 'idle'}
<button class="btn-friend" onclick={sendFriendRequest} disabled={sendingFriendRequest}>{sendingFriendRequest ? 'Adding...' : '+ Add Friend'}</button>
{:else if friendStatus === 'pending'}
<span class="friend-pending">Request Sent</span>
{:else if friendStatus === 'pending_received'}
<span class="friend-pending">Sent you a request</span>
{:else if friendStatus === 'friends'}
<span class="friend-status">Friends</span>
<button class="btn-unfriend" onclick={removeFriendFromProfile}>Remove</button>
{/if}
</div>
{/if}
</div>
</header>
<!-- ═══ TRADE WISHLIST ═══ -->
{#if profile.trade_wishlist}
<section class="section">
<h2 class="section-title">Looking For</h2>
<blockquote class="wishlist-block">
{profile.trade_wishlist}
</blockquote>
</section>
{/if}
<!-- ═══ FAVORITES ═══ -->
{#if profile.favorite_cards?.length > 0}
{@const hasFavMore = profile.favorite_cards.length > ROW_SIZE}
<section class="section" bind:this={favSectionEl}>
<!-- Header always same height: collapse button invisible when not needed -->
<div class="section-header">
<h2 class="section-title">Favorites</h2>
<button
class="expand-btn"
style:visibility={hasFavMore && favExpanded ? 'visible' : 'hidden'}
onclick={() => collapseSection(favSectionEl, () => { favExpanded = false; })}
>Collapse ▲</button>
</div>
<div class="card-grid">
{#each visibleFav as card (card.id)}
<div class="card-wrap">
<div class="card-inner"><Card {card} /></div>
</div>
{/each}
</div>
{#if hasFavMore}
{#if !favExpanded}
<button class="expand-btn" onclick={() => { favExpanded = true; }}>
Show all {profile.favorite_cards.length} cards ▼
</button>
{:else}
<button class="expand-btn" onclick={() => collapseSection(favSectionEl, () => { favExpanded = false; })}>
Collapse ▲
</button>
{/if}
{/if}
</section>
{/if}
<!-- ═══ WILLING TO TRADE ═══ -->
{#if profile.wtt_cards?.length > 0}
{@const hasWttMore = profile.wtt_cards.length > ROW_SIZE}
<section class="section" bind:this={wttSectionEl}>
<div class="section-header">
<h2 class="section-title">Willing to Trade</h2>
<button
class="expand-btn"
style:visibility={hasWttMore && wttExpanded ? 'visible' : 'hidden'}
onclick={() => collapseSection(wttSectionEl, () => { wttExpanded = false; })}
>Collapse ▲</button>
</div>
<div class="card-grid">
{#each visibleWtt as card (card.id)}
<div class="card-wrap">
<div class="card-inner"><Card {card} /></div>
</div>
{/each}
</div>
{#if hasWttMore}
{#if !wttExpanded}
<button class="expand-btn" onclick={() => { wttExpanded = true; }}>
Show all {profile.wtt_cards.length} cards ▼
</button>
{:else}
<button class="expand-btn" onclick={() => collapseSection(wttSectionEl, () => { wttExpanded = false; })}>
Collapse ▲
</button>
{/if}
{/if}
</section>
{/if}
{#if isLoggedIn}
<div class="offer-cta">
<a href="/trade/offer/{profile.username}" class="btn-primary">⇄ Offer a Trade</a>
{#if challengeStatus === 'sent'}
<span class="challenge-sent">Challenge Sent ✦</span>
{:else}
<button class="btn-secondary" onclick={openChallengeModal}> Challenge to Play</button>
{/if}
</div>
{/if}
{#if showChallengeModal}
<div class="modal-backdrop" onclick={() => { showChallengeModal = false; }}>
<div class="modal" onclick={(e) => e.stopPropagation()}>
<div class="modal-icon"></div>
<h2 class="modal-title">Challenge {profile.username}</h2>
{#if challengeDecks.length === 0}
<p class="modal-body">You have no decks. Build a deck first.</p>
<button class="btn-secondary" onclick={() => { showChallengeModal = false; }}>Close</button>
{:else if challengeStatus === 'sent'}
<p class="modal-body">Your challenge has been sent. {profile.username} has 5 minutes to accept.</p>
<button class="btn-primary" onclick={() => { showChallengeModal = false; }}>Done</button>
{:else}
<p class="modal-body">Select a deck to battle with:</p>
<select class="deck-select" bind:value={selectedDeckId}>
{#each challengeDecks as deck}
<option value={deck.id}>{deck.name}</option>
{/each}
</select>
{#if challengeError}
<p class="challenge-error">{challengeError}</p>
{/if}
<div class="modal-actions">
<button class="btn-primary" onclick={sendChallenge} disabled={challengeStatus === 'sending'}>
{challengeStatus === 'sending' ? 'Sending...' : 'Send Challenge'}
</button>
<button class="btn-secondary" onclick={() => { showChallengeModal = false; }}>Cancel</button>
</div>
{/if}
</div>
</div>
{/if}
</div>
</main>
{/if}
<style>
.page {
height: 100vh;
background: var(--color-bg);
padding: 2rem;
overflow-y: auto;
box-sizing: border-box;
}
.profile-wrap {
max-width: 900px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 2.5rem;
padding-top: 1rem;
padding-bottom: 3rem;
}
/* ── Header ── */
.profile-header {
display: flex;
align-items: flex-start;
gap: 2rem;
}
.avatar-col { flex-shrink: 0; }
.avatar {
width: 80px;
height: 80px;
border-radius: var(--radius-full);
background: var(--color-surface-raised);
border: 2px solid var(--color-bronze);
display: flex;
align-items: center;
justify-content: center;
font-family: 'Cinzel', serif;
font-size: var(--text-3xl);
font-weight: 700;
color: var(--color-gold);
box-shadow: 0 0 20px rgba(200, 134, 26, 0.2), var(--shadow-card);
}
.header-body {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.ornament-line {
height: 1px;
background: var(--color-border-subtle);
}
.username {
font-family: 'Cinzel', serif;
font-size: var(--text-2xl);
font-weight: 700;
color: var(--color-gold);
margin: 0;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.last-active {
font-family: 'Crimson Text', serif;
font-size: var(--text-base);
font-style: italic;
color: rgba(240, 180, 80, 0.35);
margin: 0;
}
.stats-row {
display: flex;
align-items: center;
gap: 1rem;
padding-top: 0.25rem;
}
.stat {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.15rem;
}
.stat-num {
font-family: 'Cinzel', serif;
font-size: var(--text-xl);
font-weight: 700;
color: var(--color-gold);
line-height: 1;
}
.stat-label {
font-family: 'Cinzel', serif;
font-size: var(--text-xs);
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--color-gold-faint);
}
.stat-sep {
font-size: var(--text-lg);
color: var(--color-border-subtle);
align-self: center;
margin-top: -0.5rem;
}
.wins { color: var(--color-success); }
.losses { color: var(--color-error); }
.good-wr { color: var(--color-success); }
.bad-wr { color: var(--color-error); }
/* ── Friend actions ── */
.friend-actions {
display: flex;
align-items: center;
gap: 0.75rem;
margin-top: 0.5rem;
}
.btn-friend {
font-family: 'Cinzel', serif;
font-size: var(--btn-font-md);
font-weight: 700;
letter-spacing: 0.07em;
text-transform: uppercase;
background: var(--color-surface-raised);
border: 1px solid var(--color-bronze);
border-radius: var(--radius-sm);
color: var(--color-gold);
padding: var(--btn-padding-md);
cursor: pointer;
transition: background 0.15s;
}
.btn-friend:hover { background: #4d3010; }
.friend-pending {
font-family: 'Cinzel', serif;
font-size: var(--text-sm);
font-weight: 700;
letter-spacing: 0.07em;
text-transform: uppercase;
color: var(--color-gold-faint);
}
.friend-status {
font-family: 'Cinzel', serif;
font-size: var(--text-sm);
font-weight: 700;
letter-spacing: 0.07em;
text-transform: uppercase;
color: var(--color-success);
}
.btn-unfriend {
font-family: 'Cinzel', serif;
font-size: var(--btn-font-sm);
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
background: none;
border: 1px solid rgba(180, 60, 60, 0.4);
border-radius: var(--radius-sm);
color: rgba(200, 80, 80, 0.6);
padding: var(--btn-padding-sm);
cursor: pointer;
transition: all 0.15s;
}
.btn-unfriend:hover { border-color: #c84040; color: #e05050; }
/* ── Sections ── */
.section {
display: flex;
flex-direction: column;
gap: 1rem;
}
/* Header row: always the same height regardless of which buttons are visible */
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.section-title {
font-family: 'Cinzel', serif;
font-size: var(--text-base);
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--color-gold-dim);
margin: 0;
}
/* ── Wishlist ── */
.wishlist-block {
margin: 0;
padding: 1rem 1.5rem;
background: rgba(61, 37, 7, 0.25);
border-left: 3px solid rgba(200, 134, 26, 0.4);
border-radius: 0 var(--radius-lg) var(--radius-lg) 0;
font-family: 'Crimson Text', serif;
font-size: var(--text-lg);
font-style: italic;
color: var(--color-gold-muted);
line-height: 1.6;
}
/* ── Card grid ── */
.card-grid {
display: flex;
flex-wrap: wrap;
gap: 10px;
justify-content: flex-start;
align-items: flex-start;
}
/*
* Two-level card sizing:
* .card-wrap — controls layout footprint (width/height + margin compensation)
* .card-inner — controls visual scale from center (so hover grows symmetrically)
*
* card dimensions assumed: 300×420px at natural scale
* at scale 0.62: visual 186×260px
* margin-right = -(300 - 186) = -114px → next card starts 186+gap from here
* margin-bottom = -(420 - 260) = -160px → row height becomes 260px
*/
.card-wrap {
width: 300px;
height: 420px;
margin-right: -114px;
margin-bottom: -160px;
flex-shrink: 0;
position: relative;
z-index: 1;
/* Pointer events on the wrapper would cover the full 300×420px layout box,
most of which is empty space at scale 0.62. Delegate to card-inner only. */
pointer-events: none;
}
/* Elevate wrapper when the visible card inside is hovered */
.card-wrap:has(.card-inner:hover) {
z-index: 10;
}
.card-inner {
width: 100%;
height: 100%;
transform: scale(0.62);
transform-origin: top left;
transition: transform 0.2s ease;
pointer-events: auto;
}
/*
* Hover grows from the visual center, not the top-left corner.
* At scale 0.62, visual center = (300*0.62/2, 420*0.62/2) = (93px, 130px).
* At scale 0.78 from top-left, center would shift to (117px, 163px) — delta (+24, +33).
* Pre-translate by (-24px, -34px) to cancel that shift, keeping center fixed.
*/
.card-inner:hover {
transform: translate(-24px, -34px) scale(0.78);
}
/* ── Expand / Collapse buttons ── */
.expand-btn {
font-family: 'Cinzel', serif;
font-size: var(--btn-font-sm);
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: rgba(240, 180, 80, 0.5);
background: none;
border: 1px solid var(--color-border-subtle);
border-radius: var(--radius-sm);
padding: var(--btn-padding-sm);
cursor: pointer;
transition: color 0.15s, border-color 0.15s;
align-self: flex-start;
white-space: nowrap;
}
.expand-btn:hover {
color: var(--color-gold);
border-color: rgba(200, 134, 26, 0.6);
}
/* ── Offer CTA ── */
.offer-cta {
display: flex;
align-items: center;
gap: 1rem;
flex-wrap: wrap;
padding-top: 0.25rem;
}
.challenge-sent {
font-family: 'Cinzel', serif;
font-size: var(--text-sm);
font-weight: 700;
letter-spacing: 0.07em;
text-transform: uppercase;
color: var(--color-success);
}
.deck-select {
width: 100%;
background: var(--color-bg);
border: 1px solid var(--color-border-subtle);
border-radius: var(--radius-md);
color: var(--color-gold);
font-family: 'Crimson Text', serif;
font-size: var(--text-md);
padding: 8px 12px;
cursor: pointer;
}
.deck-select:focus { outline: none; border-color: var(--color-bronze); }
.challenge-error {
font-family: 'Crimson Text', serif;
font-size: var(--text-base);
color: var(--color-error);
margin: 0;
min-height: 1.2em;
}
.modal-actions {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
justify-content: center;
}
/* ── Buttons ── */
.btn-primary {
font-family: 'Cinzel', serif;
font-size: var(--btn-font-lg);
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
background: var(--color-bronze);
border: none;
border-radius: var(--radius-md);
color: var(--color-btn-text);
padding: var(--btn-padding-lg);
cursor: pointer;
transition: background 0.15s, transform 0.1s;
}
.btn-primary:hover { background: var(--color-bronze-hover); transform: translateY(-1px); }
.btn-primary:active { transform: translateY(0); }
a.btn-primary { text-decoration: none; }
.btn-secondary {
font-family: 'Cinzel', serif;
font-size: var(--btn-font-md);
font-weight: 700;
letter-spacing: 0.07em;
text-transform: uppercase;
background: var(--color-surface-raised);
border: 1px solid var(--color-border-subtle);
border-radius: var(--radius-md);
color: var(--color-gold);
padding: var(--btn-padding-md);
cursor: pointer;
text-decoration: none;
display: inline-block;
transition: all 0.15s;
}
.btn-secondary:hover { border-color: var(--color-bronze); color: var(--color-btn-text); }
/* ── States ── */
.status-text {
font-family: 'Crimson Text', serif;
font-size: var(--text-md);
font-style: italic;
color: var(--color-gold-faint);
text-align: center;
margin-top: 4rem;
}
.not-found {
max-width: 400px;
margin: 6rem auto 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
text-align: center;
}
.not-found-sigil { font-size: var(--text-3xl); color: rgba(107, 76, 30, 0.4); }
.not-found-title {
font-family: 'Cinzel', serif;
font-size: var(--text-xl);
font-weight: 700;
color: rgba(240, 180, 80, 0.5);
margin: 0;
letter-spacing: 0.08em;
}
.not-found-sub {
font-family: 'Crimson Text', serif;
font-size: var(--text-md);
font-style: italic;
color: rgba(240, 180, 80, 0.3);
margin: 0;
}
.empty-profile { padding: 2rem 0; text-align: center; }
.empty-text {
font-family: 'Crimson Text', serif;
font-size: var(--text-md);
font-style: italic;
color: rgba(240, 180, 80, 0.25);
margin: 0;
}
/* ── Modal ── */
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(5, 3, 0, 0.8);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
padding: 1rem;
}
.modal {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-xl);
padding: 2rem 2.5rem;
max-width: 400px;
width: 100%;
box-shadow: var(--shadow-elevated);
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
text-align: center;
}
.modal-icon { font-size: var(--text-3xl); color: rgba(200, 134, 26, 0.6); }
.modal-title {
font-family: 'Cinzel', serif;
font-size: var(--text-lg);
font-weight: 700;
letter-spacing: 0.08em;
color: var(--color-gold);
margin: 0;
}
.modal-body {
font-family: 'Crimson Text', serif;
font-size: var(--text-md);
font-style: italic;
color: var(--color-gold-dim);
line-height: 1.6;
margin: 0;
}
/* ── Responsive ── */
@media (max-width: 600px) {
.profile-header { flex-direction: column; align-items: flex-start; gap: 1rem; }
.username { font-size: var(--text-xl); }
/* Smaller scale on narrow screens (same translate trick: delta = 300*(0.68-0.55)/2 = 19.5px, 420*(0.68-0.55)/2 = 27.3px) */
.card-inner { transform: scale(0.55); }
.card-inner:hover { transform: translate(-20px, -27px) scale(0.68); }
.card-wrap {
margin-right: calc(-(300px - 300px * 0.55));
margin-bottom: calc(-(420px - 420px * 0.55));
}
}
</style>
+30 -30
View File
@@ -80,11 +80,9 @@
</main>
<style>
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;700&family=Crimson+Text:ital,wght@0,400;0,600;1,400&display=swap');
main {
min-height: 100vh;
background: #0d0a04;
background: var(--color-bg);
display: flex;
align-items: center;
justify-content: center;
@@ -93,9 +91,9 @@
.card {
width: 380px;
background: #3d2507;
border: 2px solid #c8861a;
border-radius: 12px;
background: var(--color-surface);
border: 2px solid var(--color-bronze);
border-radius: var(--radius-xl);
padding: 2rem;
display: flex;
flex-direction: column;
@@ -104,17 +102,17 @@
.title {
font-family: 'Cinzel', serif;
font-size: 20px;
font-size: var(--text-xl);
font-weight: 700;
color: #f5d060;
color: var(--color-gold);
margin: 0;
text-align: center;
}
.hint {
font-family: 'Crimson Text', serif;
font-size: 15px;
color: rgba(245, 208, 96, 0.7);
font-size: var(--text-md);
color: var(--color-gold-dim);
margin: 0;
text-align: center;
}
@@ -127,63 +125,65 @@
.field-label {
font-family: 'Cinzel', serif;
font-size: 10px;
font-size: var(--text-xs);
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: rgba(245, 208, 96, 0.5);
color: var(--color-gold-faint);
}
input {
width: 100%;
padding: 9px 12px;
background: #221508;
border: 1.5px solid #c8861a;
border-radius: 6px;
color: #f5d060;
background: var(--color-surface);
border: 1.5px solid var(--color-border);
border-radius: var(--radius-md);
color: var(--color-gold);
font-family: 'Crimson Text', serif;
font-size: 15px;
font-size: var(--text-md);
box-sizing: border-box;
outline: none;
margin-bottom: 0.4rem;
}
input:focus { border-color: #f5d060; }
input::placeholder { color: rgba(245, 208, 96, 0.35); }
input:focus { border-color: var(--color-bronze); }
input::placeholder { color: var(--color-gold-faint); }
.btn {
width: 100%;
padding: 10px;
background: #c8861a;
color: #fff8e0;
padding: var(--btn-padding-lg);
background: var(--color-bronze);
color: var(--color-btn-text);
border: none;
border-radius: 6px;
border-radius: var(--radius-md);
font-family: 'Cinzel', serif;
font-size: 13px;
font-size: var(--btn-font-lg);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
cursor: pointer;
transition: background 0.15s;
}
.btn:hover:not(:disabled) { background: #e09820; }
.btn:hover:not(:disabled) { background: var(--color-bronze-hover); }
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.back-link {
all: unset;
font-family: 'Crimson Text', serif;
font-size: 14px;
color: rgba(245, 208, 96, 0.5);
font-size: var(--text-base);
color: var(--color-gold-faint);
cursor: pointer;
text-align: center;
transition: color 0.15s;
}
.back-link:hover { color: #f5d060; }
.back-link:hover { color: var(--color-gold); }
.error {
font-family: 'Crimson Text', serif;
font-size: 14px;
color: #f06060;
font-size: var(--text-base);
color: var(--color-error);
margin: 0;
min-height: 1.4em;
text-align: center;
@@ -1,30 +1,27 @@
<script>
<script lang="ts">
import { API_URL } from '$lib/api.js';
import { apiFetch } from '$lib/api.js';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import CardSelector from '$lib/CardSelector.svelte';
let allCards = $state([]);
let shards = $state(null);
let selectedIds = $state(new Set());
let selectedCards: any[] = $state([]); // bound from CardSelector
let selectorOpen = $state(false);
let shattering = $state(false);
let result = $state(null); // { gained, shards }
let result: { gained: number } | null = $state(null);
let inDeckIds = $state(new Set());
let selectorRef: any;
const selectedCards = $derived(allCards.filter(c => selectedIds.has(c.id)));
const totalYield = $derived(selectedCards.reduce((sum, c) => sum + c.cost, 0));
const totalYield = $derived(selectedCards.reduce((sum: number, c: any) => sum + c.cost, 0));
onMount(async () => {
if (!localStorage.getItem('token')) { goto('/auth'); return; }
const [cardsRes, profileRes, inDecksRes] = await Promise.all([
apiFetch(`${API_URL}/cards`),
const [profileRes, inDecksRes] = await Promise.all([
apiFetch(`${API_URL}/profile`),
apiFetch(`${API_URL}/cards/in-decks`),
]);
if (cardsRes.status === 401) { goto('/auth'); return; }
allCards = await cardsRes.json();
const profile = await profileRes.json();
shards = profile.shards;
if (inDecksRes.ok) inDeckIds = new Set(await inDecksRes.json());
@@ -42,8 +39,8 @@
const data = await res.json();
shards = data.shards;
result = { gained: data.gained };
allCards = allCards.filter(c => !selectedIds.has(c.id));
selectedIds = new Set();
selectorRef?.refresh(); // refetch so shattered cards disappear
}
shattering = false;
}
@@ -53,7 +50,7 @@
<main>
<div class="top">
<h1 class="page-title">Shards</h1>
<h1 class="page-title">Shatter</h1>
{#if shards !== null}
<div class="shards-display">
<span class="shards-icon"></span>
@@ -96,8 +93,9 @@
{#if selectorOpen}
<div class="selector-overlay">
<CardSelector
allCards={allCards}
bind:this={selectorRef}
bind:selectedIds={selectedIds}
bind:selectedCards={selectedCards}
{inDeckIds}
onclose={() => { selectorOpen = false; }}
/>
@@ -105,11 +103,9 @@
{/if}
<style>
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;700;900&family=Crimson+Text:ital,wght@0,400;0,600;1,400&display=swap');
main {
min-height: 100vh;
background: #0d0a04;
background: var(--color-bg);
padding: 2.5rem 2rem;
display: flex;
flex-direction: column;
@@ -130,7 +126,7 @@
font-family: 'Cinzel', serif;
font-size: clamp(22px, 4vw, 32px);
font-weight: 900;
color: #f0d080;
color: var(--color-gold);
letter-spacing: 0.12em;
text-transform: uppercase;
margin: 0;
@@ -143,22 +139,23 @@
}
.shards-icon {
font-size: 20px;
color: #7ecfcf;
font-size: var(--text-xl);
color: var(--color-cyan);
position: relative;
top: -0.1em;
animation: shard-pulse 3s ease-in-out infinite;
}
.shards-amount {
font-family: 'Cinzel', serif;
font-size: 24px;
font-size: var(--text-xl);
font-weight: 700;
color: #7ecfcf;
color: var(--color-cyan);
}
.shards-label {
font-family: 'Cinzel', serif;
font-size: 11px;
font-size: var(--text-sm);
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
@@ -168,23 +165,23 @@
.explainer {
font-family: 'Crimson Text', serif;
font-size: 16px;
font-size: var(--text-md);
font-style: italic;
color: rgba(240, 180, 80, 0.5);
color: var(--color-gold-faint);
margin: 0;
line-height: 1.6;
}
.store-hint {
font-family: 'Crimson Text', serif;
font-size: 15px;
font-size: var(--text-md);
font-style: italic;
color: rgba(240, 180, 80, 0.35);
color: var(--color-gold-faint);
margin: 0;
}
.store-link {
color: #7ecfcf;
color: var(--color-cyan);
text-decoration: underline;
text-underline-offset: 2px;
transition: color 0.15s;
@@ -197,16 +194,16 @@
align-items: center;
gap: 0.5rem;
background: #0d2a0d;
border: 1.5px solid #6aaa6a;
border-radius: 8px;
border: 1.5px solid var(--color-success);
border-radius: var(--radius-lg);
padding: 0.75rem 1.25rem;
font-family: 'Cinzel', serif;
font-size: 15px;
font-size: var(--text-md);
font-weight: 700;
color: #6aaa6a;
color: var(--color-success);
}
.result-icon { color: #7ecfcf; position: relative; top: -0.1em; }
.result-icon { color: var(--color-cyan); position: relative; top: -0.1em; animation: shard-pulse 3s ease-in-out infinite; }
.dismiss {
margin-left: auto;
@@ -214,11 +211,11 @@
border: none;
color: rgba(106, 170, 106, 0.5);
cursor: pointer;
font-size: 13px;
font-size: var(--text-base);
padding: 0 0 0 0.75rem;
transition: color 0.15s;
}
.dismiss:hover { color: #6aaa6a; }
.dismiss:hover { color: var(--color-success); }
/* ── Action area ── */
.action-area {
@@ -232,15 +229,15 @@
.select-btn {
font-family: 'Cinzel', serif;
font-size: 13px;
font-size: var(--btn-font-lg);
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
background: #3d2507;
border: 1.5px solid #c8861a;
border-radius: 6px;
color: #f0d080;
padding: 10px 24px;
background: var(--color-surface-raised);
border: 1.5px solid var(--color-bronze);
border-radius: var(--radius-md);
color: var(--color-gold);
padding: var(--btn-padding-lg);
cursor: pointer;
transition: background 0.15s;
width: 100%;
@@ -254,26 +251,26 @@
justify-content: center;
gap: 0.75rem;
font-family: 'Cinzel', serif;
font-size: 15px;
font-size: var(--text-md);
font-weight: 700;
}
.summary-count { color: #f0d080; }
.summary-arrow { color: rgba(240, 180, 80, 0.35); }
.summary-yield { color: #7ecfcf; display: flex; align-items: center; gap: 0.3rem; }
.shards-icon-sm { font-size: 14px; color: #7ecfcf; position: relative; top: -0.1em; }
.summary-count { color: var(--color-gold); }
.summary-arrow { color: var(--color-gold-faint); }
.summary-yield { color: var(--color-cyan); display: flex; align-items: center; gap: 0.3rem; }
.shards-icon-sm { font-size: var(--text-base); color: var(--color-cyan); position: relative; top: -0.1em; }
.shatter-btn {
font-family: 'Cinzel', serif;
font-size: 13px;
font-size: var(--btn-font-lg);
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
background: #1a1008;
border: 1.5px solid #7ecfcf;
border-radius: 6px;
color: #7ecfcf;
padding: 10px 24px;
background: var(--color-surface);
border: 1.5px solid var(--color-cyan);
border-radius: var(--radius-md);
color: var(--color-cyan);
padding: var(--btn-padding-lg);
cursor: pointer;
width: 100%;
transition: background 0.15s;
@@ -286,7 +283,7 @@
.selector-overlay {
position: fixed;
inset: 0;
z-index: 200;
z-index: var(--z-dropdown);
background: rgba(0, 0, 0, 0.85);
}
</style>
+255 -113
View File
@@ -1,4 +1,4 @@
<script>
<script lang="ts">
import { API_URL } from '$lib/api.js';
import { apiFetch } from '$lib/api.js';
import { goto } from '$app/navigation';
@@ -7,11 +7,11 @@
import { page } from '$app/stores';
import Card from '$lib/Card.svelte';
let shards = $state(null);
let buying = $state(null); // which quantity is being bought
let flash = $state(null); // { quantity, ok }
let shardPackages = $state([]);
let buyingShards = $state(null);
let shards: number | null = $state(null);
let buying: number | null = $state(null);
let flash: { quantity: number; ok: boolean } | null = $state(null);
let shardPackages: any[] = $state([]);
let buyingShards: string | null = $state(null);
let paymentSuccess = $state(false);
const packages = [
@@ -31,7 +31,7 @@
const profile = await profileRes.json();
shards = profile.shards;
const config = await configRes.json();
shardPackages = Object.entries(config.shard_packages).map(([id, pkg]) => ({ id, ...pkg }));
shardPackages = Object.entries(config.shard_packages).map(([id, pkg]) => ({ id, ...(pkg as object) }));
if ($page.url.searchParams.get('payment') === 'success') {
paymentSuccess = true;
@@ -41,7 +41,7 @@
}
});
async function buyWithStripe(packageId) {
async function buyWithStripe(packageId: string) {
if (buyingShards) return;
buyingShards = packageId;
const res = await apiFetch(`${API_URL}/store/stripe/checkout`, {
@@ -56,8 +56,8 @@
buyingShards = null;
}
async function buy(quantity, cost) {
if (buying !== null || shards < cost) return;
async function buy(quantity: number, cost: number) {
if (buying !== null || shards === null || shards < cost) return;
buying = quantity;
const res = await apiFetch(`${API_URL}/store/buy`, {
method: 'POST',
@@ -79,9 +79,10 @@
const SPECIFIC_CARD_COST = 1000;
let specificPhase = $state('idle'); // idle | input | generating | revealing | done
let wikiTitle = $state('');
let specificCard = $state(null);
let specificCard: any = $state(null);
let specificFlipped = $state(false);
let specificError = $state('');
let specificAction = $state({ favorited: false, tradeListed: false, shattered: false, shardGain: 0 });
function openSpecificModal() {
wikiTitle = '';
@@ -93,6 +94,36 @@
specificPhase = 'idle';
specificCard = null;
specificFlipped = false;
specificAction = { favorited: false, tradeListed: false, shattered: false, shardGain: 0 };
}
async function specificToggleFavorite() {
const res = await apiFetch(`${API_URL}/cards/${specificCard.id}/favorite`, { method: 'POST' });
if (res.ok) {
const data = await res.json();
specificAction = { ...specificAction, favorited: data.is_favorite };
}
}
async function specificToggleTrade() {
const res = await apiFetch(`${API_URL}/cards/${specificCard.id}/willing-to-trade`, { method: 'POST' });
if (res.ok) {
const data = await res.json();
specificAction = { ...specificAction, tradeListed: data.willing_to_trade };
}
}
async function specificShatter() {
const res = await apiFetch(`${API_URL}/shards/shatter`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ card_ids: [specificCard.id] }),
});
if (res.ok) {
const data = await res.json();
shards = data.shards;
specificAction = { ...specificAction, shattered: true, shardGain: data.gained };
}
}
async function buySpecificCard() {
@@ -122,7 +153,7 @@
}
// How many mini-packs to fan out per package
function fanCount(quantity) {
function fanCount(quantity: number) {
if (quantity === 1) return 1;
if (quantity === 5) return 2;
if (quantity === 10) return 3;
@@ -130,7 +161,7 @@
}
// Rotation and offset for each pack in the fan
function packTransform(index, total) {
function packTransform(index: number, total: number) {
if (total === 1) return 'rotate(0deg) translateY(0px)';
const spread = 10; // degrees between each pack
const mid = (total - 1) / 2;
@@ -148,7 +179,7 @@
<span class="shards-icon"></span>
<span class="shards-amount">{shards}</span>
<span class="shards-label">Shards</span>
<a href="/shards" class="shards-link">shatter cards</a>
<a href="/shatter" class="shards-link">shatter cards</a>
</div>
{/if}
</div>
@@ -179,12 +210,12 @@
<button
class="buy-btn"
class:flash-ok={isFlashing && flash.ok}
class:flash-err={isFlashing && !flash.ok}
class:flash-ok={isFlashing && flash?.ok}
class:flash-err={isFlashing && !flash?.ok}
onclick={() => buy(pkg.quantity, pkg.cost)}
disabled={!canAfford || buying !== null}
>
{#if isFlashing && flash.ok}
{#if isFlashing && flash?.ok}
Purchased!
{:else if buying === pkg.quantity}
...
@@ -236,7 +267,7 @@
{#each shardPackages as pkg}
<div class="shard-card">
{#if pkg.bonus > 0}
<div class="shard-sticker"><span class="sticker-icon"></span>{pkg.bonus}<br/>BONUS</div>
<div class="shard-sticker"><span class="sticker-icon"></span>{pkg.bonus}<br/>BONUS*</div>
{/if}
<span class="shard-amount"><span class="shard-icon"></span> {pkg.shards}</span>
<span class="shard-price">{pkg.price_label}</span>
@@ -250,6 +281,9 @@
</div>
{/each}
</div>
{#if shardPackages.some(p => p.bonus > 0)}
<p class="bonus-footnote">* compared to the per-shard price of the 100 shards pack</p>
{/if}
</div>
</main>
@@ -300,6 +334,30 @@
Your card is being generated…
</p>
<div class="pack-card-actions" class:actions-visible={specificPhase === 'done'}>
{#if specificAction.shattered}
<span class="shard-gained">+{specificAction.shardGain}</span>
{:else}
<button
class="pack-action-btn fav"
class:active={specificAction.favorited}
onclick={specificToggleFavorite}
title="Favorite"
>{specificAction.favorited ? '★' : '☆'}</button>
<button
class="pack-action-btn trade"
class:active={specificAction.tradeListed}
onclick={specificToggleTrade}
title="Mark for Trade"
>⇄</button>
<button
class="pack-action-btn shatter"
onclick={specificShatter}
title="Shatter for shards"
>◈</button>
{/if}
</div>
<button
class="close-reveal-btn"
class:hidden={specificPhase !== 'done'}
@@ -312,12 +370,10 @@
{/if}
<style>
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;700;900&family=Crimson+Text:ital,wght@0,400;0,600;1,400&display=swap');
main {
height: 100vh;
overflow-y: auto;
background: #0d0a04;
background: var(--color-bg);
padding: 2.5rem 2rem 5rem;
display: flex;
flex-direction: column;
@@ -336,7 +392,7 @@
font-family: 'Cinzel', serif;
font-size: clamp(22px, 4vw, 32px);
font-weight: 900;
color: #f0d080;
color: var(--color-gold);
letter-spacing: 0.12em;
text-transform: uppercase;
margin: 0;
@@ -350,38 +406,39 @@
.shards-link {
font-family: 'Cinzel', serif;
font-size: 10px;
font-size: var(--text-xs);
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: rgba(126, 207, 207, 0.6);
border: 1px solid rgba(126, 207, 207, 0.3);
border-radius: 4px;
border-radius: var(--radius-sm);
padding: 3px 8px;
text-decoration: none;
margin-top: 4px;
margin-left: 0.5rem;
transition: color 0.15s, border-color 0.15s;
}
.shards-link:hover { color: #7ecfcf; border-color: rgba(126, 207, 207, 0.7); }
.shards-link:hover { color: var(--color-cyan); border-color: rgba(126, 207, 207, 0.7); }
.shards-icon {
font-size: 20px;
color: #7ecfcf;
font-size: var(--text-xl);
color: var(--color-cyan);
position: relative;
top: -0.1em;
animation: shard-pulse 3s ease-in-out infinite;
}
.shards-amount {
font-family: 'Cinzel', serif;
font-size: 24px;
font-size: var(--text-xl);
font-weight: 700;
color: #7ecfcf;
color: var(--color-cyan);
}
.shards-label {
font-family: 'Cinzel', serif;
font-size: 11px;
font-size: var(--text-sm);
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
@@ -399,8 +456,8 @@
.pkg-card {
position: relative;
background: #1a1008;
border: 1.5px solid rgba(107, 76, 30, 0.5);
background: var(--color-surface);
border: 1.5px solid var(--color-border-subtle);
border-radius: 14px;
padding: 2rem 1.5rem 1.5rem;
display: flex;
@@ -412,7 +469,7 @@
}
.pkg-card:not(.cannot-afford):hover {
border-color: #c8861a;
border-color: var(--color-bronze);
background: #211408;
}
@@ -431,7 +488,7 @@
background: #f5d800;
color: #c0000a;
font-family: 'Cinzel', serif;
font-size: 11px;
font-size: var(--text-sm);
font-weight: 900;
display: flex;
align-items: center;
@@ -528,9 +585,9 @@
/* ── Labels ── */
.qty-label {
font-family: 'Cinzel', serif;
font-size: 15px;
font-size: var(--text-md);
font-weight: 700;
color: #f0d080;
color: var(--color-gold);
margin: 0;
letter-spacing: 0.05em;
}
@@ -539,12 +596,12 @@
.buy-btn {
width: 100%;
padding: 8px 0;
background: #3d2507;
border: 1.5px solid #c8861a;
border-radius: 6px;
color: #f0d080;
background: var(--color-surface-raised);
border: 1.5px solid var(--color-bronze);
border-radius: var(--radius-md);
color: var(--color-gold);
font-family: 'Cinzel', serif;
font-size: 13px;
font-size: var(--btn-font-lg);
font-weight: 700;
letter-spacing: 0.05em;
cursor: pointer;
@@ -572,13 +629,13 @@
.buy-btn.flash-err {
background: #4a1a1a;
border-color: #c85050;
color: #c85050;
border-color: #c84040;
color: #c84040;
}
.cost-icon {
color: #7ecfcf;
font-size: 12px;
color: var(--color-cyan);
font-size: var(--text-sm);
position: relative;
top: -0.1em;
}
@@ -587,7 +644,7 @@
width: 100%;
max-width: 800px;
height: 1px;
background: rgba(107, 76, 30, 0.3);
background: var(--color-border-dim);
}
.shard-section {
@@ -597,33 +654,44 @@
gap: 1rem;
width: 100%;
max-width: 800px;
margin-top: 0;
}
.section-title {
font-family: 'Cinzel', serif;
font-size: 16px;
font-size: var(--text-base);
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: rgba(240, 180, 80, 0.5);
color: var(--color-gold-faint);
margin: 0;
}
.section-hint {
font-family: 'Crimson Text', serif;
font-size: 15px;
font-size: var(--text-md);
font-style: italic;
color: rgba(240, 180, 80, 0.35);
color: var(--color-gold-faint);
margin: -0.5rem 0 0;
}
.bonus-footnote {
font-family: 'Crimson Text', serif;
font-size: var(--text-md);
font-style: italic;
color: var(--color-gold-faint);
opacity: 0.6;
margin: 0;
text-align: center;
}
.payment-success {
font-family: 'Crimson Text', serif;
font-size: 15px;
color: #6aaa6a;
font-size: var(--text-md);
color: var(--color-success);
background: #0d2a0d;
border: 1px solid #6aaa6a;
border-radius: 6px;
border: 1px solid var(--color-success);
border-radius: var(--radius-md);
padding: 0.6rem 1.2rem;
}
@@ -631,12 +699,13 @@
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1.25rem;
margin-top: 0.75rem;
}
.shard-card {
position: relative;
background: #1a1008;
border: 1.5px solid rgba(107, 76, 30, 0.4);
background: var(--color-surface);
border: 1.5px solid var(--color-border-subtle);
border-radius: 14px;
padding: 2rem 2rem 1.75rem;
display: flex;
@@ -651,14 +720,14 @@
position: absolute;
top: -14px;
right: -14px;
background: #7ecfcf;
color: #0d0a04;
background: var(--color-cyan);
color: var(--color-bg);
font-family: 'Cinzel', serif;
font-size: 12px;
font-size: var(--text-sm);
font-weight: 900;
line-height: 1.3;
padding: 8px 11px;
border-radius: 5px;
border-radius: var(--radius-md);
text-align: center;
transform: rotate(8deg);
box-shadow: 0 2px 10px rgba(0,0,0,0.6);
@@ -670,13 +739,13 @@
top: -0.1em;
}
.shard-card:hover { border-color: #7ecfcf; }
.shard-card:hover { border-color: var(--color-cyan); }
.shard-amount {
font-family: 'Cinzel', serif;
font-size: 26px;
font-size: var(--text-2xl);
font-weight: 700;
color: #7ecfcf;
color: var(--color-cyan);
display: flex;
align-items: center;
gap: 0.3rem;
@@ -685,12 +754,13 @@
.shard-icon {
position: relative;
top: -0.1em;
animation: shard-pulse 3s ease-in-out infinite;
}
.shard-price {
font-family: 'Crimson Text', serif;
font-size: 19px;
color: rgba(240, 180, 80, 0.6);
font-size: var(--text-lg);
color: var(--color-gold-dim);
}
.stripe-btn {
@@ -698,10 +768,10 @@
padding: 10px 0;
background: #1a3a4a;
border: 1.5px solid #4a9aba;
border-radius: 6px;
border-radius: var(--radius-md);
color: #a0d8ef;
font-family: 'Cinzel', serif;
font-size: 13px;
font-size: var(--btn-font-lg);
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
@@ -723,8 +793,8 @@
}
.specific-card-preview {
background: #1a1008;
border: 1.5px solid rgba(107, 76, 30, 0.4);
background: var(--color-surface);
border: 1.5px solid var(--color-border-subtle);
border-radius: 14px;
padding: 2rem 2.5rem;
width: 100%;
@@ -732,11 +802,11 @@
.specific-card-title {
font-family: 'Cinzel', serif;
font-size: 14px;
font-size: var(--text-base);
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: rgba(240, 180, 80, 0.6);
color: var(--color-gold-dim);
margin: 0;
}
@@ -775,7 +845,7 @@
.specific-desc {
font-family: 'Crimson Text', serif;
font-size: 16px;
font-size: var(--text-md);
font-style: italic;
color: rgba(240, 180, 80, 0.55);
line-height: 1.5;
@@ -784,15 +854,15 @@
.specific-buy-btn {
font-family: 'Cinzel', serif;
font-size: 13px;
font-size: var(--btn-font-lg);
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
background: #3d2507;
border: 1.5px solid #c8861a;
border-radius: 6px;
color: #f0d080;
padding: 10px 20px;
background: var(--color-surface-raised);
border: 1.5px solid var(--color-bronze);
border-radius: var(--radius-md);
color: var(--color-gold);
padding: var(--btn-padding-lg);
cursor: pointer;
display: flex;
align-items: center;
@@ -805,7 +875,7 @@
.specific-cant-afford {
font-family: 'Crimson Text', serif;
font-size: 13px;
font-size: var(--text-base);
font-style: italic;
color: rgba(200, 100, 80, 0.7);
margin: 0;
@@ -815,7 +885,7 @@
.specific-overlay {
position: fixed;
inset: 0;
z-index: 300;
z-index: var(--z-modal);
background: rgba(0,0,0,0.92);
display: flex;
align-items: center;
@@ -824,8 +894,8 @@
/* Input modal */
.specific-modal {
background: #1a1008;
border: 1.5px solid rgba(107, 76, 30, 0.6);
background: var(--color-surface);
border: 1.5px solid var(--color-border-subtle);
border-radius: 14px;
padding: 2.5rem 2.5rem 2rem;
display: flex;
@@ -836,40 +906,40 @@
.modal-title {
font-family: 'Cinzel', serif;
font-size: 18px;
font-size: var(--text-lg);
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #f0d080;
color: var(--color-gold);
margin: 0;
}
.modal-hint {
font-family: 'Crimson Text', serif;
font-size: 15px;
font-size: var(--text-md);
font-style: italic;
color: rgba(240, 180, 80, 0.5);
color: var(--color-gold-faint);
margin: 0;
}
.wiki-input {
font-family: 'Crimson Text', serif;
font-size: 17px;
background: #0d0a04;
border: 1.5px solid rgba(107, 76, 30, 0.6);
border-radius: 6px;
color: #f0d080;
font-size: var(--text-lg);
background: var(--color-bg);
border: 1.5px solid var(--color-border-subtle);
border-radius: var(--radius-md);
color: var(--color-gold);
padding: 10px 14px;
outline: none;
transition: border-color 0.15s;
}
.wiki-input:focus { border-color: #c8861a; }
.wiki-input::placeholder { color: rgba(240, 180, 80, 0.25); }
.wiki-input:focus { border-color: var(--color-bronze); }
.wiki-input::placeholder { color: var(--color-gold-faint); }
.modal-error {
font-family: 'Crimson Text', serif;
font-size: 14px;
color: #c85050;
font-size: var(--text-base);
color: var(--color-error);
margin: 0;
}
@@ -882,30 +952,30 @@
.modal-cancel {
font-family: 'Cinzel', serif;
font-size: 11px;
font-size: var(--text-sm);
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
background: none;
border: 1px solid rgba(107, 76, 30, 0.5);
border-radius: 5px;
color: rgba(240, 180, 80, 0.5);
border: 1px solid var(--color-border-subtle);
border-radius: var(--radius-md);
color: var(--color-gold-faint);
padding: 8px 18px;
cursor: pointer;
transition: border-color 0.15s, color 0.15s;
}
.modal-cancel:hover { border-color: #c8861a; color: #f0d080; }
.modal-cancel:hover { border-color: var(--color-bronze); color: var(--color-gold); }
.modal-confirm {
font-family: 'Cinzel', serif;
font-size: 11px;
font-size: var(--text-sm);
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
background: #3d2507;
border: 1.5px solid #c8861a;
border-radius: 5px;
color: #f0d080;
background: var(--color-surface-raised);
border: 1.5px solid var(--color-bronze);
border-radius: var(--radius-md);
color: var(--color-gold);
padding: 8px 18px;
cursor: pointer;
display: flex;
@@ -936,7 +1006,7 @@
inset: 0;
background: white;
opacity: 0;
z-index: 9999;
z-index: 300;
pointer-events: none;
border-radius: 16px;
}
@@ -1000,26 +1070,98 @@
.reveal-label {
font-family: 'Crimson Text', serif;
font-size: 17px;
font-size: var(--text-lg);
font-style: italic;
color: rgba(240, 180, 80, 0.6);
color: var(--color-gold-dim);
margin: 0;
transition: opacity 0.6s ease;
}
.reveal-label.hidden { opacity: 0; }
.close-reveal-btn.hidden { opacity: 0; pointer-events: none; }
.pack-card-actions {
display: flex;
gap: 6px;
justify-content: center;
height: 34px;
visibility: hidden;
}
.pack-card-actions.actions-visible {
visibility: visible;
}
.pack-action-btn {
font-family: 'Cinzel', serif;
font-size: var(--text-base);
font-weight: 700;
width: 34px;
height: 34px;
border-radius: var(--radius-full);
border: 1.5px solid;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s;
}
.pack-action-btn.fav {
background: rgba(30, 20, 0, 0.7);
border-color: rgba(200, 160, 0, 0.5);
color: rgba(240, 200, 0, 0.6);
}
.pack-action-btn.fav:hover, .pack-action-btn.fav.active {
background: rgba(60, 45, 0, 0.9);
border-color: #c8a000;
color: #f0c800;
}
.pack-action-btn.trade {
background: rgba(0, 25, 25, 0.7);
border-color: rgba(0, 150, 150, 0.5);
color: rgba(0, 190, 190, 0.6);
}
.pack-action-btn.trade:hover, .pack-action-btn.trade.active {
background: rgba(0, 50, 50, 0.9);
border-color: #00a0a0;
color: var(--color-cyan);
}
.pack-action-btn.shatter {
background: rgba(0, 20, 30, 0.7);
border-color: rgba(100, 200, 200, 0.4);
color: rgba(126, 207, 207, 0.6);
}
.pack-action-btn.shatter:hover {
background: rgba(0, 40, 50, 0.9);
border-color: var(--color-cyan);
color: var(--color-cyan);
}
.shard-gained {
font-family: 'Cinzel', serif;
font-size: var(--text-base);
font-weight: 700;
color: var(--color-cyan);
padding: 6px 12px;
background: rgba(0, 40, 50, 0.8);
border: 1.5px solid rgba(126, 207, 207, 0.5);
border-radius: 16px;
animation: shard-pulse 3s ease-in-out infinite;
}
.close-reveal-btn {
font-family: 'Cinzel', serif;
font-size: 13px;
font-size: var(--btn-font-lg);
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
background: rgba(60,30,5,0.85);
border: 1.5px solid #c8861a;
border-radius: 6px;
color: #f0d080;
padding: 10px 32px;
border: 1.5px solid var(--color-bronze);
border-radius: var(--radius-md);
color: var(--color-gold);
padding: var(--btn-padding-lg);
cursor: pointer;
transition: background 0.15s;
}
+121 -71
View File
@@ -1,4 +1,4 @@
<script>
<script lang="ts">
import { API_URL, WS_URL, apiFetch } from '$lib/api.js';
import { goto } from '$app/navigation';
import { onMount, onDestroy } from 'svelte';
@@ -9,13 +9,17 @@
let phase = $state('idle'); // idle | queuing | trading | complete
let error = $state('');
let reconnecting = $state(false);
let queueReconnectDelay = 1000;
let queueReconnectTimer: ReturnType<typeof setTimeout> | null = null;
let tradeReconnectDelay = 1000;
let tradeReconnectTimer: ReturnType<typeof setTimeout> | null = null;
let queueWs = null;
let tradeWs = null;
let queueWs: WebSocket | null = null;
let tradeWs: WebSocket | null = null;
let tradeId = $state('');
let allCards = $state([]); // user's full card collection (for selector)
let tradeState = $state(null); // latest trade state from server
let tradeState: any = $state(null); // latest trade state from server
let selectorOpen = $state(false);
let selectorIds = $state(new Set());
@@ -35,12 +39,11 @@
onMount(async () => {
if (!token()) { goto('/auth'); return; }
const res = await apiFetch(`${API_URL}/cards`);
if (!res.ok) { goto('/auth'); return; }
allCards = await res.json();
});
onDestroy(() => {
clearTimeout(queueReconnectTimer);
clearTimeout(tradeReconnectTimer);
queueWs?.close();
tradeWs?.close();
});
@@ -49,12 +52,18 @@
error = '';
phase = 'queuing';
queueWs = new WebSocket(`${WS_URL}/ws/trade/queue`);
queueWs.onopen = () => queueWs.send(token());
queueWs.onopen = () => {
queueWs!.send(token()!);
reconnecting = false;
queueReconnectDelay = 1000;
};
queueWs.onmessage = (e) => {
const msg = JSON.parse(e.data);
if (msg.type === 'trade_start') {
tradeId = msg.trade_id;
queueWs.close();
// Set phase before close so onclose doesn't trigger a reconnect
phase = 'trading';
queueWs!.close();
connectToTrade();
} else if (msg.type === 'error') {
error = msg.message;
@@ -62,22 +71,37 @@
}
};
queueWs.onerror = () => { error = 'Connection failed'; phase = 'idle'; };
queueWs.onclose = () => {
if (phase === 'queuing') {
reconnecting = true;
queueReconnectTimer = setTimeout(() => {
queueReconnectDelay = Math.min(queueReconnectDelay * 2, 30000);
joinQueue();
}, queueReconnectDelay);
}
};
}
function cancelQueue() {
queueWs?.close();
clearTimeout(queueReconnectTimer);
phase = 'idle';
reconnecting = false;
queueWs?.close();
}
function connectToTrade() {
phase = 'trading';
tradeWs = new WebSocket(`${WS_URL}/ws/trade/${tradeId}`);
tradeWs.onopen = () => tradeWs.send(token());
tradeWs.onopen = () => {
tradeWs!.send(token()!);
reconnecting = false;
tradeReconnectDelay = 1000;
};
tradeWs.onmessage = (e) => {
const msg = JSON.parse(e.data);
if (msg.type === 'state') {
tradeState = msg.state;
} else if (msg.type === 'trade_complete') {
// Set phase before close so onclose doesn't trigger a reconnect
phase = 'complete';
tradeWs?.close();
} else if (msg.type === 'error') {
@@ -90,9 +114,15 @@
}
}
};
tradeWs.onerror = () => { error = 'Connection lost'; phase = 'idle'; };
tradeWs.onclose = (e) => {
if (phase === 'trading') { error = 'Connection lost'; phase = 'idle'; }
tradeWs.onerror = () => { error = 'Connection lost'; reconnecting = false; phase = 'idle'; };
tradeWs.onclose = () => {
if (phase === 'trading') {
reconnecting = true;
tradeReconnectTimer = setTimeout(() => {
tradeReconnectDelay = Math.min(tradeReconnectDelay * 2, 30000);
connectToTrade();
}, tradeReconnectDelay);
}
};
}
@@ -100,7 +130,7 @@
if (myOffer.accepted) {
tradeWs?.send(JSON.stringify({ type: 'unaccept' }));
}
selectorIds = new Set(myOffer.cards.map(c => c.id));
selectorIds = new Set(myOffer.cards.map((c: any) => c.id));
selectorOpen = true;
}
@@ -118,7 +148,9 @@
}
function reset() {
clearTimeout(tradeReconnectTimer);
phase = 'idle';
reconnecting = false;
tradeState = null;
tradeId = '';
error = '';
@@ -140,7 +172,7 @@
{:else if phase === 'queuing'}
<div class="center-screen">
<div class="spinner"></div>
<p class="searching-text">Searching for a trade partner...</p>
<p class="searching-text">{reconnecting ? 'Reconnecting...' : 'Searching for a trade partner...'}</p>
<button class="cancel-btn" onclick={cancelQueue}>Cancel</button>
</div>
@@ -149,7 +181,6 @@
{#if selectorOpen}
<div class="selector-overlay">
<CardSelector
allCards={allCards}
bind:selectedIds={selectorIds}
onclose={closeSelector}
/>
@@ -185,7 +216,7 @@
<div class="panel their-panel">
<div class="panel-header">
<span class="panel-title">{partnerUsername || 'Partner'}'s Offer</span>
<span class="panel-title">{#if partnerUsername}<a href="/profile/{partnerUsername}" target="_blank" class="partner-link">{partnerUsername}</a>{:else}Partner{/if}'s Offer</span>
{#if theirOffer.accepted}
<span class="accepted-badge">Accepted ✓</span>
{/if}
@@ -211,7 +242,9 @@
<div class="action-bar">
<button class="choose-btn" onclick={openSelector}>Choose Cards</button>
{#if error}
{#if reconnecting}
<span class="reconnecting-text">Reconnecting...</span>
{:else if error}
<span class="error-inline">{error}</span>
{/if}
@@ -246,11 +279,10 @@
</main>
<style>
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;700&family=Crimson+Text:ital,wght@0,400;0,600;1,400&display=swap');
main {
height: calc(100vh - 56px);
background: #0d0a04;
background: var(--color-bg);
display: flex;
flex-direction: column;
overflow: hidden;
@@ -270,40 +302,40 @@
.title {
font-family: 'Cinzel', serif;
font-size: 36px;
font-size: var(--text-3xl);
font-weight: 700;
color: #f0d080;
color: var(--color-gold);
margin: 0;
letter-spacing: 0.04em;
}
.subtitle {
font-family: 'Crimson Text', serif;
font-size: 18px;
font-size: var(--text-lg);
font-style: italic;
color: rgba(240, 180, 80, 0.6);
color: var(--color-gold-dim);
margin: 0;
}
.error {
font-family: 'Crimson Text', serif;
font-size: 15px;
color: #c85050;
font-size: var(--text-md);
color: var(--color-error);
margin: 0;
text-align: center;
}
.primary-btn {
font-family: 'Cinzel', serif;
font-size: 13px;
font-size: var(--btn-font-lg);
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
background: #3d2507;
border: 1px solid #c8861a;
border-radius: 4px;
color: #f0d080;
padding: 10px 28px;
background: var(--color-surface-raised);
border: 1px solid var(--color-bronze);
border-radius: var(--radius-md);
color: var(--color-gold);
padding: var(--btn-padding-lg);
cursor: pointer;
transition: background 0.15s;
margin-top: 0.5rem;
@@ -313,24 +345,24 @@
.cancel-btn {
font-family: 'Cinzel', serif;
font-size: 11px;
font-size: var(--btn-font-md);
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
background: none;
border: 1px solid rgba(107, 76, 30, 0.5);
border-radius: 4px;
border: 1px solid var(--color-border-subtle);
border-radius: var(--radius-sm);
color: rgba(240, 180, 80, 0.5);
padding: 6px 18px;
padding: var(--btn-padding-md);
cursor: pointer;
transition: all 0.15s;
}
.cancel-btn:hover { border-color: #c8861a; color: #f0d080; }
.cancel-btn:hover { border-color: var(--color-bronze); color: var(--color-gold); }
.searching-text {
font-family: 'Crimson Text', serif;
font-size: 18px;
font-size: var(--text-lg);
font-style: italic;
color: rgba(240, 180, 80, 0.7);
margin: 0;
@@ -340,8 +372,8 @@
width: 40px;
height: 40px;
border: 3px solid rgba(200, 134, 26, 0.2);
border-top-color: #c8861a;
border-radius: 50%;
border-top-color: var(--color-bronze);
border-radius: var(--radius-full);
animation: spin 0.9s linear infinite;
}
@@ -349,20 +381,20 @@
.complete-icon {
font-size: 56px;
color: #6aaa6a;
color: var(--color-success);
line-height: 1;
}
.secondary-link {
font-family: 'Crimson Text', serif;
font-size: 15px;
font-size: var(--text-md);
font-style: italic;
color: rgba(240, 180, 80, 0.5);
text-decoration: underline;
transition: color 0.15s;
}
.secondary-link:hover { color: #f0d080; }
.secondary-link:hover { color: var(--color-gold); }
/* ── Trade layout ── */
@@ -394,27 +426,30 @@
align-items: center;
gap: 1rem;
padding: 1rem 1.5rem 0.75rem;
border-bottom: 1px solid rgba(107, 76, 30, 0.3);
border-bottom: 1px solid var(--color-border-dim);
}
.panel-title {
font-family: 'Cinzel', serif;
font-size: 14px;
font-size: var(--text-base);
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: rgba(240, 180, 80, 0.7);
}
.partner-link { text-decoration: none; transition: color 0.15s; }
.partner-link:hover { color: var(--color-gold); text-decoration: underline; }
.accepted-badge {
font-family: 'Cinzel', serif;
font-size: 11px;
font-size: var(--text-sm);
font-weight: 700;
letter-spacing: 0.04em;
color: #6aaa6a;
color: var(--color-success);
background: rgba(106, 170, 106, 0.12);
border: 1px solid rgba(106, 170, 106, 0.4);
border-radius: 3px;
border-radius: var(--radius-sm);
padding: 2px 7px;
}
@@ -433,7 +468,7 @@
.empty-offer p {
font-family: 'Crimson Text', serif;
font-size: 15px;
font-size: var(--text-md);
font-style: italic;
color: rgba(240, 180, 80, 0.25);
margin: 0;
@@ -456,7 +491,7 @@
.divider {
flex-shrink: 0;
width: 1px;
background: rgba(107, 76, 30, 0.35);
background: var(--color-border-dim);
margin: 0;
}
@@ -468,43 +503,58 @@
align-items: center;
gap: 1rem;
padding: 1rem 1.5rem;
border-top: 1px solid rgba(107, 76, 30, 0.35);
background: #0d0a04;
border-top: 1px solid var(--color-border-dim);
background: var(--color-bg);
}
.choose-btn {
font-family: 'Cinzel', serif;
font-size: 11px;
font-size: var(--btn-font-md);
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
background: #1e1208;
border: 1px solid rgba(107, 76, 30, 0.6);
border-radius: 4px;
border-radius: var(--radius-sm);
color: rgba(240, 180, 80, 0.8);
padding: 8px 18px;
padding: var(--btn-padding-md);
cursor: pointer;
transition: all 0.15s;
}
.choose-btn:hover { background: #2e1c0c; border-color: #c8861a; color: #f0d080; }
.choose-btn:hover { background: #2e1c0c; border-color: var(--color-bronze); color: var(--color-gold); }
.error-inline {
font-family: 'Crimson Text', serif;
font-size: 14px;
color: #c85050;
font-size: var(--text-base);
color: var(--color-error);
flex: 1;
text-align: center;
}
.reconnecting-text {
font-family: 'Crimson Text', serif;
font-size: var(--text-base);
font-style: italic;
color: rgba(240, 180, 80, 0.5);
flex: 1;
text-align: center;
animation: fade-pulse 1.4s ease-in-out infinite;
}
@keyframes fade-pulse {
0%, 100% { opacity: 0.5; }
50% { opacity: 1; }
}
.accept-btn {
font-family: 'Cinzel', serif;
font-size: 13px;
font-size: var(--btn-font-lg);
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
border-radius: 4px;
padding: 10px 28px;
border-radius: var(--radius-md);
padding: var(--btn-padding-lg);
cursor: pointer;
transition: all 0.2s;
margin-left: auto;
@@ -520,22 +570,22 @@
/* Ready: gold, inviting click */
.accept-btn.accept-ready {
background: #3d2507;
border: 2px solid #c8861a;
color: #f0d080;
background: var(--color-surface-raised);
border: 2px solid var(--color-bronze);
color: var(--color-gold);
box-shadow: 0 0 12px rgba(200, 134, 26, 0.2);
}
.accept-btn.accept-ready:hover {
background: #5a3510;
box-shadow: 0 0 20px rgba(200, 134, 26, 0.4);
box-shadow: var(--shadow-glow);
}
/* Accepted: bright green, pulsing, waiting */
.accept-btn.accept-accepted {
background: rgba(40, 90, 40, 0.4);
border: 2px solid #6aaa6a;
color: #6aaa6a;
border: 2px solid var(--color-success);
color: var(--color-success);
cursor: default;
animation: pulse-green 1.8s ease-in-out infinite;
}
@@ -548,7 +598,7 @@
.selector-overlay {
position: fixed;
inset: 0;
z-index: 200;
z-index: var(--z-dropdown);
background: rgba(0, 0, 0, 0.9);
}
@@ -0,0 +1,450 @@
<script lang="ts">
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import { get } from 'svelte/store';
import { API_URL, apiFetch } from '$lib/api.js';
import Card from '$lib/Card.svelte';
import CardSelector from '$lib/CardSelector.svelte';
const token = () => localStorage.getItem('token');
let recipientUsername = $derived(get(page).params.username);
let theirCards: any[] = $state([]); // recipient's WTT cards (static list from profile)
let loading = $state(true);
let notFound = $state(false);
// Which panel is the selector picking for: 'mine' | 'theirs' | null
let selectorMode: 'mine' | 'theirs' | null = $state(null);
let mySelectedIds: Set<string> = $state(new Set());
let mySelectedCards: any[] = $state([]); // bound from CardSelector
let theirSelectedIds: Set<string> = $state(new Set());
let theirSelectedCards = $derived(theirCards.filter((c: any) => theirSelectedIds.has(c.id)));
let submitting = $state(false);
let error = $state('');
let done = $state(false);
onMount(async () => {
if (!token()) { goto('/auth'); return; }
const username = get(page).params.username;
const profileRes = await fetch(`${API_URL}/users/${username}`);
if (profileRes.status === 404) { notFound = true; loading = false; return; }
const profile = await profileRes.json();
theirCards = profile.wtt_cards ?? [];
loading = false;
});
function openSelector(mode: 'mine' | 'theirs') {
selectorMode = mode;
}
function closeSelector() {
selectorMode = null;
}
async function sendOffer() {
if (mySelectedIds.size === 0 && theirSelectedIds.size === 0) {
error = 'At least one side must include cards.';
return;
}
submitting = true;
error = '';
const res = await apiFetch(`${API_URL}/trade-proposals`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
recipient_username: get(page).params.username,
offered_card_ids: [...mySelectedIds],
requested_card_ids: [...theirSelectedIds],
}),
});
submitting = false;
if (res.ok) {
done = true;
} else {
const data = await res.json().catch(() => ({}));
error = data.detail ?? 'Failed to send offer.';
}
}
</script>
<main>
{#if loading}
<div class="center-screen">
<p class="status-text">Loading...</p>
</div>
{:else if notFound}
<div class="center-screen">
<p class="status-text">User not found.</p>
<a href="/" class="back-link">← Home</a>
</div>
{:else if done}
<div class="center-screen">
<div class="done-icon"></div>
<h2 class="done-title">Offer Sent</h2>
<p class="done-body">Your trade offer has been sent to <strong>{recipientUsername}</strong>. They have 72 hours to respond.</p>
<div class="done-actions">
<a href="/profile/{recipientUsername}" class="choose-btn">View their profile</a>
<a href="/profile" class="choose-btn">Your proposals</a>
</div>
</div>
{:else}
<!-- Full-screen card selector overlay — rendered conditionally per mode so bind works cleanly -->
{#if selectorMode === 'mine'}
<div class="selector-overlay">
<CardSelector bind:selectedIds={mySelectedIds} bind:selectedCards={mySelectedCards} onclose={closeSelector} />
</div>
{:else if selectorMode === 'theirs'}
<div class="selector-overlay">
<CardSelector staticCards={theirCards} bind:selectedIds={theirSelectedIds} onclose={closeSelector} />
</div>
{/if}
<div class="trade-layout">
<div class="trade-header">
<h1 class="trade-title">Propose a Trade <span class="trade-with">with</span> <strong class="trade-username">{recipientUsername}</strong></h1>
<p class="action-error" style="min-height: 1.2em">{error}</p>
<button
class="send-btn"
class:disabled={mySelectedIds.size === 0 && theirSelectedIds.size === 0}
disabled={submitting || (mySelectedIds.size === 0 && theirSelectedIds.size === 0)}
onclick={sendOffer}
>
{submitting ? 'Sending...' : 'Send Offer'}
</button>
</div>
<div class="trade-panels">
<!-- YOUR OFFER panel -->
<div class="panel your-panel">
<div class="panel-header">
<span class="panel-title">Your Offer</span>
<span class="panel-count">{mySelectedIds.size} card{mySelectedIds.size === 1 ? '' : 's'}</span>
<button class="choose-btn" onclick={() => openSelector('mine')}>
{mySelectedIds.size > 0 ? 'Change' : 'Choose Cards'}
</button>
</div>
<div class="panel-cards">
{#if mySelectedCards.length === 0}
<div class="empty-offer">
<p>No cards offered</p>
</div>
{:else}
<div class="card-scroll">
{#each mySelectedCards as card (card.id)}
<div class="card-wrap">
<Card {card} noHover={true} />
</div>
{/each}
</div>
{/if}
</div>
</div>
<!-- THEIR WTT panel -->
<div class="panel their-panel">
<div class="panel-header">
<span class="panel-title">You Want</span>
<span class="panel-count">{theirSelectedIds.size} card{theirSelectedIds.size === 1 ? '' : 's'}</span>
{#if theirCards.length > 0}
<button class="choose-btn" onclick={() => openSelector('theirs')}>
{theirSelectedIds.size > 0 ? 'Change' : 'Choose Cards'}
</button>
{/if}
</div>
<div class="panel-cards">
{#if theirSelectedCards.length === 0}
<div class="empty-offer">
{#if theirCards.length === 0}
<p>{recipientUsername} has no cards marked as willing to trade.</p>
{:else}
<p>No cards requested</p>
{/if}
</div>
{:else}
<div class="card-scroll">
{#each theirSelectedCards as card (card.id)}
<div class="card-wrap">
<Card {card} noHover={true} />
</div>
{/each}
</div>
{/if}
</div>
</div>
</div>
</div>
{/if}
</main>
<style>
main {
height: 100vh;
background: var(--color-bg);
display: flex;
flex-direction: column;
overflow: hidden;
}
/* ── Overlay ── */
.selector-overlay {
position: fixed;
inset: 0;
z-index: var(--z-dropdown);
background: var(--color-bg);
}
/* ── Trade layout ── */
.trade-layout {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow: hidden;
}
.trade-header {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.75rem 1.5rem;
border-bottom: 1px solid var(--color-border-dim);
flex-shrink: 0;
}
.trade-title {
font-family: 'Cinzel', serif;
font-size: var(--text-lg);
font-weight: 700;
letter-spacing: 0.08em;
color: var(--color-gold);
margin: 0;
flex: 1;
}
.trade-with {
font-family: 'Crimson Text', serif;
font-size: var(--text-lg);
font-style: italic;
font-weight: 400;
color: rgba(240, 180, 80, 0.5);
letter-spacing: 0;
}
.trade-username {
font-family: 'Cinzel', serif;
font-size: var(--text-lg);
font-weight: 700;
color: var(--color-bronze);
letter-spacing: 0.08em;
}
/* ── Panels ── */
.trade-panels {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0;
flex: 1;
min-height: 0;
}
.panel {
display: flex;
flex-direction: column;
padding: 1.25rem 1.5rem;
gap: 1rem;
min-height: 0;
}
.your-panel { border-right: 1px solid var(--color-border-dim); }
.panel-header {
display: flex;
align-items: center;
gap: 0.75rem;
flex-shrink: 0;
}
.panel-title {
font-family: 'Cinzel', serif;
font-size: var(--text-base);
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: rgba(240, 180, 80, 0.5);
}
.panel-count {
font-family: 'Crimson Text', serif;
font-size: var(--text-base);
font-style: italic;
color: rgba(240, 180, 80, 0.3);
}
.panel-cards {
flex: 1;
min-height: 0;
overflow: hidden;
}
.empty-offer {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.empty-offer p {
font-family: 'Crimson Text', serif;
font-size: var(--text-md);
font-style: italic;
color: rgba(240, 180, 80, 0.2);
margin: 0;
}
.card-scroll {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-content: flex-start;
height: 100%;
overflow-y: auto;
padding-bottom: 0.5rem;
}
/*
* Cards displayed at scale 0.62 inside a 300×420 layout box.
* Negative margins compensate so the visual footprint is 186×260px.
*/
.card-wrap {
width: 300px;
height: 420px;
margin-right: -114px;
margin-bottom: -160px;
flex-shrink: 0;
pointer-events: none;
}
.card-wrap :global(.card) {
transform: scale(0.62);
transform-origin: top left;
}
/* ── Choose button ── */
.choose-btn {
font-family: 'Cinzel', serif;
font-size: var(--btn-font-md);
font-weight: 700;
letter-spacing: 0.07em;
text-transform: uppercase;
background: var(--color-surface-raised);
border: 1px solid var(--color-border-subtle);
border-radius: var(--radius-md);
color: var(--color-gold);
padding: var(--btn-padding-md);
cursor: pointer;
transition: all 0.15s;
align-self: flex-start;
flex-shrink: 0;
}
.choose-btn:hover { border-color: var(--color-bronze); background: #4d3010; }
a.choose-btn { text-decoration: none; }
.action-error {
font-family: 'Crimson Text', serif;
font-size: var(--text-base);
color: var(--color-error);
margin: 0;
flex-shrink: 0;
}
.send-btn {
font-family: 'Cinzel', serif;
font-size: var(--btn-font-lg);
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
background: var(--color-bronze);
border: none;
border-radius: var(--radius-md);
color: var(--color-btn-text);
padding: var(--btn-padding-lg);
cursor: pointer;
transition: background 0.15s, transform 0.1s, box-shadow 0.15s;
box-shadow: var(--shadow-glow);
}
.send-btn:hover:not(:disabled) { background: var(--color-bronze-hover); transform: translateY(-1px); box-shadow: 0 0 30px rgba(200, 134, 26, 0.5); }
.send-btn:active:not(:disabled) { transform: translateY(0); }
.send-btn:disabled, .send-btn.disabled { background: #2a1a05; color: rgba(240, 180, 80, 0.2); cursor: default; box-shadow: none; }
/* ── Center screen states ── */
.center-screen {
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1.5rem;
padding: 2rem;
}
.status-text {
font-family: 'Crimson Text', serif;
font-size: var(--text-md);
font-style: italic;
color: var(--color-gold-faint);
margin: 0;
}
.done-icon { font-size: var(--text-3xl); color: rgba(200, 134, 26, 0.5); }
.done-title {
font-family: 'Cinzel', serif;
font-size: var(--text-xl);
font-weight: 700;
letter-spacing: 0.08em;
color: var(--color-gold);
margin: 0;
}
.done-body {
font-family: 'Crimson Text', serif;
font-size: var(--text-lg);
font-style: italic;
color: var(--color-gold-dim);
text-align: center;
margin: 0;
max-width: 400px;
}
.done-body strong { color: var(--color-gold-muted); font-style: normal; }
.done-actions {
display: flex;
gap: 1.5rem;
}
/* ── Responsive ── */
@media (max-width: 640px) {
.trade-panels {
grid-template-columns: 1fr;
grid-template-rows: 1fr 1fr;
overflow-y: auto;
}
.your-panel { border-right: none; border-bottom: 1px solid var(--color-border-dim); }
}
</style>
@@ -0,0 +1,439 @@
<script lang="ts">
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import { get } from 'svelte/store';
import { API_URL, apiFetch } from '$lib/api.js';
import Card from '$lib/Card.svelte';
const token = () => localStorage.getItem('token');
let proposal: any = $state(null);
let loading = $state(true);
let notFound = $state(false);
let submitting = $state(false);
let error = $state('');
let done = $state(false);
let doneStatus: 'accepted' | 'declined' | 'withdrawn' = $state('accepted');
let confirmingAccept = $state(false);
onMount(async () => {
if (!token()) { goto('/auth'); return; }
const id = get(page).params.id;
const res = await apiFetch(`${API_URL}/trade-proposals/${id}`);
if (res.status === 404 || res.status === 403) { notFound = true; loading = false; return; }
if (res.status === 401) { goto('/auth'); return; }
proposal = await res.json();
loading = false;
});
async function accept() {
submitting = true;
error = '';
const res = await apiFetch(`${API_URL}/trade-proposals/${proposal.id}/accept`, { method: 'POST' });
submitting = false;
if (res.ok) {
doneStatus = 'accepted';
done = true;
} else {
const data = await res.json().catch(() => ({}));
error = data.detail ?? 'Failed to accept proposal.';
}
}
async function decline() {
submitting = true;
error = '';
const res = await apiFetch(`${API_URL}/trade-proposals/${proposal.id}/decline`, { method: 'POST' });
submitting = false;
if (res.ok) {
doneStatus = proposal.direction === 'outgoing' ? 'withdrawn' : 'declined';
done = true;
} else {
const data = await res.json().catch(() => ({}));
error = data.detail ?? 'Failed to decline proposal.';
}
}
</script>
<main>
{#if loading}
<div class="center-screen">
<p class="status-text">Loading...</p>
</div>
{:else if notFound}
<div class="center-screen">
<p class="status-text">Proposal not found.</p>
<a href="/profile" class="action-btn secondary">← Back to Profile</a>
</div>
{:else if done}
<div class="center-screen">
<div class="done-icon"></div>
{#if doneStatus === 'accepted'}
<h2 class="done-title">Trade Accepted</h2>
<p class="done-body">The cards have been exchanged. Check your collection.</p>
{:else if doneStatus === 'declined'}
<h2 class="done-title">Proposal Declined</h2>
<p class="done-body">The trade offer from <strong>{proposal.proposer_username}</strong> has been declined.</p>
{:else}
<h2 class="done-title">Proposal Withdrawn</h2>
<p class="done-body">Your trade offer to <strong>{proposal.recipient_username}</strong> has been withdrawn.</p>
{/if}
<a href="/profile" class="action-btn secondary">← Back to Profile</a>
</div>
{:else}
<div class="trade-layout">
<div class="trade-header">
<h1 class="trade-title">
{#if proposal.direction === 'incoming'}
Trade offer <span class="trade-with">from</span> <strong class="trade-username">{proposal.proposer_username}</strong>
{:else}
Your offer <span class="trade-with">to</span> <strong class="trade-username">{proposal.recipient_username}</strong>
{/if}
</h1>
<p class="action-error" style="min-height: 1.2em">{error}</p>
{#if proposal.status !== 'pending'}
<span class="status-badge {proposal.status}">{proposal.status}</span>
{:else if proposal.direction === 'incoming'}
{#if confirmingAccept}
<span class="confirm-label">Accept and exchange cards?</span>
<button class="action-btn primary" disabled={submitting} onclick={accept}>
{submitting ? 'Accepting...' : 'Confirm'}
</button>
<button class="action-btn secondary" disabled={submitting} onclick={() => confirmingAccept = false}>
Cancel
</button>
{:else}
<button class="action-btn destructive" disabled={submitting} onclick={decline}>
{submitting ? '...' : 'Decline'}
</button>
<button class="action-btn primary" disabled={submitting} onclick={() => confirmingAccept = true}>
Accept
</button>
{/if}
{:else}
<button class="action-btn destructive" disabled={submitting} onclick={decline}>
{submitting ? '...' : 'Withdraw'}
</button>
{/if}
</div>
<div class="trade-panels">
<!-- THEIR OFFER panel (what the proposer is giving) -->
<div class="panel your-panel">
<div class="panel-header">
<span class="panel-title">
{proposal.direction === 'incoming' ? `${proposal.proposer_username}'s Offer` : 'Your Offer'}
</span>
<span class="panel-count">{proposal.offered_cards.length} card{proposal.offered_cards.length === 1 ? '' : 's'}</span>
</div>
<div class="panel-cards">
{#if proposal.offered_cards.length === 0}
<div class="empty-offer"><p>No cards offered</p></div>
{:else}
<div class="card-scroll">
{#each proposal.offered_cards as card (card.id)}
<div class="card-wrap">
<Card {card} noHover={true} />
</div>
{/each}
</div>
{/if}
</div>
</div>
<!-- REQUESTED panel (what the proposer wants) -->
<div class="panel their-panel">
<div class="panel-header">
<span class="panel-title">
{proposal.direction === 'incoming' ? 'They Want' : `${proposal.recipient_username}'s Cards`}
</span>
<span class="panel-count">{proposal.requested_cards.length} card{proposal.requested_cards.length === 1 ? '' : 's'}</span>
</div>
<div class="panel-cards">
{#if proposal.requested_cards.length === 0}
<div class="empty-offer"><p>No cards requested</p></div>
{:else}
<div class="card-scroll">
{#each proposal.requested_cards as card (card.id)}
<div class="card-wrap">
<Card {card} noHover={true} />
</div>
{/each}
</div>
{/if}
</div>
</div>
</div>
</div>
{/if}
</main>
<style>
main {
height: 100vh;
background: var(--color-bg);
display: flex;
flex-direction: column;
overflow: hidden;
}
/* ── Trade layout ── */
.trade-layout {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow: hidden;
}
.trade-header {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.75rem 1.5rem;
border-bottom: 1px solid var(--color-border-dim);
flex-shrink: 0;
}
.trade-title {
font-family: 'Cinzel', serif;
font-size: var(--text-lg);
font-weight: 700;
letter-spacing: 0.08em;
color: var(--color-gold);
margin: 0;
flex: 1;
}
.trade-with {
font-family: 'Crimson Text', serif;
font-size: var(--text-lg);
font-style: italic;
font-weight: 400;
color: rgba(240, 180, 80, 0.5);
letter-spacing: 0;
}
.trade-username {
font-family: 'Cinzel', serif;
font-size: var(--text-lg);
font-weight: 700;
color: var(--color-bronze);
letter-spacing: 0.08em;
}
/* ── Panels ── */
.trade-panels {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0;
flex: 1;
min-height: 0;
}
.panel {
display: flex;
flex-direction: column;
padding: 1.25rem 1.5rem;
gap: 1rem;
min-height: 0;
}
.your-panel { border-right: 1px solid var(--color-border-dim); }
.panel-header {
display: flex;
align-items: center;
gap: 0.75rem;
flex-shrink: 0;
}
.panel-title {
font-family: 'Cinzel', serif;
font-size: var(--text-base);
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: rgba(240, 180, 80, 0.5);
}
.panel-count {
font-family: 'Crimson Text', serif;
font-size: var(--text-base);
font-style: italic;
color: rgba(240, 180, 80, 0.3);
}
.panel-cards {
flex: 1;
min-height: 0;
overflow: hidden;
}
.empty-offer {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.empty-offer p {
font-family: 'Crimson Text', serif;
font-size: var(--text-md);
font-style: italic;
color: rgba(240, 180, 80, 0.2);
margin: 0;
}
.card-scroll {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-content: flex-start;
height: 100%;
overflow-y: auto;
padding-bottom: 0.5rem;
}
.card-wrap {
width: 300px;
height: 420px;
margin-right: -114px;
margin-bottom: -160px;
flex-shrink: 0;
pointer-events: none;
}
.card-wrap :global(.card) {
transform: scale(0.62);
transform-origin: top left;
}
/* ── Buttons ── */
.action-btn {
font-family: 'Cinzel', serif;
font-size: var(--btn-font-lg);
font-weight: 700;
letter-spacing: 0.07em;
text-transform: uppercase;
border-radius: var(--radius-md);
padding: var(--btn-padding-lg);
cursor: pointer;
transition: background 0.15s, transform 0.1s;
border: none;
flex-shrink: 0;
text-decoration: none;
display: inline-block;
}
.action-btn.primary {
background: var(--color-bronze);
color: var(--color-btn-text);
box-shadow: var(--shadow-glow);
}
.action-btn.primary:hover:not(:disabled) { background: var(--color-bronze-hover); transform: translateY(-1px); }
.action-btn.destructive {
background: rgba(180, 40, 40, 0.8);
color: #fff;
}
.action-btn.destructive:hover:not(:disabled) { background: rgba(210, 50, 50, 0.9); }
.action-btn.secondary {
background: var(--color-surface-raised);
border: 1px solid var(--color-border-subtle);
color: var(--color-gold);
}
.action-btn.secondary:hover { border-color: var(--color-bronze); background: #4d3010; }
.action-btn:disabled { opacity: 0.5; cursor: default; transform: none; }
.confirm-label {
font-family: 'Crimson Text', serif;
font-size: var(--text-md);
font-style: italic;
color: rgba(240, 180, 80, 0.7);
}
.action-error {
font-family: 'Crimson Text', serif;
font-size: var(--text-base);
color: var(--color-error);
margin: 0;
flex-shrink: 0;
}
.status-badge {
font-family: 'Cinzel', serif;
font-size: var(--text-sm);
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
padding: 5px 12px;
border-radius: var(--radius-sm);
border: 1px solid;
flex-shrink: 0;
}
.status-badge.accepted { color: var(--color-success); border-color: rgba(106, 170, 106, 0.4); }
.status-badge.declined { color: var(--color-error); border-color: rgba(200, 64, 64, 0.4); }
.status-badge.expired { color: rgba(240, 180, 80, 0.3); border-color: var(--color-border-dim); }
.status-badge.withdrawn { color: rgba(240, 180, 80, 0.3); border-color: var(--color-border-dim); }
/* ── Center screen states ── */
.center-screen {
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1.5rem;
padding: 2rem;
}
.status-text {
font-family: 'Crimson Text', serif;
font-size: var(--text-md);
font-style: italic;
color: var(--color-gold-faint);
margin: 0;
}
.done-icon { font-size: var(--text-3xl); color: rgba(200, 134, 26, 0.5); }
.done-title {
font-family: 'Cinzel', serif;
font-size: var(--text-xl);
font-weight: 700;
letter-spacing: 0.08em;
color: var(--color-gold);
margin: 0;
}
.done-body {
font-family: 'Crimson Text', serif;
font-size: var(--text-lg);
font-style: italic;
color: var(--color-gold-dim);
text-align: center;
margin: 0;
max-width: 400px;
}
.done-body strong { color: var(--color-gold-muted); font-style: normal; }
/* ── Responsive ── */
@media (max-width: 640px) {
.trade-panels {
grid-template-columns: 1fr;
grid-template-rows: 1fr 1fr;
overflow-y: auto;
}
.your-panel { border-right: none; border-bottom: 1px solid var(--color-border-dim); }
}
</style>
+290
View File
@@ -0,0 +1,290 @@
<script lang="ts">
import { API_URL, apiFetch } from '$lib/api.js';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
let query = $state('');
let results: any[] = $state([]);
let loading = $state(false);
let searched = $state(false);
onMount(() => {
if (!localStorage.getItem('token')) goto('/auth');
});
$effect(() => {
const q = query;
if (q.trim().length === 0) {
results = [];
searched = false;
return;
}
if (q.trim().length < 2) {
results = [];
searched = false;
return;
}
const timer = setTimeout(async () => {
loading = true;
const res = await apiFetch(`${API_URL}/users?q=${encodeURIComponent(q.trim())}`);
if (res.ok) {
results = await res.json();
}
loading = false;
searched = true;
}, 300);
return () => clearTimeout(timer);
});
</script>
<div class="page">
<header class="page-header">
<h1>Players</h1>
<p class="subtitle">Search for other players by username</p>
</header>
<div class="search-wrap">
<div class="search-field">
<svg class="search-icon" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="8.5" cy="8.5" r="5.5" stroke="currentColor" stroke-width="1.5"/>
<path d="M13 13l3.5 3.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
<input
type="text"
bind:value={query}
placeholder="Search by username..."
autocomplete="off"
spellcheck="false"
/>
</div>
</div>
<div class="results-area">
{#if query.trim() === ''}
<p class="hint">Type a username to search</p>
{:else if loading}
<p class="hint">Searching...</p>
{:else if searched && results.length === 0}
<p class="hint">No players found</p>
{:else if results.length > 0}
<ul class="results">
{#each results as user}
<li class="result-row">
<a
class="username"
href="/profile/{user.username}"
>{user.username}</a>
<span class="stats">
<span class="stat win">W: {user.wins}</span>
<span class="sep">·</span>
<span class="stat loss">L: {user.losses}</span>
<span class="sep">·</span>
<span class="stat rate">{user.win_rate}% win rate</span>
</span>
<div class="actions">
<a class="btn-primary" href="/trade/offer/{user.username}">Propose Trade</a>
<button class="btn-disabled" disabled title="Coming soon">Challenge</button>
</div>
</li>
{/each}
</ul>
{/if}
</div>
</div>
<style>
.page {
max-width: 760px;
margin: 0 auto;
padding: 3rem 2rem 4rem;
}
.page-header {
margin-bottom: 2.5rem;
}
h1 {
font-family: 'Cinzel', serif;
font-size: var(--text-3xl);
font-weight: 900;
color: var(--color-gold);
letter-spacing: 0.1em;
text-transform: uppercase;
margin: 0 0 0.4rem;
}
.subtitle {
font-family: 'Crimson Text', serif;
font-size: var(--text-lg);
color: var(--color-gold-dim);
margin: 0;
}
/* Search */
.search-wrap {
margin-bottom: 2rem;
}
.search-field {
position: relative;
}
.search-icon {
position: absolute;
left: 14px;
top: 50%;
transform: translateY(-50%);
width: 18px;
height: 18px;
color: var(--color-gold-faint);
pointer-events: none;
}
input {
width: 100%;
box-sizing: border-box;
background: var(--color-surface);
border: 1.5px solid var(--color-bronze);
border-radius: var(--radius-lg);
padding: 13px 16px 13px 42px;
font-family: 'Crimson Text', serif;
font-size: var(--text-lg);
color: var(--color-gold);
outline: none;
transition: border-color 0.15s;
}
input::placeholder {
color: var(--color-gold-faint);
}
input:focus {
border-color: var(--color-gold);
}
/* States */
.hint {
font-family: 'Crimson Text', serif;
font-style: italic;
font-size: var(--text-lg);
color: var(--color-gold-faint);
text-align: center;
margin: 3rem 0;
}
/* Results */
.results {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.result-row {
display: flex;
align-items: center;
gap: 1.5rem;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: 0.9rem 1.2rem;
transition: border-color 0.15s, box-shadow 0.15s;
}
.result-row:hover {
border-color: var(--color-bronze);
box-shadow: var(--shadow-subtle);
}
.username {
font-family: 'Cinzel', serif;
font-size: var(--text-md);
font-weight: 700;
letter-spacing: 0.06em;
color: var(--color-gold);
text-decoration: none;
min-width: 120px;
transition: color 0.15s;
}
.username:hover {
color: var(--color-bronze-hover);
text-decoration: underline;
}
.stats {
flex: 1;
font-family: 'Crimson Text', serif;
font-size: var(--text-md);
color: var(--color-gold-dim);
display: flex;
align-items: center;
gap: 0.4rem;
flex-wrap: wrap;
}
.stat.win { color: var(--color-success); }
.stat.loss { color: var(--color-error); }
.stat.rate { color: var(--color-gold-dim); }
.sep {
color: var(--color-border);
font-size: var(--text-base);
}
.actions {
display: flex;
gap: 0.5rem;
flex-shrink: 0;
}
.btn-primary {
font-family: 'Cinzel', serif;
font-size: var(--btn-font-md);
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
background: var(--color-bronze);
color: var(--color-btn-text);
border: none;
border-radius: var(--radius-md);
padding: var(--btn-padding-md);
cursor: pointer;
text-decoration: none;
display: inline-block;
transition: background 0.15s;
}
.btn-primary:hover {
background: var(--color-bronze-hover);
}
.btn-disabled {
font-family: 'Cinzel', serif;
font-size: var(--btn-font-md);
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
background: rgba(200, 134, 26, 0.15);
color: rgba(240, 208, 128, 0.3);
border: 1px solid rgba(107, 76, 30, 0.25);
border-radius: var(--radius-md);
padding: var(--btn-padding-md);
cursor: not-allowed;
opacity: 1;
}
@media (max-width: 560px) {
.result-row {
flex-wrap: wrap;
gap: 0.75rem;
}
.username { min-width: unset; }
.stats { width: 100%; }
.actions { width: 100%; }
}
</style>
+14 -16
View File
@@ -50,11 +50,9 @@
</main>
<style>
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;700&family=Crimson+Text:ital,wght@0,400;0,600;1,400&display=swap');
main {
min-height: 100vh;
background: #0d0a04;
background: var(--color-bg);
display: flex;
align-items: center;
justify-content: center;
@@ -63,9 +61,9 @@
.card {
width: 380px;
background: #3d2507;
border: 2px solid #c8861a;
border-radius: 12px;
background: var(--color-surface-raised);
border: 2px solid var(--color-bronze);
border-radius: var(--radius-xl);
padding: 2rem;
display: flex;
flex-direction: column;
@@ -74,17 +72,17 @@
.title {
font-family: 'Cinzel', serif;
font-size: 20px;
font-size: var(--text-xl);
font-weight: 700;
color: #f5d060;
color: var(--color-gold);
margin: 0;
text-align: center;
}
.hint {
font-family: 'Crimson Text', serif;
font-size: 15px;
color: rgba(245, 208, 96, 0.7);
font-size: var(--text-md);
color: var(--color-gold-dim);
margin: 0;
text-align: center;
line-height: 1.6;
@@ -92,17 +90,17 @@
.btn {
width: 100%;
padding: 10px;
background: #c8861a;
color: #fff8e0;
padding: var(--btn-padding-lg);
background: var(--color-bronze);
color: var(--color-btn-text);
border: none;
border-radius: 6px;
border-radius: var(--radius-md);
font-family: 'Cinzel', serif;
font-size: 13px;
font-size: var(--btn-font-lg);
font-weight: 700;
cursor: pointer;
transition: background 0.15s;
}
.btn:hover { background: #e09820; }
.btn:hover { background: var(--color-bronze-hover); }
</style>
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+11
View File
@@ -0,0 +1,11 @@
---
name: Empty error elements are intentional
description: Always-rendered error paragraphs with min-height prevent layout shift when errors appear
type: feedback
---
Always-rendered `<p class="error">{error}</p>` elements with `min-height: 1.4em` are intentional — they reserve space so surrounding elements don't shift when an error message appears.
**Why:** Layout stability during form validation feedback.
**How to apply:** Do not suggest wrapping these in `{#if error}` or removing the `min-height`. This is a deliberate UX choice, not an oversight.