🐐
This commit is contained in:
+6
-2
@@ -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
|
||||
@@ -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
@@ -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)
|
||||
@@ -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)
|
||||
@@ -1,26 +1,38 @@
|
||||
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:
|
||||
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)
|
||||
@@ -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
|
||||
@@ -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 ###
|
||||
+69
@@ -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')")
|
||||
@@ -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):
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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,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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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",
|
||||
}
|
||||
@@ -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}
|
||||
@@ -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]
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
@@ -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"})
|
||||
@@ -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}
|
||||
@@ -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],
|
||||
}
|
||||
@@ -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}
|
||||
@@ -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]
|
||||
@@ -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
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
# Design Language
|
||||
|
||||
## Aesthetic
|
||||
Dark fantasy TCG aesthetic. Warm golds and bronzes on near-black brown backgrounds. Cinzel for headings/labels/buttons, Crimson Text for body/prose/inputs.
|
||||
|
||||
## Colors
|
||||
|
||||
All core colors are available as CSS custom properties on `:root` in `app.css` (e.g. `var(--color-bg)`, `var(--color-bronze)`).
|
||||
|
||||
| Role | Value |
|
||||
|------|-------|
|
||||
| Page background | `#0d0a04` |
|
||||
| Header background | `#1a1008` |
|
||||
| Modal / dark surface | `#110d04` |
|
||||
| Raised surface | `#3d2507` |
|
||||
| Primary text / accent | `#f0d080` (gold) |
|
||||
| Secondary text | `rgba(240, 180, 80, 0.6)` |
|
||||
| Placeholder text | `rgba(240, 180, 80, 0.3–0.4)` |
|
||||
| Interactive accent (buttons, borders, hover) | `#c8861a` (bronze-orange) |
|
||||
| Interactive accent hover | `#e09820` |
|
||||
| Border / divider | `#6b4c1e` |
|
||||
| Subtle border | `rgba(107, 76, 30, 0.3–0.5)` |
|
||||
| Button text | `#fff8e0` |
|
||||
| Error / delete | `#c84040` |
|
||||
| Success / positive | `#6aaa6a` |
|
||||
| Energy cost indicator | `#6ea0ec` |
|
||||
|
||||
## Typography
|
||||
- **Headings / labels / buttons**: Cinzel (Google Fonts), weights 400/700/900
|
||||
- **Body / prose / inputs**: Crimson Text (Google Fonts), weights 400/600; italic for flavor/secondary text
|
||||
- Button and label text: **uppercase**, `letter-spacing: 0.06–0.1em`
|
||||
- Form labels: uppercase, `letter-spacing: 0.08em`
|
||||
|
||||
### Type Scale
|
||||
|
||||
All type scale tokens are CSS custom properties on `:root` in `app.css`.
|
||||
|
||||
| Token | Value | Use for |
|
||||
|-------|-------|---------|
|
||||
| `--text-xs` | `9px` | Fine print, badges, metadata labels |
|
||||
| `--text-sm` | `11px` | Secondary text, captions, small labels |
|
||||
| `--text-base` | `13px` | Default body text, buttons, card details |
|
||||
| `--text-md` | `15px` | Form inputs, emphasized body text |
|
||||
| `--text-lg` | `18px` | Section headings, card titles |
|
||||
| `--text-xl` | `22px` | Page headings, prominent labels |
|
||||
| `--text-2xl` | `28px` | Large display headings |
|
||||
| `--text-3xl` | `36px` | Hero headings, splash text |
|
||||
|
||||
## Buttons
|
||||
|
||||
| Variant | Background | Border | Text |
|
||||
|---------|-----------|--------|------|
|
||||
| Primary | `#c8861a` | none | `#fff8e0` |
|
||||
| Secondary | `#3d2507` | `1px solid rgba(107,76,30,0.4)` | `#f0d080` |
|
||||
| Destructive | `rgba(180,40,40,0.8)` | none | `#fff` |
|
||||
|
||||
### Sizes
|
||||
|
||||
All button size tokens are CSS custom properties on `:root` in `app.css`.
|
||||
|
||||
| Size | Padding | Font-size | Border-radius | Use for |
|
||||
|------|---------|-----------|---------------|---------|
|
||||
| Small | `4px 10px` | `10px` | `var(--radius-sm)` | Toolbar filters, sort toggles, inline actions, edit/delete |
|
||||
| Medium | `8px 18px` | `12px` | `var(--radius-md)` | Card actions, done/choose, friend, secondary, cancel |
|
||||
| Large | `10px 32px` | `13px` | `var(--radius-md)` | Primary CTAs, auth submit, play, buy, accept trade |
|
||||
|
||||
- Font: Cinzel 700 uppercase, letter-spacing 0.06–0.1em
|
||||
- Disabled: opacity 0.5; hover: lighten background or brighten border + text
|
||||
|
||||
## Inputs / Forms
|
||||
- Background: `#1a1008`; border-radius: 6px; color: `#f0d080`; font: Crimson Text 15px
|
||||
- Text inputs: `1.5px solid #6b4c1e`; focus border: `#c8861a`
|
||||
- Selects: `1.5px solid #6b4c1e`
|
||||
- Placeholder: `rgba(240, 180, 80, 0.4)`; accent-color (checkboxes, ranges): `#c8861a`
|
||||
|
||||
## Containers / Panels
|
||||
|
||||
Border-radius, shadows, z-index layers, and spacing are available as CSS custom properties on `:root` in `app.css` (e.g. `var(--radius-lg)`, `var(--shadow-card)`, `var(--z-modal)`, `var(--space-md)`).
|
||||
|
||||
- Border-radius: 10–12px; box-shadow: `0 4px 24px rgba(0,0,0,0.5)`
|
||||
- Borders: `1–2px solid #6b4c1e` standard, `#c8861a` for emphasis
|
||||
- Backgrounds: `#1a1008` (surface), `#3d2507` (raised)
|
||||
- Card hover lift: `translateY(-4px) scale(1.02)`, shadow `0 12px 40px rgba(0,0,0,0.6)`
|
||||
|
||||
## Transitions
|
||||
- Default: `0.15s ease` on background, border-color, color, transform
|
||||
- Card hover: `0.2s ease`
|
||||
|
||||
## Card Type Colors (CSS vars `--bg` / `--header`)
|
||||
|
||||
| Type | Background | Header |
|
||||
|------|-----------|--------|
|
||||
| person | `#f0e0c8` | `#b87830` |
|
||||
| location | `#d8e8d4` | `#4a7a50` |
|
||||
| artwork | `#e4d4e8` | `#7a5090` |
|
||||
| life_form | `#ccdce8` | `#3a6878` |
|
||||
| event | `#e8d4d4` | `#8b2020` |
|
||||
| group | `#e8e4d0` | `#748c12` |
|
||||
| science_thing | `#c7c5c1` | `#060c17` |
|
||||
| vehicle | `#c7c1c4` | `#801953` |
|
||||
| organization | `#b7c1c4` | `#3c5251` |
|
||||
|
||||
## Rarity Badge Colors
|
||||
|
||||
| Rarity | Background | Text |
|
||||
|--------|-----------|------|
|
||||
| common | `#c8c8c8` | `#333` |
|
||||
| uncommon | `#4a7a50` | `#fff` |
|
||||
| rare | `#2a5a9b` | `#fff` |
|
||||
| super_rare | `#7a3a9b` | `#fff` |
|
||||
| epic | `#9b3a3a` | `#fff` |
|
||||
| legendary | `#b87820` | `#fff` |
|
||||
|
||||
## Card Component
|
||||
- Outer border and internal borders: `#000` (pure black) — these are structural borders within the card face, distinct from the themed `#6b4c1e` borders on containers/panels.
|
||||
- Card background: `#111`; border-radius: 12px; padding: 7px
|
||||
|
||||
## Spacing
|
||||
- Page padding: `2rem`; section gap: `1–1.5rem`; component internal gap: `0.4–0.75rem`
|
||||
+102
-1
@@ -1,14 +1,115 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;700;900&family=Crimson+Text:ital,wght@0,400;0,600;1,400&display=swap');
|
||||
|
||||
:root {
|
||||
/* Colors */
|
||||
--color-bg: #0d0a04;
|
||||
--color-surface: #1a1008;
|
||||
--color-surface-raised: #3d2507;
|
||||
--color-gold: #f0d080;
|
||||
--color-gold-muted: rgba(240, 180, 80, 0.8);
|
||||
--color-gold-dim: rgba(240, 180, 80, 0.6);
|
||||
--color-gold-faint: rgba(240, 180, 80, 0.4);
|
||||
--color-bronze: #c8861a;
|
||||
--color-bronze-hover: #e09820;
|
||||
--color-border: #6b4c1e;
|
||||
--color-border-subtle: rgba(107, 76, 30, 0.4);
|
||||
--color-border-dim: rgba(107, 76, 30, 0.3);
|
||||
--color-overlay: rgba(0, 0, 0, 0.5);
|
||||
--color-btn-text: #fff8e0;
|
||||
--color-error: #c84040;
|
||||
--color-success: #6aaa6a;
|
||||
--color-energy: #6ea0ec;
|
||||
--color-shard: #b87820;
|
||||
--color-cyan: #7ecfcf; /* shard quantity / cyan accent */
|
||||
|
||||
/* Border-radius */
|
||||
--radius-sm: 4px;
|
||||
--radius-md: 6px;
|
||||
--radius-lg: 10px;
|
||||
--radius-xl: 12px;
|
||||
--radius-full: 50%;
|
||||
|
||||
/* Shadows */
|
||||
--shadow-subtle: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
--shadow-card: 0 4px 24px rgba(0, 0, 0, 0.5);
|
||||
--shadow-elevated: 0 12px 40px rgba(0, 0, 0, 0.6);
|
||||
--shadow-glow: 0 0 20px rgba(200, 134, 26, 0.3);
|
||||
|
||||
/* Z-index layers */
|
||||
--z-base: 1;
|
||||
--z-card: 10;
|
||||
--z-header: 100;
|
||||
--z-dropdown: 200;
|
||||
--z-modal: 300;
|
||||
--z-toast: 400;
|
||||
|
||||
/* Type scale */
|
||||
--text-xs: 9px;
|
||||
--text-sm: 11px;
|
||||
--text-base: 13px;
|
||||
--text-md: 15px;
|
||||
--text-lg: 18px;
|
||||
--text-xl: 22px;
|
||||
--text-2xl: 28px;
|
||||
--text-3xl: 36px;
|
||||
|
||||
/* Button sizes */
|
||||
--btn-padding-sm: 4px 10px;
|
||||
--btn-font-sm: 10px;
|
||||
--btn-padding-md: 8px 18px;
|
||||
--btn-font-md: 12px;
|
||||
--btn-padding-lg: 10px 32px;
|
||||
--btn-font-lg: 13px;
|
||||
|
||||
/* Spacing */
|
||||
--space-xs: 0.25rem;
|
||||
--space-sm: 0.5rem;
|
||||
--space-md: 1rem;
|
||||
--space-lg: 1.5rem;
|
||||
--space-xl: 2rem;
|
||||
}
|
||||
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--color-border) transparent;
|
||||
}
|
||||
|
||||
body {
|
||||
background: #0d0a04;
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--color-border);
|
||||
border-radius: 3px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--color-bronze);
|
||||
}
|
||||
|
||||
@keyframes shard-pulse {
|
||||
0%, 100% {
|
||||
filter: brightness(1);
|
||||
text-shadow: 0 0 6px rgba(126, 207, 207, 0.45);
|
||||
}
|
||||
50% {
|
||||
filter: brightness(1.45);
|
||||
text-shadow: 0 0 14px rgba(126, 207, 207, 0.9), 0 0 28px rgba(126, 207, 207, 0.35);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
<script>
|
||||
<script lang="ts">
|
||||
let { card, noHover = false, defenseOverride = null } = $props();
|
||||
|
||||
const RARITY_BADGE = {
|
||||
@@ -7,7 +7,7 @@
|
||||
rare: { symbol: "R", label: "Rare", bg: "#2a5a9b", color: "#fff" },
|
||||
super_rare: { symbol: "SR", label: "Super Rare", bg: "#7a3a9b", color: "#fff" },
|
||||
epic: { symbol: "E", label: "Epic", bg: "#9b3a3a", color: "#fff" },
|
||||
legendary: { symbol: "L", label: "Legendary", bg: "#b87820", color: "#fff" },
|
||||
legendary: { symbol: "L", label: "Legendary", bg: "#b87820", color: "#fff8e0" },
|
||||
};
|
||||
|
||||
const TYPE_COLORS = {
|
||||
@@ -26,13 +26,13 @@
|
||||
const FOIL_RARITIES = new Set(["super_rare", "epic", "legendary"]);
|
||||
|
||||
let rarity = $derived(card.card_rarity);
|
||||
let badge = $derived(RARITY_BADGE[rarity] ?? RARITY_BADGE.common);
|
||||
let badge = $derived(RARITY_BADGE[rarity as keyof typeof RARITY_BADGE] ?? RARITY_BADGE.common);
|
||||
let foil = $derived(FOIL_RARITIES.has(rarity))
|
||||
let foilOffset = $derived(foil ? `${-(Math.random() * 5).toFixed(2)}s` : '0s');
|
||||
let super_rare = $derived(rarity == "super_rare");
|
||||
let epic = $derived(rarity == "epic");
|
||||
let legendary = $derived(rarity === "legendary");
|
||||
let colors = $derived(TYPE_COLORS[card.card_type] ?? TYPE_COLORS.other);
|
||||
let colors = $derived(TYPE_COLORS[card.card_type as keyof typeof TYPE_COLORS] ?? TYPE_COLORS.other);
|
||||
let typeLabel = $derived(card.card_type.charAt(0).toUpperCase() + card.card_type.slice(1).replace("_", " "));
|
||||
let wikiUrl = $derived("https://en.wikipedia.org/wiki/" + encodeURIComponent(card.name.replace(/ /g, "_")));
|
||||
</script>
|
||||
@@ -47,7 +47,7 @@
|
||||
|
||||
<div class="card-image-wrap">
|
||||
{#if card.image_link}
|
||||
<img src={card.image_link} alt={card.name} class="card-image" draggable="false"/>
|
||||
<img src={card.image_link} alt={card.name} class="card-image" draggable="false" loading="lazy"/>
|
||||
{:else}
|
||||
<div class="card-image-placeholder">
|
||||
<span>{card.name[0]}</span>
|
||||
@@ -56,6 +56,17 @@
|
||||
|
||||
<div class="rarity-badge" title={badge.label} style="--rb: {badge.bg}; --rc: {badge.color}">{badge.symbol}</div>
|
||||
|
||||
{#if card.willing_to_trade || card.is_favorite}
|
||||
<div class="card-badges">
|
||||
{#if card.willing_to_trade}
|
||||
<div class="wtt-badge" title="Willing to trade">⇄</div>
|
||||
{/if}
|
||||
{#if card.is_favorite}
|
||||
<div class="favorite-badge" title="Favorite">★</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<a href={wikiUrl} target="_blank" rel="noopener noreferrer" class="wiki-link" title="Open Wikipedia article">
|
||||
<svg viewBox="0 0 50 50" xmlns="http://www.w3.org/2000/svg" width="16" height="16">
|
||||
<circle cx="25" cy="25" r="24" fill="white" stroke="#888" stroke-width="1"/>
|
||||
@@ -76,7 +87,7 @@
|
||||
|
||||
<div class="card-footer">
|
||||
<span class="stat">ATK <strong>{card.attack}</strong></span>
|
||||
<span class="card-date">{new Date(card.created_at).toLocaleDateString()}</span>
|
||||
<span class="card-date">{new Date(card.generated_at ?? card.created_at).toLocaleDateString('en-GB', { year: 'numeric', month: '2-digit', day: '2-digit' })}</span>
|
||||
<span class="stat">DEF <strong>{defenseOverride !== null ? defenseOverride : card.defense}</strong></span>
|
||||
</div>
|
||||
|
||||
@@ -84,15 +95,13 @@
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;700&family=Crimson+Text:ital,wght@0,400;0,600;1,400&display=swap');
|
||||
|
||||
.card {
|
||||
width: 300px;
|
||||
border-radius: 12px;
|
||||
border-radius: var(--radius-xl);
|
||||
padding: 7px;
|
||||
background: #111;
|
||||
border: 2px solid #111;
|
||||
box-shadow: 0 4px 24px rgba(0,0,0,0.5);
|
||||
border: 2px solid #000;
|
||||
box-shadow: var(--shadow-card);
|
||||
font-family: 'Crimson Text', serif;
|
||||
position: relative;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
@@ -103,14 +112,14 @@
|
||||
|
||||
.card:hover {
|
||||
transform: translateY(-4px) scale(1.02);
|
||||
box-shadow: 0 12px 40px rgba(0,0,0,0.6);
|
||||
box-shadow: var(--shadow-elevated);
|
||||
}
|
||||
|
||||
.card.foil::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 12px;
|
||||
border-radius: var(--radius-xl);
|
||||
animation: foil-shift 2.5s ease-in-out infinite alternate;
|
||||
animation-delay: var(--foil-offset, 0s);
|
||||
pointer-events: none;
|
||||
@@ -182,7 +191,7 @@
|
||||
|
||||
.card-name {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 13px;
|
||||
font-size: var(--text-base);
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
line-height: 1.3;
|
||||
@@ -196,7 +205,7 @@
|
||||
|
||||
.card-type-badge {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 9px;
|
||||
font-size: var(--text-xs);
|
||||
color: rgba(255,255,255,0.95);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
@@ -232,7 +241,7 @@
|
||||
justify-content: center;
|
||||
background: #ddd;
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 64px;
|
||||
font-size: var(--text-3xl);
|
||||
color: rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
@@ -242,12 +251,12 @@
|
||||
left: 7px;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 50%;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--rb);
|
||||
border: 2.5px solid #000;
|
||||
color: var(--rc);
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 9px;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -256,13 +265,44 @@
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.card-badges {
|
||||
position: absolute;
|
||||
bottom: 7px;
|
||||
right: 7px;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.wtt-badge, .favorite-badge {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: var(--radius-full);
|
||||
border: 2px solid #000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.wtt-badge {
|
||||
background: rgba(0, 160, 160, 0.85);
|
||||
color: #fff;
|
||||
font-size: var(--text-base);
|
||||
}
|
||||
|
||||
.favorite-badge {
|
||||
background: rgba(255, 200, 0, 0.92);
|
||||
color: #5a3a00;
|
||||
font-size: var(--text-md);
|
||||
}
|
||||
|
||||
.wiki-link {
|
||||
position: absolute;
|
||||
top: 7px;
|
||||
right: 7px;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 50%;
|
||||
border-radius: var(--radius-full);
|
||||
background: rgba(255,255,255,0.92);
|
||||
border: 1.5px solid #000;
|
||||
display: flex;
|
||||
@@ -284,7 +324,7 @@
|
||||
|
||||
.card-text {
|
||||
padding: 10px 12px;
|
||||
font-size: 13px;
|
||||
font-size: var(--text-base);
|
||||
line-height: 1.55;
|
||||
color: #1a1208;
|
||||
font-style: italic;
|
||||
@@ -304,21 +344,21 @@
|
||||
|
||||
.stat {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 11px;
|
||||
font-size: var(--text-sm);
|
||||
color: #2a2010;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.stat strong {
|
||||
color: #000;
|
||||
font-size: 15px;
|
||||
font-size: var(--text-md);
|
||||
}
|
||||
|
||||
.card-date {
|
||||
font-size: 10px;
|
||||
color: rgba(0,0,0,0.5);
|
||||
font-size: var(--text-sm);
|
||||
color: rgba(0,0,0,0.4);
|
||||
font-style: italic;
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-family: 'Cinzel', serif;
|
||||
}
|
||||
|
||||
.cost-bubbles {
|
||||
@@ -335,15 +375,15 @@
|
||||
.cost-bubble {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: #6ea0ec;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--color-energy);
|
||||
border: 2.5px solid #000;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #08152c;
|
||||
font-size: 12px;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 700;
|
||||
font-family: 'Cinzel', serif;
|
||||
line-height: 1;
|
||||
@@ -351,6 +391,6 @@
|
||||
|
||||
.card.no-hover:hover {
|
||||
transform: none;
|
||||
box-shadow: 0 4px 24px rgba(0,0,0,0.5);
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
</style>
|
||||
@@ -1,26 +1,24 @@
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import Card from '$lib/Card.svelte';
|
||||
import { apiFetch, API_URL } from '$lib/api.js';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
const RARITIES = ['common', 'uncommon', 'rare', 'super_rare', 'epic', 'legendary'];
|
||||
const TYPES = ['person', 'location', 'artwork', 'life_form', 'event', 'group', 'science_thing', 'vehicle', 'organization', 'other'];
|
||||
const RARITY_ORDER = Object.fromEntries(RARITIES.map((r, i) => [r, i]));
|
||||
|
||||
let {
|
||||
allCards = [],
|
||||
staticCards = null as any[] | null, // if provided, use this list instead of self-fetching (e.g. another user's WTT cards)
|
||||
selectedIds = $bindable(new Set()),
|
||||
selectedCards = $bindable([]),
|
||||
selectedCost = $bindable(0),
|
||||
costMap = $bindable(new Map()),
|
||||
inDeckIds = new Set(),
|
||||
onclose = null,
|
||||
costLimit = null, // if set, prevents selecting cards that would exceed it
|
||||
showFooter = true, // set false to hide the Done button (e.g. inline deck builder)
|
||||
} = $props();
|
||||
|
||||
const selectedCost = $derived(
|
||||
costLimit !== null
|
||||
? allCards.filter(c => selectedIds.has(c.id)).reduce((sum, c) => sum + c.cost, 0)
|
||||
: 0
|
||||
);
|
||||
|
||||
function label(str) {
|
||||
function label(str: string) {
|
||||
return str.charAt(0).toUpperCase() + str.slice(1).replace(/_/g, ' ');
|
||||
}
|
||||
|
||||
@@ -32,40 +30,159 @@
|
||||
let costMax = $state(10);
|
||||
let filtersOpen = $state(false);
|
||||
let searchQuery = $state('');
|
||||
let willingToTradeOnly = $state(false);
|
||||
|
||||
let filtered = $derived.by(() => {
|
||||
const RARITY_ORDER = Object.fromEntries(RARITIES.map((r, i) => [r, i]));
|
||||
|
||||
// In static mode (staticCards provided), cards are filtered client-side.
|
||||
// In self-fetch mode, they come from the server with all filtering applied.
|
||||
const PAGE_SIZE = 40;
|
||||
let fetchedCards: any[] = $state([]);
|
||||
let total = $state(0);
|
||||
let loadingMore = $state(false);
|
||||
let hasMore = $derived(fetchedCards.length < total);
|
||||
// Must be $state so the IntersectionObserver $effect re-runs when the element is bound
|
||||
let sentinel: HTMLElement | undefined = $state();
|
||||
// .grid has overflow-y: auto — it is the scroll container, not the viewport.
|
||||
let gridEl: HTMLElement | undefined = $state();
|
||||
|
||||
// In static mode, apply client-side filter/sort to staticCards
|
||||
let cards = $derived.by(() => {
|
||||
if (staticCards === null) return fetchedCards;
|
||||
const q = searchQuery.trim().toLowerCase();
|
||||
let result = allCards.filter(c =>
|
||||
let result = staticCards.filter((c: any) =>
|
||||
selectedRarities.has(c.card_rarity) &&
|
||||
selectedTypes.has(c.card_type) &&
|
||||
c.cost >= costMin &&
|
||||
c.cost <= costMax &&
|
||||
(!q || c.name.toLowerCase().includes(q))
|
||||
(!q || c.name.toLowerCase().includes(q)) &&
|
||||
(!willingToTradeOnly || c.willing_to_trade)
|
||||
);
|
||||
result = result.slice().sort((a, b) => {
|
||||
result = result.slice().sort((a: any, b: any) => {
|
||||
let cmp = 0;
|
||||
if (sortBy === 'name') cmp = a.name.localeCompare(b.name);
|
||||
else if (sortBy === 'cost') cmp = b.cost - a.cost || a.name.localeCompare(b.name);
|
||||
else if (sortBy === 'attack') cmp = b.attack - a.attack || a.name.localeCompare(b.name);
|
||||
else if (sortBy === 'defense') cmp = b.defense - a.defense || a.name.localeCompare(b.name);
|
||||
else if (sortBy === 'rarity') cmp = RARITY_ORDER[b.card_rarity] - RARITY_ORDER[a.card_rarity] || a.name.localeCompare(b.name);
|
||||
else if (sortBy === 'date_generated') cmp = (b.generated_at ?? '').localeCompare(a.generated_at ?? '');
|
||||
else if (sortBy === 'date_received') cmp = (b.received_at ?? b.generated_at ?? '').localeCompare(a.received_at ?? a.generated_at ?? '');
|
||||
return sortAsc ? cmp : -cmp;
|
||||
});
|
||||
return result;
|
||||
});
|
||||
|
||||
function toggleSort(val) {
|
||||
// Keep bindable selectedCards in sync with loaded set.
|
||||
$effect(() => {
|
||||
selectedCards = cards.filter((c: any) => selectedIds.has(c.id));
|
||||
});
|
||||
|
||||
// Update costMap as new cards load so we know costs even after they scroll away.
|
||||
$effect(() => {
|
||||
let changed = false;
|
||||
for (const c of cards) {
|
||||
if (!costMap.has(c.id)) changed = true;
|
||||
}
|
||||
if (changed) {
|
||||
const m = new Map(costMap);
|
||||
for (const c of cards) m.set(c.id, c.cost);
|
||||
costMap = m;
|
||||
}
|
||||
});
|
||||
|
||||
// Compute cost from costMap so it includes cards not yet scrolled into view.
|
||||
$effect(() => {
|
||||
let sum = 0;
|
||||
for (const id of selectedIds) {
|
||||
sum += costMap.get(id) ?? 0;
|
||||
}
|
||||
selectedCost = sum;
|
||||
});
|
||||
|
||||
// For non-name sorts the "natural" first click should show the highest/newest/rarest values.
|
||||
// This matches old client-side behaviour where numeric sorts used b - a (descending first).
|
||||
function sortDir() {
|
||||
return sortBy === 'name'
|
||||
? (sortAsc ? 'asc' : 'desc')
|
||||
: (sortAsc ? 'desc' : 'asc');
|
||||
}
|
||||
|
||||
async function fetchCards(reset = false) {
|
||||
if (staticCards !== null) return; // static mode: nothing to fetch
|
||||
if (reset) { fetchedCards = []; total = 0; }
|
||||
if (loadingMore) return;
|
||||
loadingMore = true;
|
||||
const params = new URLSearchParams({
|
||||
skip: String(reset ? 0 : fetchedCards.length),
|
||||
limit: String(PAGE_SIZE),
|
||||
search: searchQuery.trim(),
|
||||
cost_min: String(costMin),
|
||||
cost_max: String(costMax),
|
||||
favorites_only: 'false',
|
||||
wtt_only: String(willingToTradeOnly),
|
||||
sort_by: sortBy,
|
||||
sort_dir: sortDir(),
|
||||
});
|
||||
for (const r of selectedRarities) params.append('rarities', r);
|
||||
for (const t of selectedTypes) params.append('types', t);
|
||||
const res = await apiFetch(`${API_URL}/cards?${params}`);
|
||||
if (!res.ok) { loadingMore = false; return; }
|
||||
const data = await res.json();
|
||||
fetchedCards = reset ? data.cards : [...fetchedCards, ...data.cards];
|
||||
total = data.total;
|
||||
loadingMore = false;
|
||||
}
|
||||
|
||||
// Exposed so parents (e.g. shatter page) can trigger a full refetch after mutations
|
||||
export function refresh() { fetchCards(true); }
|
||||
|
||||
// Debounced refetch on any filter/sort change (only in self-fetch mode).
|
||||
// Skip the initial run — the component mounts and triggers the first fetch directly
|
||||
// via the onMount-equivalent $effect below, avoiding a double-fetch flash.
|
||||
let mounted = false;
|
||||
let fetchTimer: number;
|
||||
$effect(() => {
|
||||
searchQuery; sortBy; sortAsc; selectedRarities; selectedTypes;
|
||||
costMin; costMax; willingToTradeOnly;
|
||||
if (staticCards !== null || !mounted) return;
|
||||
clearTimeout(fetchTimer);
|
||||
fetchTimer = setTimeout(() => fetchCards(true), 300);
|
||||
});
|
||||
|
||||
// onMount runs once and doesn't track reactive dependencies — safe for the initial fetch.
|
||||
// Using $effect here causes re-runs whenever loadingMore changes, resetting state infinitely.
|
||||
onMount(() => {
|
||||
if (staticCards === null) fetchCards(true).then(() => { mounted = true; });
|
||||
else mounted = true;
|
||||
});
|
||||
|
||||
// IntersectionObserver: load next page when sentinel scrolls into view (self-fetch mode only).
|
||||
// Uses gridEl as root because .grid has overflow-y: auto — it is the scroll container.
|
||||
// Both sentinel and gridEl are $state so this effect re-runs once both are bound.
|
||||
$effect(() => {
|
||||
if (!sentinel || !gridEl || staticCards !== null) return;
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0].isIntersecting && hasMore && !loadingMore) fetchCards(false);
|
||||
},
|
||||
{ root: gridEl, rootMargin: '200px' }
|
||||
);
|
||||
observer.observe(sentinel);
|
||||
return () => observer.disconnect();
|
||||
});
|
||||
|
||||
function toggleSort(val: string) {
|
||||
if (sortBy === val) sortAsc = !sortAsc;
|
||||
else { sortBy = val; sortAsc = true; }
|
||||
}
|
||||
|
||||
function toggleRarity(r) {
|
||||
function toggleRarity(r: string) {
|
||||
const s = new Set(selectedRarities);
|
||||
s.has(r) ? s.delete(r) : s.add(r);
|
||||
selectedRarities = s;
|
||||
}
|
||||
|
||||
function toggleType(t) {
|
||||
function toggleType(t: string) {
|
||||
const s = new Set(selectedTypes);
|
||||
s.has(t) ? s.delete(t) : s.add(t);
|
||||
selectedTypes = s;
|
||||
@@ -76,14 +193,17 @@
|
||||
function toggleAllRarities() { selectedRarities = allRaritiesSelected() ? new Set() : new Set(RARITIES); }
|
||||
function toggleAllTypes() { selectedTypes = allTypesSelected() ? new Set() : new Set(TYPES); }
|
||||
|
||||
function toggleCard(id) {
|
||||
function toggleCard(id: string | number) {
|
||||
const s = new Set(selectedIds);
|
||||
if (s.has(id)) {
|
||||
s.delete(id);
|
||||
} else {
|
||||
if (costLimit !== null) {
|
||||
const card = allCards.find(c => c.id === id);
|
||||
if (card && selectedCost + card.cost > costLimit) return;
|
||||
const card = cards.find((c: any) => c.id === id);
|
||||
if (costLimit !== null && card && selectedCost + card.cost > costLimit) return;
|
||||
if (card && !costMap.has(id)) {
|
||||
const m = new Map(costMap);
|
||||
m.set(id, card.cost);
|
||||
costMap = m;
|
||||
}
|
||||
s.add(id);
|
||||
}
|
||||
@@ -101,7 +221,7 @@
|
||||
<div class="toolbar">
|
||||
<div class="sort-row">
|
||||
<span class="toolbar-label">Sort by</span>
|
||||
{#each [['name','Name'],['cost','Cost'],['attack','Attack'],['defense','Defense'],['rarity','Rarity']] as [val, lbl]}
|
||||
{#each [['name','Name'],['cost','Cost'],['attack','Attack'],['defense','Defense'],['rarity','Rarity'],['date_generated','Generated'],['date_received','Received']] as [val, lbl]}
|
||||
<button class="sort-btn" class:active={sortBy === val} onclick={() => toggleSort(val)}>
|
||||
{lbl}
|
||||
{#if sortBy === val}<span class="sort-arrow">{sortAsc ? '↑' : '↓'}</span>{/if}
|
||||
@@ -115,13 +235,22 @@
|
||||
bind:value={searchQuery}
|
||||
/>
|
||||
|
||||
<button class="filter-toggle" onclick={() => filtersOpen = !filtersOpen}>
|
||||
{filtersOpen ? 'Hide filters' : 'Filter'}
|
||||
<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}
|
||||
<div class="filters">
|
||||
@@ -171,11 +300,12 @@
|
||||
{/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)}
|
||||
<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)}
|
||||
@@ -187,20 +317,25 @@
|
||||
<div class="selected-badge">✓</div>
|
||||
{/if}
|
||||
{#if inDeckIds.has(card.id)}
|
||||
<div class="in-deck-badge" title="In a deck">⊞</div>
|
||||
<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}
|
||||
</div>
|
||||
{/each}
|
||||
<!-- Sentinel inside the scroll container (.grid) for the IntersectionObserver root -->
|
||||
<div bind:this={sentinel} class="scroll-sentinel"></div>
|
||||
{#if loadingMore}<p class="status loading-more">Loading...</p>{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;700&family=Crimson+Text:ital,wght@0,400;0,600;1,400&display=swap');
|
||||
|
||||
.selector {
|
||||
background: #0d0a04;
|
||||
background: var(--color-bg);
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
@@ -211,7 +346,7 @@
|
||||
.toolbar {
|
||||
flex-shrink: 0;
|
||||
padding: 1.5rem 2rem 1rem;
|
||||
border-bottom: 1px solid rgba(107, 76, 30, 0.3);
|
||||
border-bottom: 1px solid var(--color-border-dim);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
@@ -226,23 +361,23 @@
|
||||
|
||||
.search-input {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 15px;
|
||||
background: rgba(255,255,255,0.04);
|
||||
border: 1px solid rgba(107, 76, 30, 0.4);
|
||||
border-radius: 4px;
|
||||
color: #f0d080;
|
||||
font-size: var(--text-md);
|
||||
background: var(--color-surface);
|
||||
border: 1.5px solid var(--color-bronze);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-gold);
|
||||
padding: 5px 10px;
|
||||
outline: none;
|
||||
width: 220px;
|
||||
margin-left: auto;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.search-input:focus { border-color: #c8861a; }
|
||||
.search-input::placeholder { color: rgba(240, 180, 80, 0.3); }
|
||||
.search-input:focus { border-color: var(--color-gold); }
|
||||
.search-input::placeholder { color: var(--color-gold-faint); }
|
||||
|
||||
.toolbar-label {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 11px;
|
||||
font-size: var(--text-sm);
|
||||
color: rgba(240, 180, 80, 0.5);
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
@@ -251,41 +386,47 @@
|
||||
|
||||
.sort-btn {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 11px;
|
||||
font-size: var(--btn-font-sm);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
background: none;
|
||||
border: 1px solid rgba(107, 76, 30, 0.4);
|
||||
border-radius: 4px;
|
||||
color: rgba(240, 180, 80, 0.6);
|
||||
padding: 4px 10px;
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-gold-dim);
|
||||
padding: var(--btn-padding-sm);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.sort-btn:hover { border-color: #c8861a; color: #f0d080; }
|
||||
.sort-btn.active { background: #3d2507; border-color: #c8861a; color: #f0d080; }
|
||||
.sort-arrow { font-size: 10px; margin-left: 3px; }
|
||||
.sort-btn:hover { border-color: var(--color-bronze); color: var(--color-gold); }
|
||||
.sort-btn.active { background: var(--color-surface-raised); border-color: var(--color-bronze); color: var(--color-gold); }
|
||||
.filter-toggle.active { background: var(--color-surface-raised); border-color: var(--color-bronze); color: var(--color-gold); }
|
||||
.sort-arrow { font-size: var(--text-xs); margin-left: 3px; }
|
||||
|
||||
.filter-toggle {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 11px;
|
||||
font-size: var(--btn-font-sm);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
background: none;
|
||||
border: 1px solid rgba(107, 76, 30, 0.4);
|
||||
border-radius: 4px;
|
||||
color: rgba(240, 180, 80, 0.6);
|
||||
padding: 4px 10px;
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-gold-dim);
|
||||
padding: var(--btn-padding-sm);
|
||||
cursor: pointer;
|
||||
margin-left: 0.5rem;
|
||||
position: relative;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.filter-toggle:hover { border-color: #c8861a; color: #f0d080; }
|
||||
.filter-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-left: 0.75rem;
|
||||
}
|
||||
|
||||
.filter-toggle:hover { border-color: var(--color-bronze); color: var(--color-gold); }
|
||||
|
||||
.filter-dot {
|
||||
position: absolute;
|
||||
@@ -293,8 +434,8 @@
|
||||
right: -3px;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: #c8861a;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--color-bronze);
|
||||
}
|
||||
|
||||
.filters {
|
||||
@@ -309,7 +450,7 @@
|
||||
|
||||
.filter-group-label {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 11px;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
@@ -318,7 +459,7 @@
|
||||
|
||||
.select-all {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 12px;
|
||||
font-size: var(--text-sm);
|
||||
font-style: italic;
|
||||
background: none;
|
||||
border: none;
|
||||
@@ -329,7 +470,7 @@
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.select-all:hover { color: #f0d080; }
|
||||
.select-all:hover { color: var(--color-gold); }
|
||||
|
||||
.checkboxes { display: flex; flex-wrap: wrap; gap: 0.4rem 1rem; }
|
||||
|
||||
@@ -338,13 +479,13 @@
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 14px;
|
||||
color: rgba(240, 180, 80, 0.8);
|
||||
font-size: var(--text-base);
|
||||
color: var(--color-gold-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.checkbox-label input {
|
||||
accent-color: #c8861a;
|
||||
accent-color: var(--color-bronze);
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
cursor: pointer;
|
||||
@@ -354,13 +495,13 @@
|
||||
|
||||
.range-label {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 11px;
|
||||
font-size: var(--text-sm);
|
||||
color: rgba(240, 180, 80, 0.7);
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
input[type=range] {
|
||||
accent-color: #c8861a;
|
||||
accent-color: var(--color-bronze);
|
||||
width: 160px;
|
||||
}
|
||||
|
||||
@@ -374,22 +515,46 @@
|
||||
padding: 2rem 2rem 0;
|
||||
}
|
||||
|
||||
.card-item {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.received-label {
|
||||
position: absolute;
|
||||
top: 44px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
background: rgba(13, 10, 4, 0.88);
|
||||
backdrop-filter: blur(6px);
|
||||
border: 1px solid rgba(200, 134, 26, 0.55);
|
||||
border-radius: 20px;
|
||||
padding: 3px 12px;
|
||||
font-size: var(--text-sm);
|
||||
font-family: 'Cinzel', serif;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.07em;
|
||||
color: var(--color-gold);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.card-wrap {
|
||||
all: unset;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
display: block;
|
||||
border-radius: 12px;
|
||||
border-radius: var(--radius-xl);
|
||||
transition: transform 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.card-wrap:hover {
|
||||
transform: translateY(-4px) scale(1.02);
|
||||
box-shadow: 0 12px 40px rgba(0,0,0,0.6);
|
||||
box-shadow: var(--shadow-elevated);
|
||||
}
|
||||
|
||||
.card-wrap.selected {
|
||||
box-shadow: 0 0 0 3px #c8861a, 0 0 20px rgba(200, 134, 26, 0.4);
|
||||
box-shadow: 0 0 0 3px var(--color-bronze), var(--shadow-glow);
|
||||
}
|
||||
|
||||
.card-wrap.disabled {
|
||||
@@ -402,69 +567,104 @@
|
||||
top: 80px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: #c8861a;
|
||||
color: #fff8e0;
|
||||
background: var(--color-bronze);
|
||||
color: var(--color-btn-text);
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 23.875px;
|
||||
font-weight: 1000;
|
||||
font-size: var(--text-xl);
|
||||
font-weight: 900;
|
||||
padding: 4px 10px;
|
||||
border-radius: 23px;
|
||||
border: black 3px solid;
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
z-index: var(--z-card);
|
||||
}
|
||||
|
||||
.in-deck-badge::after {
|
||||
content: 'In deck';
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
right: 0;
|
||||
background: #000;
|
||||
color: #7ecfcf;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
padding: 4px 7px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid rgba(126, 207, 207, 0.5);
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
z-index: var(--z-toast);
|
||||
}
|
||||
|
||||
.in-deck-badge:hover::after {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.in-deck-badge {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 6px;
|
||||
background: rgba(13, 8, 2, 0.75);
|
||||
background: #000;
|
||||
color: #7ecfcf;
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
padding: 3px 5px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgba(126, 207, 207, 0.5);
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 700;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
padding: 0;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid rgba(126, 207, 207, 0.9);
|
||||
box-shadow: 0 0 6px rgba(126, 207, 207, 0.4);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: var(--z-card);
|
||||
}
|
||||
|
||||
.scroll-sentinel { height: 1px; width: 100%; flex-basis: 100%; }
|
||||
|
||||
.status {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 16px;
|
||||
font-size: var(--text-md);
|
||||
font-style: italic;
|
||||
color: rgba(240, 180, 80, 0.5);
|
||||
text-align: center;
|
||||
margin-top: 4rem;
|
||||
}
|
||||
|
||||
.loading-more {
|
||||
flex-basis: 100%;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.top-bar {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1rem 2rem;
|
||||
border-bottom: 1px solid rgba(107, 76, 30, 0.3);
|
||||
background: #0d0a04;
|
||||
border-bottom: 1px solid var(--color-border-dim);
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
.counter {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 13px;
|
||||
color: rgba(240, 180, 80, 0.6);
|
||||
font-size: var(--text-base);
|
||||
color: var(--color-gold-dim);
|
||||
}
|
||||
|
||||
.done-btn {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 11px;
|
||||
font-size: var(--btn-font-md);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
background: #3d2507;
|
||||
border: 1px solid #c8861a;
|
||||
border-radius: 4px;
|
||||
color: #f0d080;
|
||||
padding: 8px 24px;
|
||||
background: var(--color-surface-raised);
|
||||
border: 1px solid var(--color-bronze);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-gold);
|
||||
padding: var(--btn-padding-md);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
@@ -11,11 +11,12 @@
|
||||
<style>
|
||||
.type-badge {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 10px;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
padding: 3px 8px;
|
||||
border-radius: 3px;
|
||||
white-space: nowrap;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: default;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
let isRefreshing = false;
|
||||
/** @type {Promise<string> | null} */
|
||||
let refreshPromise = null;
|
||||
import { PUBLIC_API_URL } from '$env/static/public';
|
||||
export const API_URL = PUBLIC_API_URL;
|
||||
@@ -27,6 +28,10 @@ async function refreshTokens() {
|
||||
return data.access_token;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} url
|
||||
* @param {RequestInit} [options]
|
||||
*/
|
||||
export async function apiFetch(url, options = {}) {
|
||||
const token = localStorage.getItem('token');
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
const cache = {};
|
||||
|
||||
const FILES = {
|
||||
cardFlip: '/sounds/card-flip.mp3',
|
||||
packOpen: '/sounds/pack-open.mp3',
|
||||
packRip: '/sounds/pack-rip.mp3',
|
||||
cardPlay: '/sounds/card-play.mp3',
|
||||
attack: '/sounds/attack.mp3',
|
||||
defend: '/sounds/defend.mp3',
|
||||
cardDestroy: '/sounds/card-destroy.mp3',
|
||||
cardShatter: '/sounds/card-shatter.mp3',
|
||||
win: '/sounds/win.mp3',
|
||||
loss: '/sounds/loss.mp3',
|
||||
buttonClick: '/sounds/button-click.mp3',
|
||||
};
|
||||
|
||||
const VOLUMES = {
|
||||
cardShatter: 0.1,
|
||||
attack: 0.1,
|
||||
defend: 0.1,
|
||||
};
|
||||
|
||||
const DEFAULT_VOLUME = 0.3;
|
||||
|
||||
export function play(name) {
|
||||
if (!FILES[name]) return;
|
||||
if (!cache[name]) cache[name] = new Audio(FILES[name]);
|
||||
// cloneNode allows the same sound to overlap itself
|
||||
const audio = cache[name].cloneNode();
|
||||
audio.volume = VOLUMES[name] ?? DEFAULT_VOLUME;
|
||||
audio.play().catch(() => {});
|
||||
}
|
||||
+796
-28
@@ -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' },
|
||||
];
|
||||
|
||||
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) {
|
||||
|
||||
@@ -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>
|
||||
@@ -6,7 +6,10 @@
|
||||
|
||||
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>
|
||||
|
||||
@@ -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,17 +229,45 @@
|
||||
{#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-shatter-wrap" class:shattered={action.shattered}>
|
||||
<div class="card-flipper" class:flipped>
|
||||
{#if !action.shattered}
|
||||
<div class="card-face back"><div class="card-back-face"></div></div>
|
||||
{/if}
|
||||
<div class="card-face front"><Card {card} /></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pack-card-actions" class:actions-visible={phase === 'done' && flipped}>
|
||||
{#if action.shattered}
|
||||
<span class="shard-gained">+{action.shardGain} ◈</span>
|
||||
{:else}
|
||||
<button
|
||||
class="pack-action-btn fav"
|
||||
class:active={action.favorited}
|
||||
onclick={() => packToggleFavorite(i)}
|
||||
title="Favorite"
|
||||
>★</button>
|
||||
<button
|
||||
class="pack-action-btn trade"
|
||||
class:active={action.tradeListed}
|
||||
onclick={() => packToggleTrade(i)}
|
||||
title="Mark for Trade"
|
||||
>⇄</button>
|
||||
<button
|
||||
class="pack-action-btn shatter"
|
||||
onclick={() => packShatter(i)}
|
||||
title="Shatter for shards"
|
||||
>◈</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
@@ -204,12 +281,10 @@
|
||||
</main>
|
||||
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;700;900&family=Crimson+Text:ital,wght@0,400;0,600;1,400&display=swap');
|
||||
|
||||
main {
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
background: #0d0a04;
|
||||
background: var(--color-bg);
|
||||
padding: 2rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -225,28 +300,67 @@
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: clamp(16px, 3vw, 26px);
|
||||
font-weight: 900;
|
||||
color: #f0d080;
|
||||
color: var(--color-gold);
|
||||
letter-spacing: 0.1em;
|
||||
margin: 0 0 0.4rem;
|
||||
}
|
||||
|
||||
.countdown {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 15px;
|
||||
font-size: var(--text-md);
|
||||
font-style: italic;
|
||||
color: rgba(240, 180, 80, 0.6);
|
||||
color: var(--color-gold-dim);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.invisible {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.verify-notice {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 18px;
|
||||
font-size: var(--text-lg);
|
||||
font-style: italic;
|
||||
color: rgba(240, 180, 80, 0.55);
|
||||
color: var(--color-gold-dim);
|
||||
text-align: center;
|
||||
margin-top: 4rem;
|
||||
}
|
||||
|
||||
.no-packs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1.2rem;
|
||||
margin-top: 4rem;
|
||||
}
|
||||
|
||||
.no-packs-msg {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: var(--text-lg);
|
||||
font-style: italic;
|
||||
color: var(--color-gold-dim);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.btn-buy-packs {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: var(--btn-font-lg);
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--color-btn-text);
|
||||
background: var(--color-bronze);
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--btn-padding-lg);
|
||||
text-decoration: none;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.btn-buy-packs:hover {
|
||||
background: var(--color-bronze-hover);
|
||||
}
|
||||
|
||||
.pack-wrap {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
@@ -340,7 +454,7 @@
|
||||
position: fixed;
|
||||
overflow: hidden;
|
||||
inset: 0;
|
||||
z-index: 200;
|
||||
z-index: var(--z-dropdown);
|
||||
background: rgba(0,0,0,0);
|
||||
transition: background 0.6s ease;
|
||||
display: flex;
|
||||
@@ -432,6 +546,9 @@
|
||||
transform: translateY(80vh);
|
||||
transition: opacity 0.5s ease, transform 0.7s cubic-bezier(0.2, 0.8, 0.3, 1);
|
||||
transition-delay: calc(var(--i) * 0.1s);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.fan-card.fan-visible {
|
||||
@@ -463,6 +580,18 @@
|
||||
transform: rotateY(180deg);
|
||||
}
|
||||
|
||||
/* Wrapper holds the animation — kept separate from card-flipper so that
|
||||
CSS filter doesn't conflict with transform-style: preserve-3d */
|
||||
.card-shatter-wrap.shattered {
|
||||
animation: shatter-fade 0.7s ease-out forwards;
|
||||
}
|
||||
|
||||
@keyframes shatter-fade {
|
||||
0% { opacity: 1; filter: brightness(1); transform: scale(1); }
|
||||
30% { opacity: 1; filter: brightness(2.5) saturate(3) hue-rotate(160deg); transform: scale(1.04); }
|
||||
100% { opacity: 0; filter: brightness(1); transform: scale(0.97); }
|
||||
}
|
||||
|
||||
.card-face {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
@@ -501,12 +630,13 @@
|
||||
transform: translateX(-50%);
|
||||
padding: 10px 32px;
|
||||
background: rgba(60,30,5,0.85);
|
||||
color: #f0d080;
|
||||
border: 1.5px solid #c8861a;
|
||||
border-radius: 6px;
|
||||
color: var(--color-gold);
|
||||
border: 1.5px solid var(--color-bronze);
|
||||
border-radius: var(--radius-md);
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 13px;
|
||||
font-size: var(--text-base);
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
letter-spacing: 0.08em;
|
||||
transition: background 0.15s;
|
||||
@@ -517,6 +647,81 @@
|
||||
background: rgba(100,60,10,0.9);
|
||||
}
|
||||
|
||||
/* ── Pack card actions ── */
|
||||
.pack-card-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin-top: 8px;
|
||||
height: 44px;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.pack-card-actions.actions-visible {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.pack-action-btn {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: var(--radius-full);
|
||||
border: 1.5px solid;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.pack-action-btn.fav {
|
||||
background: rgba(30, 20, 0, 0.7);
|
||||
border-color: rgba(200, 160, 0, 0.5);
|
||||
color: rgba(240, 200, 0, 0.6);
|
||||
}
|
||||
.pack-action-btn.fav:hover, .pack-action-btn.fav.active {
|
||||
background: rgba(60, 45, 0, 0.9);
|
||||
border-color: #c8a000;
|
||||
color: #f0c800;
|
||||
}
|
||||
|
||||
.pack-action-btn.trade {
|
||||
background: rgba(0, 25, 25, 0.7);
|
||||
border-color: rgba(0, 150, 150, 0.5);
|
||||
color: rgba(0, 190, 190, 0.6);
|
||||
}
|
||||
.pack-action-btn.trade:hover, .pack-action-btn.trade.active {
|
||||
background: rgba(0, 50, 50, 0.9);
|
||||
border-color: #00a0a0;
|
||||
color: var(--color-cyan);
|
||||
}
|
||||
|
||||
.pack-action-btn.shatter {
|
||||
background: rgba(0, 20, 30, 0.7);
|
||||
border-color: rgba(100, 200, 200, 0.4);
|
||||
color: rgba(126, 207, 207, 0.6);
|
||||
}
|
||||
.pack-action-btn.shatter:hover {
|
||||
background: rgba(0, 40, 50, 0.9);
|
||||
border-color: var(--color-cyan);
|
||||
color: var(--color-cyan);
|
||||
}
|
||||
|
||||
.shard-gained {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: var(--text-md);
|
||||
font-weight: 700;
|
||||
color: var(--color-cyan);
|
||||
padding: 8px 16px;
|
||||
background: rgba(0, 40, 50, 0.8);
|
||||
border: 1.5px solid rgba(126, 207, 207, 0.5);
|
||||
border-radius: 22px;
|
||||
}
|
||||
|
||||
/* ── Mobile ── */
|
||||
@media (max-width: 640px) {
|
||||
.fan-wrap {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import { API_URL, WS_URL } from '$lib/api.js';
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
|
||||
function validate() {
|
||||
if (!username.trim()) return 'Username is required';
|
||||
if (username.length < 2) return 'Username must be at least 2 characters';
|
||||
if (username.length > 16) return 'Username must be 16 characters or fewer';
|
||||
if (mode === 'register') {
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return 'Please enter a valid email';
|
||||
@@ -40,20 +41,18 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const form = new FormData();
|
||||
form.append('username', username);
|
||||
form.append('password', password);
|
||||
const res = await fetch(`${API_URL}/login`, {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({ username, password }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.detail);
|
||||
localStorage.setItem('token', data.access_token);
|
||||
localStorage.setItem('refresh_token', data.refresh_token);
|
||||
goto('/');
|
||||
} catch (e) {
|
||||
error = e.message;
|
||||
} catch (e: any) {
|
||||
error = e.message || 'Connection failed — check your network and try again';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
@@ -61,7 +60,15 @@
|
||||
</script>
|
||||
|
||||
<main>
|
||||
<div class="card-ghost" aria-hidden="true"></div>
|
||||
|
||||
<div class="card">
|
||||
<div class="wordmark">WikiTCG</div>
|
||||
<div class="rune-divider" aria-hidden="true">
|
||||
<span class="line"></span>
|
||||
<span class="rune">✦</span>
|
||||
<span class="line"></span>
|
||||
</div>
|
||||
<h1>{mode === 'login' ? 'Sign In' : 'Register'}</h1>
|
||||
|
||||
<div class="fields">
|
||||
@@ -103,33 +110,99 @@
|
||||
</main>
|
||||
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;700&family=Crimson+Text:ital,wght@0,400;0,600;1,400&display=swap');
|
||||
|
||||
main {
|
||||
min-height: 100vh;
|
||||
background: #0d0a04;
|
||||
background: var(--color-bg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Faint ghost card silhouettes in the background */
|
||||
.card-ghost {
|
||||
position: absolute;
|
||||
right: 12%;
|
||||
top: 50%;
|
||||
transform: translateY(-50%) rotate(8deg);
|
||||
width: 180px;
|
||||
height: 260px;
|
||||
border: 2px solid var(--color-gold);
|
||||
border-radius: var(--radius-lg);
|
||||
opacity: 0.04;
|
||||
pointer-events: none;
|
||||
animation: ghost-float 6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.card-ghost::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: calc(-100% - 60vw + 180px);
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 2px solid var(--color-gold);
|
||||
border-radius: var(--radius-lg);
|
||||
transform: rotate(-12deg);
|
||||
}
|
||||
|
||||
@keyframes ghost-float {
|
||||
0%, 100% { transform: translateY(-50%) rotate(8deg); }
|
||||
50% { transform: translateY(calc(-50% - 12px)) rotate(8deg); }
|
||||
}
|
||||
|
||||
.card {
|
||||
position: relative;
|
||||
z-index: var(--z-base);
|
||||
width: 340px;
|
||||
background: #2e1c05;
|
||||
border: 2px solid #6b4c1e;
|
||||
border-radius: 12px;
|
||||
background: var(--color-surface);
|
||||
border: 2px solid var(--color-bronze);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: 2rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.2rem;
|
||||
}
|
||||
|
||||
.wordmark {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: var(--text-xl);
|
||||
font-weight: 900;
|
||||
color: var(--color-gold);
|
||||
text-align: center;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: -0.4rem;
|
||||
}
|
||||
|
||||
.rune-divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.rune-divider .line {
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--color-border-subtle);
|
||||
}
|
||||
|
||||
.rune-divider .rune {
|
||||
font-size: var(--text-xs);
|
||||
color: rgba(240, 180, 80, 0.35);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 20px;
|
||||
color: #f0d080;
|
||||
font-size: var(--text-md);
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--color-gold-dim);
|
||||
text-align: center;
|
||||
margin: 0;
|
||||
margin: -0.4rem 0 0;
|
||||
}
|
||||
|
||||
.fields {
|
||||
@@ -141,39 +214,42 @@
|
||||
input {
|
||||
width: 100%;
|
||||
padding: 9px 12px;
|
||||
background: #1a1008;
|
||||
border: 1.5px solid #8b6420;
|
||||
border-radius: 6px;
|
||||
color: #f0d080;
|
||||
background: var(--color-surface);
|
||||
border: 1.5px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-gold);
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 15px;
|
||||
font-size: var(--text-md);
|
||||
box-sizing: border-box;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
input::placeholder {
|
||||
color: rgba(240, 180, 80, 0.4);
|
||||
color: var(--color-gold-faint);
|
||||
}
|
||||
|
||||
input:focus {
|
||||
border-color: #f0d080;
|
||||
border-color: var(--color-bronze);
|
||||
}
|
||||
|
||||
button {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
background: #6b4c1e;
|
||||
color: #f0d080;
|
||||
border: 1.5px solid #8b6420;
|
||||
border-radius: 6px;
|
||||
padding: var(--btn-padding-lg);
|
||||
background: var(--color-bronze);
|
||||
color: var(--color-btn-text);
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 13px;
|
||||
font-size: var(--btn-font-lg);
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
button:hover:not(:disabled) {
|
||||
background: #8b6420;
|
||||
background: var(--color-bronze-hover);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
@@ -182,44 +258,44 @@
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #c84040;
|
||||
color: var(--color-error);
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 13px;
|
||||
font-size: var(--text-base);
|
||||
text-align: center;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.toggle {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 13px;
|
||||
color: rgba(240, 180, 80, 0.7);
|
||||
font-size: var(--text-base);
|
||||
color: var(--color-gold-dim);
|
||||
text-align: center;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.link {
|
||||
all: unset;
|
||||
color: #f0d080;
|
||||
color: var(--color-gold);
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
width: auto;
|
||||
padding: 0;
|
||||
font-size: 13px;
|
||||
font-size: var(--text-base);
|
||||
font-family: 'Crimson Text', serif;
|
||||
}
|
||||
|
||||
.link:hover {
|
||||
color: #f0d080;
|
||||
color: var(--color-gold);
|
||||
}
|
||||
|
||||
.forgot-link {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 13px;
|
||||
color: rgba(245, 208, 96, 0.45);
|
||||
font-size: var(--text-base);
|
||||
color: var(--color-gold-faint);
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.forgot-link:hover { color: rgba(245, 208, 96, 0.8); }
|
||||
.forgot-link:hover { color: var(--color-gold-muted); }
|
||||
</style>
|
||||
@@ -1,11 +1,10 @@
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import { API_URL, WS_URL } from '$lib/api.js';
|
||||
import { apiFetch } from '$lib/api.js';
|
||||
import { goto } from '$app/navigation';
|
||||
import { onMount } from 'svelte';
|
||||
import Card from '$lib/Card.svelte';
|
||||
|
||||
let allCards = $state([]);
|
||||
let loading = $state(true);
|
||||
const token = () => localStorage.getItem('token');
|
||||
|
||||
@@ -21,53 +20,106 @@
|
||||
|
||||
let filtersOpen = $state(false);
|
||||
|
||||
const RARITY_ORDER = Object.fromEntries(RARITIES.map((r, i) => [r, i]));
|
||||
|
||||
function label(str) {
|
||||
function label(str: string) {
|
||||
return str.charAt(0).toUpperCase() + str.slice(1).replace(/_/g, ' ');
|
||||
}
|
||||
|
||||
|
||||
let sortAsc = $state(true);
|
||||
let costMin = $state(1);
|
||||
let costMax = $state(10);
|
||||
let searchQuery = $state('');
|
||||
let favoritesOnly = $state(false);
|
||||
let willingToTradeOnly = $state(false);
|
||||
|
||||
let filtered = $derived.by(() => {
|
||||
const q = searchQuery.trim().toLowerCase();
|
||||
let result = allCards.filter(c =>
|
||||
selectedRarities.has(c.card_rarity) &&
|
||||
selectedTypes.has(c.card_type) &&
|
||||
c.cost >= costMin &&
|
||||
c.cost <= costMax &&
|
||||
(!q || c.name.toLowerCase().includes(q))
|
||||
// Selection mode for bulk actions
|
||||
let selectionMode = $state(false);
|
||||
let selectedIds = $state(new Set<string>());
|
||||
let bulkLoading = $state(false);
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
// 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);
|
||||
});
|
||||
|
||||
// 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' }
|
||||
);
|
||||
|
||||
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;
|
||||
observer.observe(sentinel);
|
||||
return () => observer.disconnect();
|
||||
});
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
function toggleSort(val) {
|
||||
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'}
|
||||
<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} />
|
||||
<p class="card-count">{total} card{total === 1 ? '' : 's'}</p>
|
||||
<div class="grid" style={selectionMode ? 'padding-bottom: 100px' : ''}>
|
||||
{#each cards as card (card.id)}
|
||||
<div class="card-item">
|
||||
<button
|
||||
class="card-btn"
|
||||
class:selected={selectionMode && selectedIds.has(card.id)}
|
||||
onclick={() => selectionMode ? toggleCardSelection(card.id) : openCard(card)}
|
||||
>
|
||||
<Card {card} noHover={selectionMode} />
|
||||
{#if selectionMode && selectedIds.has(card.id)}
|
||||
<div class="selected-badge">✓</div>
|
||||
{/if}
|
||||
</button>
|
||||
{#if sortBy === 'date_received' && card.received_at}
|
||||
<span class="received-label" aria-label="Date received">Received {new Date(card.received_at).toLocaleDateString('en-GB', { year: 'numeric', month: '2-digit', day: '2-digit' })}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<div bind:this={sentinel} class="scroll-sentinel"></div>
|
||||
{#if loadingMore}<p class="status">Loading more...</p>{/if}
|
||||
{/if}
|
||||
|
||||
{#if selectionMode}
|
||||
<div class="bulk-bar">
|
||||
<span class="bulk-count">{selectedIds.size} card{selectedIds.size === 1 ? '' : 's'} selected</span>
|
||||
<div class="bulk-actions">
|
||||
<button class="bulk-btn" disabled={bulkLoading} onclick={() => bulkFavorite(true)}>★ Favorite</button>
|
||||
<button class="bulk-btn" disabled={bulkLoading} onclick={() => bulkFavorite(false)}>★ Unfavorite</button>
|
||||
<button class="bulk-btn" disabled={bulkLoading} onclick={() => bulkWTT(true)}>⇄ Mark WTT</button>
|
||||
<button class="bulk-btn" disabled={bulkLoading} onclick={() => bulkWTT(false)}>⇄ Unmark WTT</button>
|
||||
</div>
|
||||
<div class="bulk-secondary">
|
||||
<button class="bulk-select-all" onclick={selectAllLoaded}>
|
||||
{cards.every(c => selectedIds.has(c.id)) ? 'Deselect All' : 'Select All'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if selectedCard}
|
||||
<div class="backdrop" onclick={closeCard}>
|
||||
<div class="card-popup" onclick={(e) => e.stopPropagation()}>
|
||||
<div class="card-popup" onclick={(e) => e.stopPropagation()} onkeydown={trapFocus} bind:this={popupEl} role="dialog" aria-modal="true" aria-label="Card details">
|
||||
<Card card={selectedCard} />
|
||||
<div class="popup-actions">
|
||||
<div class="action-col">
|
||||
@@ -289,6 +491,24 @@
|
||||
{selectedCard.reported ? 'Already Reported' : reportLoading ? 'Reporting...' : 'Report Error'}
|
||||
</button>
|
||||
</div>
|
||||
<div class="action-col">
|
||||
<button
|
||||
class="fav-btn"
|
||||
class:fav-active={selectedCard.is_favorite}
|
||||
onclick={toggleFavorite}
|
||||
>
|
||||
{selectedCard.is_favorite ? '★ Favorited' : '★ Favorite'}
|
||||
</button>
|
||||
</div>
|
||||
<div class="action-col">
|
||||
<button
|
||||
class="wtt-btn"
|
||||
class:wtt-active={selectedCard.willing_to_trade}
|
||||
onclick={toggleWillingToTrade}
|
||||
>
|
||||
{selectedCard.willing_to_trade ? '⇄ Listed for Trade' : '⇄ Mark for Trade'}
|
||||
</button>
|
||||
</div>
|
||||
<div class="action-col">
|
||||
<button
|
||||
class="refresh-btn"
|
||||
@@ -310,12 +530,10 @@
|
||||
</main>
|
||||
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;700&family=Crimson+Text:ital,wght@0,400;0,600;1,400&display=swap');
|
||||
|
||||
main {
|
||||
height: 100vh;
|
||||
overflow-y: auto;
|
||||
background: #0d0a04;
|
||||
background: var(--color-bg);
|
||||
padding: 0 2rem 2rem 2rem;
|
||||
}
|
||||
|
||||
@@ -323,9 +541,9 @@
|
||||
position: sticky;
|
||||
top: 0px;
|
||||
z-index: 50;
|
||||
background: #0d0a04;
|
||||
background: var(--color-bg);
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 1px solid rgba(107, 76, 30, 0.3);
|
||||
border-bottom: 1px solid var(--color-border-dim);
|
||||
margin-bottom: 2rem;
|
||||
padding-top: 32px;
|
||||
}
|
||||
@@ -339,23 +557,23 @@
|
||||
|
||||
.search-input {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 15px;
|
||||
background: rgba(255,255,255,0.04);
|
||||
border: 1px solid rgba(107, 76, 30, 0.4);
|
||||
border-radius: 4px;
|
||||
color: #f0d080;
|
||||
font-size: var(--text-md);
|
||||
background: var(--color-surface);
|
||||
border: 1.5px solid var(--color-bronze);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-gold);
|
||||
padding: 5px 10px;
|
||||
outline: none;
|
||||
width: 220px;
|
||||
margin-left: auto;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.search-input:focus { border-color: #c8861a; }
|
||||
.search-input:focus { border-color: var(--color-gold); }
|
||||
.search-input::placeholder { color: rgba(240, 180, 80, 0.3); }
|
||||
|
||||
.toolbar-label {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 11px;
|
||||
font-size: var(--text-sm);
|
||||
color: rgba(240, 180, 80, 0.5);
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
@@ -364,50 +582,61 @@
|
||||
|
||||
.sort-btn {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 11px;
|
||||
font-size: var(--btn-font-sm);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
background: none;
|
||||
border: 1px solid rgba(107, 76, 30, 0.4);
|
||||
border-radius: 4px;
|
||||
color: rgba(240, 180, 80, 0.6);
|
||||
padding: 4px 10px;
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-gold-dim);
|
||||
padding: var(--btn-padding-sm);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.sort-btn:hover {
|
||||
border-color: #c8861a;
|
||||
color: #f0d080;
|
||||
border-color: var(--color-bronze);
|
||||
color: var(--color-gold);
|
||||
}
|
||||
|
||||
.sort-btn.active {
|
||||
background: #3d2507;
|
||||
border-color: #c8861a;
|
||||
color: #f0d080;
|
||||
background: var(--color-surface-raised);
|
||||
border-color: var(--color-bronze);
|
||||
color: var(--color-gold);
|
||||
}
|
||||
|
||||
.filter-toggle {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 11px;
|
||||
font-size: var(--btn-font-sm);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
background: none;
|
||||
border: 1px solid rgba(107, 76, 30, 0.4);
|
||||
border-radius: 4px;
|
||||
color: rgba(240, 180, 80, 0.6);
|
||||
padding: 4px 10px;
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-gold-dim);
|
||||
padding: var(--btn-padding-sm);
|
||||
cursor: pointer;
|
||||
margin-left: 0.5rem;
|
||||
position: relative;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.filter-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-left: 0.75rem;
|
||||
}
|
||||
|
||||
.filter-toggle:hover {
|
||||
border-color: #c8861a;
|
||||
color: #f0d080;
|
||||
border-color: var(--color-bronze);
|
||||
color: var(--color-gold);
|
||||
}
|
||||
|
||||
.filter-toggle.active {
|
||||
background: var(--color-surface-raised);
|
||||
border-color: var(--color-bronze);
|
||||
color: var(--color-gold);
|
||||
}
|
||||
|
||||
.filter-dot {
|
||||
@@ -416,8 +645,8 @@
|
||||
right: -3px;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: #c8861a;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--color-bronze);
|
||||
}
|
||||
|
||||
.filters {
|
||||
@@ -441,7 +670,7 @@
|
||||
|
||||
.filter-group-label {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 11px;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
@@ -450,7 +679,7 @@
|
||||
|
||||
.select-all {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 12px;
|
||||
font-size: var(--text-sm);
|
||||
font-style: italic;
|
||||
background: none;
|
||||
border: none;
|
||||
@@ -462,7 +691,7 @@
|
||||
}
|
||||
|
||||
.select-all:hover {
|
||||
color: #f0d080;
|
||||
color: var(--color-gold);
|
||||
}
|
||||
|
||||
.checkboxes {
|
||||
@@ -476,23 +705,25 @@
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 14px;
|
||||
color: rgba(240, 180, 80, 0.8);
|
||||
font-size: var(--text-base);
|
||||
color: var(--color-gold-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.checkbox-label input {
|
||||
accent-color: #c8861a;
|
||||
accent-color: var(--color-bronze);
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.scroll-sentinel { height: 1px; }
|
||||
|
||||
.card-count {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 16px;
|
||||
font-size: var(--text-md);
|
||||
font-style: italic;
|
||||
color: rgba(240, 180, 80, 0.4);
|
||||
color: var(--color-gold-faint);
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
@@ -506,7 +737,7 @@
|
||||
|
||||
.status {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 16px;
|
||||
font-size: var(--text-md);
|
||||
font-style: italic;
|
||||
color: rgba(240, 180, 80, 0.5);
|
||||
text-align: center;
|
||||
@@ -514,7 +745,7 @@
|
||||
}
|
||||
|
||||
.sort-arrow {
|
||||
font-size: 10px;
|
||||
font-size: var(--text-xs);
|
||||
margin-left: 3px;
|
||||
}
|
||||
|
||||
@@ -526,21 +757,25 @@
|
||||
|
||||
.range-label {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 11px;
|
||||
color: rgba(240, 180, 80, 0.7);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-gold-dim);
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
input[type=range] {
|
||||
accent-color: #c8861a;
|
||||
accent-color: var(--color-bronze);
|
||||
width: 160px;
|
||||
}
|
||||
|
||||
.card-item {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.card-btn {
|
||||
all: unset;
|
||||
cursor: pointer;
|
||||
display: block;
|
||||
border-radius: 12px;
|
||||
border-radius: var(--radius-xl);
|
||||
transition: transform 0.15s;
|
||||
}
|
||||
|
||||
@@ -548,6 +783,26 @@
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
.received-label {
|
||||
position: absolute;
|
||||
top: 44px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
background: rgba(13, 10, 4, 0.88);
|
||||
backdrop-filter: blur(6px);
|
||||
border: 1px solid rgba(200, 134, 26, 0.55);
|
||||
border-radius: 20px;
|
||||
padding: 3px 12px;
|
||||
font-size: var(--text-sm);
|
||||
font-family: 'Cinzel', serif;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.07em;
|
||||
color: var(--color-gold);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
@@ -555,7 +810,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 100;
|
||||
z-index: var(--z-header);
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
|
||||
@@ -565,12 +820,17 @@
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding-top: 5rem;
|
||||
}
|
||||
|
||||
.popup-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
align-items: stretch;
|
||||
width: 100%;
|
||||
max-width: 260px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.action-col {
|
||||
@@ -578,37 +838,70 @@
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
min-height: 60px;
|
||||
}
|
||||
|
||||
.report-btn, .refresh-btn {
|
||||
.fav-btn, .wtt-btn, .report-btn, .refresh-btn {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 11px;
|
||||
font-size: var(--btn-font-md);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
border-radius: 4px;
|
||||
padding: 8px 18px;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: var(--btn-padding-md);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.fav-btn {
|
||||
background: rgba(30, 20, 0, 0.6);
|
||||
border: 1px solid rgba(200, 160, 0, 0.4);
|
||||
color: rgba(240, 200, 0, 0.6);
|
||||
}
|
||||
|
||||
.fav-btn:hover {
|
||||
border-color: rgba(200, 160, 0, 0.8);
|
||||
color: #f0c800;
|
||||
}
|
||||
|
||||
.fav-btn.fav-active {
|
||||
background: rgba(60, 45, 0, 0.8);
|
||||
border-color: #c8a000;
|
||||
color: #f0c800;
|
||||
}
|
||||
|
||||
.wtt-btn {
|
||||
background: rgba(0, 30, 30, 0.6);
|
||||
border: 1px solid rgba(0, 160, 160, 0.4);
|
||||
color: rgba(0, 200, 200, 0.6);
|
||||
}
|
||||
|
||||
.wtt-btn:hover {
|
||||
border-color: rgba(0, 180, 180, 0.8);
|
||||
color: #7ecfcf;
|
||||
}
|
||||
|
||||
.wtt-btn.wtt-active {
|
||||
background: rgba(0, 50, 50, 0.8);
|
||||
border-color: #00a0a0;
|
||||
color: #7ecfcf;
|
||||
}
|
||||
|
||||
.report-btn {
|
||||
background: rgba(180, 60, 60, 0.5);
|
||||
border: 1px solid rgba(240,250,240,0.8);
|
||||
border: 1px solid rgba(200, 60, 60, 0.5);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.report-btn:hover:not(:disabled) {
|
||||
/* border-color: #c84040; */
|
||||
color: #E0E0E0;
|
||||
background: rgba(180, 40, 40, 0.9);
|
||||
}
|
||||
|
||||
.refresh-btn {
|
||||
background: #3d2507;
|
||||
border: 1px solid #c8861a;
|
||||
color: #f0d080;
|
||||
background: var(--color-surface-raised);
|
||||
border: 1px solid var(--color-bronze);
|
||||
color: var(--color-gold);
|
||||
}
|
||||
|
||||
.refresh-btn:hover:not(:disabled) {
|
||||
@@ -622,16 +915,16 @@
|
||||
|
||||
.refresh-countdown {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 12px;
|
||||
font-size: var(--text-sm);
|
||||
font-style: italic;
|
||||
color: rgba(240, 180, 80, 0.5);
|
||||
}
|
||||
|
||||
.action-message {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 14px;
|
||||
font-size: var(--text-base);
|
||||
font-style: italic;
|
||||
color: rgba(240, 180, 80, 0.7);
|
||||
color: var(--color-gold-dim);
|
||||
margin: 0;
|
||||
min-height: 1.4em;
|
||||
text-align: center;
|
||||
@@ -639,15 +932,15 @@
|
||||
|
||||
.close-btn {
|
||||
position: absolute;
|
||||
top: -12px;
|
||||
top: calc(5rem - 14px);
|
||||
right: -12px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background: #1a1008;
|
||||
border: 1px solid #6b4c1e;
|
||||
color: rgba(240, 180, 80, 0.7);
|
||||
font-size: 12px;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-gold-dim);
|
||||
font-size: var(--text-sm);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -656,7 +949,110 @@
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
border-color: #c8861a;
|
||||
color: #f0d080;
|
||||
border-color: var(--color-bronze);
|
||||
color: var(--color-gold);
|
||||
}
|
||||
|
||||
.card-btn.selected {
|
||||
box-shadow: 0 0 0 3px var(--color-bronze), var(--shadow-glow);
|
||||
}
|
||||
|
||||
.selected-badge {
|
||||
position: absolute;
|
||||
top: 80px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: var(--color-bronze);
|
||||
color: var(--color-btn-text);
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: var(--text-xl);
|
||||
font-weight: 900;
|
||||
padding: 4px 10px;
|
||||
border-radius: 23px;
|
||||
border: black 3px solid;
|
||||
pointer-events: none;
|
||||
z-index: var(--z-card);
|
||||
}
|
||||
|
||||
.bulk-bar {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 60;
|
||||
background: var(--color-surface);
|
||||
border-top: 1px solid var(--color-border);
|
||||
padding: 12px 2rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
box-shadow: 0 -4px 20px rgba(0,0,0,0.6);
|
||||
}
|
||||
|
||||
.bulk-count {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-gold);
|
||||
white-space: nowrap;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.bulk-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.bulk-btn {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: var(--btn-font-md);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
background: var(--color-surface-raised);
|
||||
border: 1px solid rgba(107, 76, 30, 0.6);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-gold);
|
||||
padding: var(--btn-padding-md);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.bulk-btn:hover:not(:disabled) {
|
||||
border-color: var(--color-bronze);
|
||||
background: #5a3510;
|
||||
color: var(--color-btn-text);
|
||||
}
|
||||
|
||||
.bulk-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.bulk-secondary {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.bulk-select-all {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: var(--text-base);
|
||||
font-style: italic;
|
||||
background: none;
|
||||
border: none;
|
||||
color: rgba(240, 180, 80, 0.5);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
text-decoration: underline;
|
||||
transition: color 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.bulk-select-all:hover {
|
||||
color: var(--color-gold);
|
||||
}
|
||||
</style>
|
||||
@@ -1,15 +1,15 @@
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import { API_URL, WS_URL } from '$lib/api.js';
|
||||
import { apiFetch } from '$lib/api.js';
|
||||
import { goto } from '$app/navigation';
|
||||
import { onMount } from 'svelte';
|
||||
import DeckTypeBadge from '$lib/DeckTypeBadge.svelte';
|
||||
|
||||
let decks = $state([]);
|
||||
let decks: any[] = $state([]);
|
||||
let loading = $state(true);
|
||||
|
||||
let editConfirm = $state(null); // deck object pending edit confirmation
|
||||
let deleteConfirm = $state(null); // deck object pending delete confirmation
|
||||
let editConfirm: any = $state(null); // deck object pending edit confirmation
|
||||
let deleteConfirm: any = $state(null); // deck object pending delete confirmation
|
||||
|
||||
const token = () => localStorage.getItem('token');
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
goto(`/decks/${deck.id}`);
|
||||
}
|
||||
|
||||
function clickEdit(deck) {
|
||||
function clickEdit(deck: any) {
|
||||
if (deck.times_played > 0) {
|
||||
editConfirm = deck;
|
||||
} else {
|
||||
@@ -41,7 +41,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
function clickDelete(deck) {
|
||||
function clickDelete(deck: any) {
|
||||
deleteConfirm = deck;
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
deleteConfirm = null;
|
||||
}
|
||||
|
||||
function winRate(deck) {
|
||||
function winRate(deck: any) {
|
||||
if (deck.times_played === 0) return null;
|
||||
return Math.round((deck.wins / deck.times_played) * 100);
|
||||
}
|
||||
@@ -160,12 +160,10 @@
|
||||
</main>
|
||||
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;700&family=Crimson+Text:ital,wght@0,400;0,600;1,400&display=swap');
|
||||
|
||||
main {
|
||||
height: 100vh;
|
||||
overflow-y: auto;
|
||||
background: #0d0a04;
|
||||
background: var(--color-bg);
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
@@ -174,29 +172,31 @@
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 2rem;
|
||||
border-bottom: 1px solid rgba(107, 76, 30, 0.3);
|
||||
border-bottom: 1px solid var(--color-border-dim);
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: #f0d080;
|
||||
font-size: var(--text-xl);
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-gold);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.new-btn {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 11px;
|
||||
font-size: var(--btn-font-md);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
background: #3d2507;
|
||||
border: 1px solid #c8861a;
|
||||
border-radius: 4px;
|
||||
color: #f0d080;
|
||||
padding: 6px 14px;
|
||||
background: var(--color-surface-raised);
|
||||
border: 1px solid var(--color-bronze);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-gold);
|
||||
padding: var(--btn-padding-md);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
@@ -210,16 +210,16 @@
|
||||
}
|
||||
|
||||
thead tr {
|
||||
border-bottom: 1px solid rgba(107, 76, 30, 0.5);
|
||||
border-bottom: 1px solid var(--color-border-subtle);
|
||||
}
|
||||
|
||||
th {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 10px;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(240, 180, 80, 0.4);
|
||||
color: var(--color-gold-faint);
|
||||
padding: 0 1rem 0.75rem 0;
|
||||
text-align: left;
|
||||
}
|
||||
@@ -237,62 +237,43 @@
|
||||
}
|
||||
|
||||
.deck-name {
|
||||
font-size: 17px;
|
||||
color: #e8d090;
|
||||
font-size: var(--text-lg);
|
||||
color: var(--color-gold);
|
||||
}
|
||||
|
||||
.deck-count {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 13px;
|
||||
font-size: var(--text-base);
|
||||
font-weight: 700;
|
||||
color: rgba(240, 180, 80, 0.6);
|
||||
color: var(--color-gold-dim);
|
||||
width: 60px;
|
||||
}
|
||||
|
||||
.deck-cost {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 13px;
|
||||
font-size: var(--text-base);
|
||||
font-weight: 700;
|
||||
color: #6aaa6a;
|
||||
color: var(--color-success);
|
||||
width: 60px;
|
||||
}
|
||||
|
||||
.deck-cost.over-budget { color: #c85050; }
|
||||
.deck-cost.over-budget { color: var(--color-error); }
|
||||
|
||||
.deck-type { width: 90px; }
|
||||
|
||||
.type-badge {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
padding: 3px 8px;
|
||||
border-radius: 3px;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.type-wall { background: rgba(58, 104, 120, 0.3); color: #a0d4e8; border: 1px solid rgba(58, 104, 120, 0.5); }
|
||||
.type-aggro { background: rgba(139, 32, 32, 0.3); color: #e89090; border: 1px solid rgba(139, 32, 32, 0.5); }
|
||||
.type-god-card { background: rgba(184, 120, 32, 0.3); color: #f5d880; border: 1px solid rgba(184, 120, 32, 0.5); }
|
||||
.type-rush { background: rgba(74, 122, 80, 0.3); color: #a8dca8; border: 1px solid rgba(74, 122, 80, 0.5); }
|
||||
.type-control { background: rgba(122, 80, 144, 0.3); color: #d0a0e8; border: 1px solid rgba(122, 80, 144, 0.5); }
|
||||
.type-unplayable { background: rgba(60, 60, 60, 0.3); color: #909090; border: 1px solid rgba(60, 60, 60, 0.5); }
|
||||
.type-pantheon { background: rgba(184, 150, 60, 0.3); color: #fce8a0; border: 1px solid rgba(184, 150, 60, 0.5); }
|
||||
.type-balanced { background: rgba(106, 104, 96, 0.3); color: #c8c6c0; border: 1px solid rgba(106, 104, 96, 0.5); }
|
||||
.deck-type { white-space: nowrap; }
|
||||
|
||||
.deck-stat {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 13px;
|
||||
color: rgba(240, 180, 80, 0.6);
|
||||
font-size: var(--text-base);
|
||||
color: var(--color-gold-dim);
|
||||
width: 60px;
|
||||
}
|
||||
|
||||
.wins { color: #6aaa6a; }
|
||||
.losses { color: #c85050; }
|
||||
.wins { color: var(--color-success); }
|
||||
.losses { color: var(--color-error); }
|
||||
.separator { color: rgba(240, 180, 80, 0.3); }
|
||||
|
||||
.good-wr { color: #6aaa6a; }
|
||||
.bad-wr { color: #c85050; }
|
||||
.good-wr { color: var(--color-success); }
|
||||
.bad-wr { color: var(--color-error); }
|
||||
|
||||
.no-data {
|
||||
color: rgba(240, 180, 80, 0.2);
|
||||
@@ -306,25 +287,25 @@
|
||||
|
||||
.edit-btn, .delete-btn {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 10px;
|
||||
font-size: var(--btn-font-sm);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
border-radius: 4px;
|
||||
padding: 4px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: var(--btn-padding-sm);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.edit-btn {
|
||||
background: none;
|
||||
border: 1px solid rgba(107, 76, 30, 0.5);
|
||||
color: rgba(240, 180, 80, 0.7);
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
color: var(--color-gold-dim);
|
||||
}
|
||||
|
||||
.edit-btn:hover {
|
||||
border-color: #c8861a;
|
||||
color: #f0d080;
|
||||
border-color: var(--color-bronze);
|
||||
color: var(--color-gold);
|
||||
}
|
||||
|
||||
.delete-btn {
|
||||
@@ -334,13 +315,13 @@
|
||||
}
|
||||
|
||||
.delete-btn:hover {
|
||||
border-color: #c84040;
|
||||
color: #e05050;
|
||||
border-color: var(--color-error);
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.status {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 16px;
|
||||
font-size: var(--text-md);
|
||||
font-style: italic;
|
||||
color: rgba(240, 180, 80, 0.5);
|
||||
text-align: center;
|
||||
@@ -354,13 +335,13 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 100;
|
||||
z-index: var(--z-header);
|
||||
}
|
||||
|
||||
.popup {
|
||||
background: #1a1008;
|
||||
border: 1px solid #6b4c1e;
|
||||
border-radius: 10px;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 2rem;
|
||||
max-width: 400px;
|
||||
width: calc(100% - 2rem);
|
||||
@@ -371,22 +352,22 @@
|
||||
|
||||
.popup-title {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 18px;
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 700;
|
||||
color: #f0d080;
|
||||
color: var(--color-gold);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.popup-body {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 15px;
|
||||
color: rgba(240, 180, 80, 0.7);
|
||||
font-size: var(--text-md);
|
||||
color: var(--color-gold-dim);
|
||||
margin: 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.popup-body strong {
|
||||
color: #f0d080;
|
||||
color: var(--color-gold);
|
||||
}
|
||||
|
||||
.popup-actions {
|
||||
@@ -397,52 +378,52 @@
|
||||
|
||||
.popup-cancel {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 11px;
|
||||
font-size: var(--btn-font-md);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
background: none;
|
||||
border: 1px solid rgba(107, 76, 30, 0.4);
|
||||
border-radius: 4px;
|
||||
color: rgba(240, 180, 80, 0.6);
|
||||
padding: 7px 16px;
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-gold-dim);
|
||||
padding: var(--btn-padding-md);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.popup-cancel:hover {
|
||||
border-color: #c8861a;
|
||||
color: #f0d080;
|
||||
border-color: var(--color-bronze);
|
||||
color: var(--color-gold);
|
||||
}
|
||||
|
||||
.popup-confirm {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 11px;
|
||||
font-size: var(--btn-font-md);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
background: #c8861a;
|
||||
background: var(--color-bronze);
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: #fff8e0;
|
||||
padding: 7px 16px;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-btn-text);
|
||||
padding: var(--btn-padding-md);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.popup-confirm:hover { background: #e09820; }
|
||||
.popup-confirm:hover { background: var(--color-bronze-hover); }
|
||||
|
||||
.popup-delete {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 11px;
|
||||
font-size: var(--btn-font-md);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
background: rgba(180, 40, 40, 0.8);
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
border-radius: var(--radius-sm);
|
||||
color: #fff;
|
||||
padding: 7px 16px;
|
||||
padding: var(--btn-padding-md);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import { API_URL } from '$lib/api.js';
|
||||
import { apiFetch } from '$lib/api.js';
|
||||
import { goto } from '$app/navigation';
|
||||
@@ -9,17 +9,18 @@
|
||||
const deckId = $derived($page.params.id);
|
||||
const token = () => localStorage.getItem('token');
|
||||
|
||||
let allCards = $state([]);
|
||||
let selectedIds = $state(new Set());
|
||||
let selectedCost = $state(0);
|
||||
let costMap: Map<string, number> = $state(new Map());
|
||||
let deckName = $state('');
|
||||
let editingName = $state(false);
|
||||
let nameInput = $state('');
|
||||
let loading = $state(true);
|
||||
let saving = $state(false);
|
||||
let nameError = $state('');
|
||||
let saveError = $state('');
|
||||
|
||||
const selectedCost = $derived(
|
||||
allCards.filter(c => selectedIds.has(c.id)).reduce((sum, c) => sum + c.cost, 0)
|
||||
);
|
||||
const MAX_NAME = 64;
|
||||
|
||||
function startEditName() {
|
||||
nameInput = deckName;
|
||||
@@ -27,13 +28,20 @@
|
||||
}
|
||||
|
||||
function commitName() {
|
||||
if (nameInput.trim()) deckName = nameInput.trim();
|
||||
const trimmed = nameInput.trim();
|
||||
if (trimmed && trimmed.length <= MAX_NAME) {
|
||||
deckName = trimmed;
|
||||
nameError = '';
|
||||
} else if (trimmed.length > MAX_NAME) {
|
||||
nameError = `Name must be ${MAX_NAME} characters or fewer`;
|
||||
}
|
||||
editingName = false;
|
||||
}
|
||||
|
||||
async function save() {
|
||||
saving = true;
|
||||
await apiFetch(`${API_URL}/decks/${deckId}`, {
|
||||
saveError = '';
|
||||
const res = await apiFetch(`${API_URL}/decks/${deckId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
@@ -42,26 +50,29 @@
|
||||
}),
|
||||
});
|
||||
saving = false;
|
||||
if (!res.ok) {
|
||||
saveError = 'Failed to save deck. Please try again.';
|
||||
return;
|
||||
}
|
||||
goto('/decks');
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
if (!token()) { goto('/auth'); return; }
|
||||
|
||||
const [cardsRes, deckCardsRes] = await Promise.all([
|
||||
apiFetch(`${API_URL}/cards`),
|
||||
const [deckCardsRes, decksRes] = await Promise.all([
|
||||
apiFetch(`${API_URL}/decks/${deckId}/cards`),
|
||||
apiFetch(`${API_URL}/decks`),
|
||||
]);
|
||||
|
||||
if (cardsRes.status === 401) { goto('/auth'); return; }
|
||||
if (deckCardsRes.status === 401) { goto('/auth'); return; }
|
||||
|
||||
allCards = await cardsRes.json();
|
||||
const currentCardIds = await deckCardsRes.json();
|
||||
selectedIds = new Set(currentCardIds);
|
||||
const deckCards = await deckCardsRes.json();
|
||||
selectedIds = new Set(deckCards.map((c: any) => c.id));
|
||||
costMap = new Map(deckCards.map((c: any) => [c.id, c.cost]));
|
||||
|
||||
const decksRes = await apiFetch(`${API_URL}/decks`);
|
||||
const decks = await decksRes.json();
|
||||
const deck = decks.find(d => d.id === deckId);
|
||||
const deck = decks.find((d: any) => d.id === deckId);
|
||||
deckName = deck?.name ?? 'Untitled Deck';
|
||||
|
||||
loading = false;
|
||||
@@ -71,6 +82,7 @@
|
||||
<main>
|
||||
<div class="toolbar">
|
||||
<div class="deck-header">
|
||||
<div class="name-area">
|
||||
{#if editingName}
|
||||
<input
|
||||
class="name-input"
|
||||
@@ -82,14 +94,19 @@
|
||||
{: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>
|
||||
<div class="save-area">
|
||||
<button class="done-btn" onclick={save} disabled={saving}>
|
||||
{saving ? 'Saving...' : 'Done'}
|
||||
</button>
|
||||
<p class="field-error">{saveError}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -98,8 +115,9 @@
|
||||
<p class="status">Loading...</p>
|
||||
{:else}
|
||||
<CardSelector
|
||||
allCards={allCards}
|
||||
bind:selectedIds={selectedIds}
|
||||
bind:selectedCost={selectedCost}
|
||||
bind:costMap={costMap}
|
||||
costLimit={50}
|
||||
showFooter={false}
|
||||
/>
|
||||
@@ -107,21 +125,19 @@
|
||||
</main>
|
||||
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;700&family=Crimson+Text:ital,wght@0,400;0,600;1,400&display=swap');
|
||||
|
||||
main {
|
||||
height: 100vh;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: #0d0a04;
|
||||
background: var(--color-bg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
flex-shrink: 0;
|
||||
background: #0d0a04;
|
||||
background: var(--color-bg);
|
||||
padding: 1.5rem 2rem 1rem;
|
||||
border-bottom: 1px solid rgba(107, 76, 30, 0.3);
|
||||
border-bottom: 1px solid var(--color-border-dim);
|
||||
}
|
||||
|
||||
.deck-header {
|
||||
@@ -133,9 +149,9 @@
|
||||
|
||||
.name-btn {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 18px;
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 700;
|
||||
color: #f0d080;
|
||||
color: var(--color-gold);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
@@ -148,12 +164,12 @@
|
||||
|
||||
.name-input {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 18px;
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 700;
|
||||
color: #f0d080;
|
||||
color: var(--color-gold);
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 1.5px solid #c8861a;
|
||||
border-bottom: 1.5px solid var(--color-bronze);
|
||||
outline: none;
|
||||
padding: 0 0 2px 0;
|
||||
min-width: 200px;
|
||||
@@ -167,27 +183,27 @@
|
||||
|
||||
.card-counter {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 14px;
|
||||
font-size: var(--text-base);
|
||||
font-weight: 700;
|
||||
color: rgba(240, 180, 80, 0.7);
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.card-counter.full { color: #6aaa6a; }
|
||||
.card-counter.over { color: #c85050; }
|
||||
.card-counter.full { color: var(--color-success); }
|
||||
.card-counter.over { color: var(--color-error); }
|
||||
.card-counter.empty { color: rgba(240, 180, 80, 0.3); }
|
||||
|
||||
.done-btn {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 11px;
|
||||
font-size: var(--btn-font-md);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
background: #3d2507;
|
||||
border: 1px solid #c8861a;
|
||||
border-radius: 4px;
|
||||
color: #f0d080;
|
||||
padding: 6px 16px;
|
||||
background: var(--color-surface-raised);
|
||||
border: 1px solid var(--color-bronze);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-gold);
|
||||
padding: var(--btn-padding-md);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
@@ -195,9 +211,30 @@
|
||||
.done-btn:hover:not(:disabled) { background: #5a3510; }
|
||||
.done-btn:disabled { opacity: 0.5; cursor: default; }
|
||||
|
||||
.name-area {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.save-area {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.field-error {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-error);
|
||||
margin: 0;
|
||||
min-height: 1em;
|
||||
}
|
||||
|
||||
.status {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 16px;
|
||||
font-size: var(--text-md);
|
||||
font-style: italic;
|
||||
color: rgba(240, 180, 80, 0.5);
|
||||
text-align: center;
|
||||
|
||||
@@ -48,11 +48,9 @@
|
||||
</main>
|
||||
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;700&family=Crimson+Text:ital,wght@0,400;0,600;1,400&display=swap');
|
||||
|
||||
main {
|
||||
min-height: 100vh;
|
||||
background: #0d0a04;
|
||||
background: var(--color-bg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -61,9 +59,9 @@
|
||||
|
||||
.card {
|
||||
width: 340px;
|
||||
background: #2e1c05;
|
||||
border: 2px solid #6b4c1e;
|
||||
border-radius: 12px;
|
||||
background: var(--color-surface);
|
||||
border: 2px solid var(--color-bronze);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: 2rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -72,16 +70,16 @@
|
||||
|
||||
.title {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 20px;
|
||||
color: #f0d080;
|
||||
font-size: var(--text-xl);
|
||||
color: var(--color-gold);
|
||||
text-align: center;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 15px;
|
||||
color: rgba(245, 208, 96, 0.7);
|
||||
font-size: var(--text-md);
|
||||
color: var(--color-gold-dim);
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
line-height: 1.6;
|
||||
@@ -90,38 +88,41 @@
|
||||
input {
|
||||
width: 100%;
|
||||
padding: 9px 12px;
|
||||
background: #1a1008;
|
||||
border: 1.5px solid #8b6420;
|
||||
border-radius: 6px;
|
||||
color: #f0d080;
|
||||
background: var(--color-surface);
|
||||
border: 1.5px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-gold);
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 15px;
|
||||
font-size: var(--text-md);
|
||||
box-sizing: border-box;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
input::placeholder {
|
||||
color: rgba(240, 180, 80, 0.4);
|
||||
color: var(--color-gold-faint);
|
||||
}
|
||||
|
||||
input:focus { border-color: #f5d060; }
|
||||
input:focus { border-color: var(--color-bronze); }
|
||||
|
||||
|
||||
button {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
background: #6b4c1e;
|
||||
color: #f0d080;
|
||||
border: 1.5px solid #8b6420;
|
||||
border-radius: 6px;
|
||||
padding: var(--btn-padding-lg);
|
||||
background: var(--color-bronze);
|
||||
color: var(--color-btn-text);
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 13px;
|
||||
font-size: var(--btn-font-lg);
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
button:hover:not(:disabled) {
|
||||
background: #8b6420;
|
||||
background: var(--color-bronze-hover);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
@@ -131,19 +132,19 @@
|
||||
|
||||
.back-link {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 14px;
|
||||
color: rgba(245, 208, 96, 0.5);
|
||||
font-size: var(--text-base);
|
||||
color: var(--color-gold-faint);
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.back-link:hover { color: #f5d060; }
|
||||
.back-link:hover { color: var(--color-gold); }
|
||||
|
||||
.error {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 14px;
|
||||
color: #f06060;
|
||||
font-size: var(--text-base);
|
||||
color: var(--color-error);
|
||||
margin: 0;
|
||||
min-height: 1.4em;
|
||||
text-align: center;
|
||||
|
||||
@@ -71,11 +71,9 @@
|
||||
</main>
|
||||
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;700&family=Crimson+Text:ital,wght@0,400;0,600;1,400&display=swap');
|
||||
|
||||
main {
|
||||
min-height: 100vh;
|
||||
background: #0d0a04;
|
||||
background: var(--color-bg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -84,9 +82,9 @@
|
||||
|
||||
.card {
|
||||
width: 380px;
|
||||
background: #3d2507;
|
||||
border: 2px solid #c8861a;
|
||||
border-radius: 12px;
|
||||
background: var(--color-surface);
|
||||
border: 2px solid var(--color-bronze);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: 2rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -95,17 +93,17 @@
|
||||
|
||||
.title {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 20px;
|
||||
font-size: var(--text-xl);
|
||||
font-weight: 700;
|
||||
color: #f5d060;
|
||||
color: var(--color-gold);
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 15px;
|
||||
color: rgba(245, 208, 96, 0.7);
|
||||
font-size: var(--text-md);
|
||||
color: var(--color-gold-dim);
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
line-height: 1.6;
|
||||
@@ -119,52 +117,54 @@
|
||||
|
||||
.field-label {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 10px;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(245, 208, 96, 0.5);
|
||||
color: var(--color-gold-faint);
|
||||
}
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
padding: 9px 12px;
|
||||
background: #221508;
|
||||
border: 1.5px solid #c8861a;
|
||||
border-radius: 6px;
|
||||
color: #f5d060;
|
||||
background: var(--color-surface);
|
||||
border: 1.5px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-gold);
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 15px;
|
||||
font-size: var(--text-md);
|
||||
box-sizing: border-box;
|
||||
outline: none;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
input:focus { border-color: #f5d060; }
|
||||
input::placeholder { color: rgba(245, 208, 96, 0.35); }
|
||||
input:focus { border-color: var(--color-bronze); }
|
||||
input::placeholder { color: var(--color-gold-faint); }
|
||||
|
||||
.btn {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
background: #c8861a;
|
||||
color: #fff8e0;
|
||||
padding: var(--btn-padding-lg);
|
||||
background: var(--color-bronze);
|
||||
color: var(--color-btn-text);
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
border-radius: var(--radius-md);
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 13px;
|
||||
font-size: var(--btn-font-lg);
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.btn:hover:not(:disabled) { background: #e09820; }
|
||||
.btn:hover:not(:disabled) { background: var(--color-bronze-hover); }
|
||||
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
.error {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 14px;
|
||||
color: #f06060;
|
||||
font-size: var(--text-base);
|
||||
color: var(--color-error);
|
||||
margin: 0;
|
||||
min-height: 1.4em;
|
||||
text-align: center;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import Card from '$lib/Card.svelte';
|
||||
|
||||
// A fake card for display purposes
|
||||
const exampleCard = {
|
||||
@@ -12,7 +12,7 @@
|
||||
attack: 351,
|
||||
defense: 222,
|
||||
cost: 5,
|
||||
created_at: new Date().toISOString(),
|
||||
generated_at: new Date().toISOString(),
|
||||
reported: false,
|
||||
};
|
||||
|
||||
@@ -48,34 +48,7 @@
|
||||
<div class="card-explainer">
|
||||
<div class="card-annotated">
|
||||
<div class="card-display">
|
||||
<!-- Inline card rendering matching Card.svelte visuals -->
|
||||
<div class="demo-card">
|
||||
<div class="demo-inner" style="--bg: #f0e0c8; --header: #b87830">
|
||||
<div class="demo-header">
|
||||
<span class="demo-name">{exampleCard.name}</span>
|
||||
<span class="demo-type-badge">Person</span>
|
||||
</div>
|
||||
<div class="demo-image-wrap">
|
||||
<img src={exampleCard.image_link} alt={exampleCard.name} class="demo-image" />
|
||||
<div class="demo-rarity" style="background: #2a5a9b; color: #fff">R</div>
|
||||
<a href="https://en.wikipedia.org/wiki/Harald_Bluetooth" target="_blank" rel="noopener" class="demo-wiki">
|
||||
<svg viewBox="0 0 50 50" width="14" height="14"><circle cx="25" cy="25" r="24" fill="white" stroke="#888" stroke-width="1"/><text x="25" y="33" text-anchor="middle" font-family="serif" font-size="28" font-weight="bold" fill="#000">W</text></svg>
|
||||
</a>
|
||||
<div class="demo-cost-bubbles">
|
||||
{#each { length: exampleCard.cost } as _}
|
||||
<div class="demo-cost-bubble">✦</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<div class="demo-divider"></div>
|
||||
<div class="demo-text">{exampleCard.text}</div>
|
||||
<div class="demo-footer" style="background: #e8d8b8">
|
||||
<span class="demo-stat">ATK <strong>{exampleCard.attack}</strong></span>
|
||||
<span class="demo-date">{new Date(exampleCard.created_at).toLocaleDateString()}</span>
|
||||
<span class="demo-stat">DEF <strong>{exampleCard.defense}</strong></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Card card={exampleCard} noHover={true} />
|
||||
</div>
|
||||
|
||||
<!-- Annotation markers -->
|
||||
@@ -195,12 +168,10 @@
|
||||
</main>
|
||||
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;700&family=Crimson+Text:ital,wght@0,400;0,600;1,400&display=swap');
|
||||
|
||||
main {
|
||||
height: 100vh;
|
||||
overflow-y: auto;
|
||||
background: #0d0a04;
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
.content {
|
||||
@@ -211,11 +182,12 @@
|
||||
|
||||
.page-title {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: #f0d080;
|
||||
font-size: clamp(22px, 4vw, 32px);
|
||||
font-weight: 900;
|
||||
color: var(--color-gold);
|
||||
margin: 0 0 2rem;
|
||||
letter-spacing: 0.08em;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.section {
|
||||
@@ -224,14 +196,14 @@
|
||||
|
||||
.section-title {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 14px;
|
||||
font-size: var(--text-base);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: #f0d080AA;
|
||||
color: var(--color-gold-dim);
|
||||
margin: 0 0 1.25rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px solid #f0d08055;
|
||||
border-bottom: 1px solid var(--color-gold-faint);
|
||||
}
|
||||
|
||||
.body-text ul {
|
||||
@@ -240,8 +212,8 @@
|
||||
|
||||
.body-text {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 17px;
|
||||
color: rgba(240, 180, 80, 0.75);
|
||||
font-size: var(--text-lg);
|
||||
color: var(--color-gold-muted);
|
||||
line-height: 1.7;
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
@@ -272,23 +244,23 @@
|
||||
.marker {
|
||||
position: absolute;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 10;
|
||||
z-index: var(--z-card);
|
||||
}
|
||||
|
||||
.marker-bubble {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
background: #c8861a;
|
||||
border: 2px solid #fff;
|
||||
color: #fff;
|
||||
background: var(--color-bronze);
|
||||
border: 2px solid var(--color-btn-text);
|
||||
color: var(--color-btn-text);
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 11px;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.6);
|
||||
box-shadow: var(--shadow-subtle);
|
||||
}
|
||||
|
||||
/* ── Annotation list ── */
|
||||
@@ -313,10 +285,10 @@
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
background: #c8861a;
|
||||
color: #fff;
|
||||
background: var(--color-bronze);
|
||||
color: var(--color-btn-text);
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 11px;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -333,16 +305,16 @@
|
||||
|
||||
.annotation-label {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 12px;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 700;
|
||||
color: #f0d080;
|
||||
color: var(--color-gold);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.annotation-desc {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 14px;
|
||||
color: rgba(240, 180, 80, 0.6);
|
||||
font-size: var(--text-base);
|
||||
color: var(--color-gold-dim);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
@@ -350,13 +322,13 @@
|
||||
.rules-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 12px;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.rule-card {
|
||||
background: #1a1008;
|
||||
border: 1px solid rgba(107, 76, 30, 0.3);
|
||||
border-radius: 8px;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border-dim);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 1.25rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -364,198 +336,27 @@
|
||||
}
|
||||
|
||||
.rule-icon {
|
||||
color: #f0d080AA;
|
||||
font-size: 20px;
|
||||
color: var(--color-gold-dim);
|
||||
font-size: var(--text-xl);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.rule-title {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 12px;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
color: #f0d080;
|
||||
color: var(--color-gold);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.rule-body {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 14px;
|
||||
color: rgba(240, 180, 80, 0.6);
|
||||
font-size: var(--text-base);
|
||||
color: var(--color-gold-dim);
|
||||
line-height: 1.55;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ── Demo card ── */
|
||||
.demo-card {
|
||||
width: 300px;
|
||||
border-radius: 12px;
|
||||
padding: 7px;
|
||||
background: #111;
|
||||
border: 2px solid #111;
|
||||
box-shadow: 0 4px 24px rgba(0,0,0,0.5);
|
||||
font-family: 'Crimson Text', serif;
|
||||
position: relative;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.demo-inner {
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: var(--bg);
|
||||
border: 2px solid #000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.demo-header {
|
||||
padding: 9px 12px 7px;
|
||||
background: var(--header);
|
||||
border-bottom: 2px solid #000;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.demo-name {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
text-shadow: 0 1px 3px rgba(0,0,0,0.6);
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.demo-type-badge {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 9px;
|
||||
color: rgba(255,255,255,0.95);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
background: rgba(0,0,0,0.25);
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.demo-image-wrap {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 4/3;
|
||||
overflow: hidden;
|
||||
border-bottom: 2px solid #000;
|
||||
}
|
||||
|
||||
.demo-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
object-position: top;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.demo-rarity {
|
||||
position: absolute;
|
||||
top: 7px;
|
||||
left: 7px;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 50%;
|
||||
border: 2.5px solid #000;
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.demo-wiki {
|
||||
position: absolute;
|
||||
top: 7px;
|
||||
right: 7px;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255,255,255,0.92);
|
||||
border: 1.5px solid #000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.demo-cost-bubbles {
|
||||
position: absolute;
|
||||
bottom: 6px;
|
||||
left: 8px;
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
flex-wrap: wrap;
|
||||
max-width: calc(100% - 16px);
|
||||
}
|
||||
|
||||
.demo-cost-bubble {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: #6ea0ec;
|
||||
border: 2.5px solid #000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #08152c;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
font-family: 'Cinzel', serif;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.demo-divider {
|
||||
height: 2px;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.demo-text {
|
||||
padding: 10px 12px;
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
color: #1a1208;
|
||||
font-style: italic;
|
||||
background: #f0e6cc;
|
||||
border-bottom: 2px solid #000;
|
||||
height: 110px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.demo-footer {
|
||||
padding: 7px 12px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.demo-stat {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 11px;
|
||||
color: #2a2010;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.demo-stat strong {
|
||||
color: #000;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.demo-date {
|
||||
font-size: 10px;
|
||||
color: rgba(0,0,0,0.5);
|
||||
font-style: italic;
|
||||
font-family: 'Crimson Text', serif;
|
||||
}
|
||||
</style>
|
||||
@@ -1,24 +1,30 @@
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import { API_URL, WS_URL } from '$lib/api.js';
|
||||
import { apiFetch } from '$lib/api.js';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import { get } from 'svelte/store';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import Card from '$lib/Card.svelte';
|
||||
import DeckTypeBadge from '$lib/DeckTypeBadge.svelte';
|
||||
import { play } from '$lib/audio.js';
|
||||
|
||||
const token = () => localStorage.getItem('token');
|
||||
|
||||
let queueWs = null;
|
||||
let gameWs = null;
|
||||
let queueWs: WebSocket | null = null;
|
||||
let gameWs: WebSocket | null = null;
|
||||
let phase = $state('idle');
|
||||
let error = $state('');
|
||||
let reconnecting = $state(false);
|
||||
let gameReconnectDelay = 1000;
|
||||
let gameReconnectTimer: number | undefined = undefined;
|
||||
|
||||
let decks = $state([]);
|
||||
let decks: any[] = $state([]);
|
||||
let selectedDeckId = $state('');
|
||||
let selectedDeck = $derived(decks.find(d => d.id === selectedDeckId));
|
||||
|
||||
let gameId = $state('');
|
||||
let gameState = $state(null);
|
||||
let gameState: any = $state(null);
|
||||
let myId = $state('');
|
||||
|
||||
let viewingBoard = $state(false);
|
||||
@@ -34,7 +40,7 @@
|
||||
'Expert'
|
||||
);
|
||||
|
||||
let selectedHandIndex = $state(null);
|
||||
let selectedHandIndex: number | null = $state(null);
|
||||
let combatAnimating = $state(false);
|
||||
let lunging = $state(new Set());
|
||||
let lungingDown = $state(new Set());
|
||||
@@ -46,24 +52,25 @@
|
||||
let gameOver = $derived(!!gameState?.result);
|
||||
let sacrificeMode = $state(false);
|
||||
|
||||
let displayedDefense = $state({});
|
||||
let displayedDefense: Record<string, number> = $state({});
|
||||
let destroying = $state(new Set());
|
||||
let destroyed = $state(new Set());
|
||||
let displayedLife = $state({});
|
||||
let displayedLife: Record<string, number> = $state({});
|
||||
|
||||
const TURN_TIME_LIMIT = 120; // seconds
|
||||
const TIMER_WARNING = 30; // show timer when this many seconds remain
|
||||
|
||||
let turnStartedAt = $state(null);
|
||||
let turnStartedAt: Date | null = $state(null);
|
||||
let secondsRemaining = $state(TURN_TIME_LIMIT);
|
||||
let timerInterval = null
|
||||
let timerInterval: number | undefined = undefined;
|
||||
|
||||
$effect(() => {
|
||||
if (!gameState?.turn_started_at) return;
|
||||
turnStartedAt = new Date(gameState.turn_started_at);
|
||||
const ts = new Date(gameState.turn_started_at);
|
||||
turnStartedAt = ts;
|
||||
clearInterval(timerInterval);
|
||||
timerInterval = setInterval(async () => {
|
||||
const elapsed = (Date.now() - turnStartedAt) / 1000;
|
||||
const elapsed = (Date.now() - ts.getTime()) / 1000;
|
||||
secondsRemaining = Math.max(0, TURN_TIME_LIMIT - elapsed);
|
||||
|
||||
if (secondsRemaining <= 0 && !isMyTurn && gameState && !gameState.result) {
|
||||
@@ -75,6 +82,7 @@
|
||||
|
||||
onDestroy(() => {
|
||||
clearInterval(timerInterval);
|
||||
clearTimeout(gameReconnectTimer);
|
||||
});
|
||||
|
||||
async function claimTimeoutWin() {
|
||||
@@ -103,7 +111,7 @@
|
||||
...(gameState.you.board.filter(Boolean) || []),
|
||||
...(gameState.opponent.board.filter(Boolean) || []),
|
||||
];
|
||||
const next = {};
|
||||
const next: Record<string, number> = {};
|
||||
for (const card of all) next[card.instance_id] = card.defense;
|
||||
displayedDefense = next;
|
||||
});
|
||||
@@ -115,6 +123,21 @@
|
||||
|
||||
onMount(async () => {
|
||||
if (!token()) { goto('/auth'); return; }
|
||||
|
||||
// Support joining a direct challenge game via ?game_id=... query param
|
||||
const challengeGameId = get(page).url.searchParams.get('game_id');
|
||||
if (challengeGameId) {
|
||||
gameId = challengeGameId;
|
||||
phase = 'playing';
|
||||
connectToGame();
|
||||
// Load decks in the background so the lobby is ready if the connection fails
|
||||
apiFetch(`${API_URL}/decks`).then(r => r.json()).then(data => {
|
||||
decks = data;
|
||||
if (decks.length > 0) selectedDeckId = decks[0].id;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await apiFetch(`${API_URL}/decks`);
|
||||
decks = await res.json();
|
||||
if (decks.length > 0) selectedDeckId = decks[0].id;
|
||||
@@ -130,12 +153,12 @@
|
||||
error = '';
|
||||
phase = 'queuing';
|
||||
queueWs = new WebSocket(`${WS_URL}/ws/queue?deck_id=${selectedDeckId}`);
|
||||
queueWs.onopen = () => queueWs.send(token());
|
||||
queueWs.onopen = () => queueWs!.send(token()!);
|
||||
queueWs.onmessage = (e) => {
|
||||
const msg = JSON.parse(e.data);
|
||||
if (msg.type === 'game_start') {
|
||||
gameId = msg.game_id;
|
||||
queueWs.close();
|
||||
queueWs!.close();
|
||||
connectToGame();
|
||||
} else if (msg.type === 'error') {
|
||||
error = msg.message;
|
||||
@@ -147,7 +170,11 @@
|
||||
|
||||
function connectToGame() {
|
||||
gameWs = new WebSocket(`${WS_URL}/ws/game/${gameId}`);
|
||||
gameWs.onopen = () => gameWs.send(token());
|
||||
gameWs.onopen = () => {
|
||||
gameWs!.send(token()!);
|
||||
reconnecting = false;
|
||||
gameReconnectDelay = 1000;
|
||||
};
|
||||
gameWs.onmessage = async (e) => {
|
||||
const msg = JSON.parse(e.data);
|
||||
if (msg.type === 'state') {
|
||||
@@ -165,6 +192,7 @@
|
||||
phase = newState.result ? 'ended' : 'playing';
|
||||
} else if (msg.type === 'sacrifice_animation') {
|
||||
const id = msg.instance_id;
|
||||
play('cardShatter');
|
||||
destroying = new Set([...destroying, id]);
|
||||
await delay(600);
|
||||
destroying = new Set([...destroying].filter(i => i !== id));
|
||||
@@ -174,10 +202,26 @@
|
||||
setTimeout(() => error = '', 3000);
|
||||
}
|
||||
};
|
||||
gameWs.onclose = (e) => {
|
||||
// 1008 = Policy Violation — server rejects unknown/expired game_id.
|
||||
// Fall back to the normal lobby and strip the stale query param.
|
||||
if (e.code === 1008 && phase !== 'ended') {
|
||||
phase = 'idle';
|
||||
history.replaceState({}, '', '/play');
|
||||
return;
|
||||
}
|
||||
if (phase === 'playing') {
|
||||
reconnecting = true;
|
||||
gameReconnectTimer = setTimeout(() => {
|
||||
gameReconnectDelay = Math.min(gameReconnectDelay * 2, 30000);
|
||||
connectToGame();
|
||||
}, gameReconnectDelay);
|
||||
}
|
||||
};
|
||||
gameWs.onerror = () => { error = 'Connection lost'; };
|
||||
}
|
||||
|
||||
async function animateCombat(newState) {
|
||||
async function animateCombat(newState: any) {
|
||||
combatAnimating = true;
|
||||
|
||||
// The attacker is whoever was active when end_turn was called.
|
||||
@@ -187,7 +231,7 @@
|
||||
// active_player_id hasn't switched yet.
|
||||
const attackerId = newState.result
|
||||
? newState.active_player_id
|
||||
: newState.player_order.find(id => id !== newState.active_player_id);
|
||||
: newState.player_order.find((id: string) => id !== newState.active_player_id);
|
||||
|
||||
const attackerIsMe = attackerId === myId;
|
||||
|
||||
@@ -206,7 +250,11 @@
|
||||
} else {
|
||||
lungingDown = new Set([...lungingDown, attacker.instance_id]);
|
||||
}
|
||||
if (defender) shaking = new Set([...shaking, defender.instance_id]);
|
||||
play('attack');
|
||||
if (defender) {
|
||||
shaking = new Set([...shaking, defender.instance_id]);
|
||||
play('defend');
|
||||
}
|
||||
await delay(220);
|
||||
if (defender) {
|
||||
const newDefense = Math.max(0, (displayedDefense[defender.instance_id] ?? defender.defense) - attacker.attack);
|
||||
@@ -222,6 +270,7 @@
|
||||
lungingDown = new Set([...lungingDown].filter(id => id !== attacker.instance_id));
|
||||
if (defender) shaking = new Set([...shaking].filter(id => id !== defender.instance_id));
|
||||
if (defender && (displayedDefense[defender.instance_id] ?? defender.defense) <= 0) {
|
||||
play('cardShatter');
|
||||
destroying = new Set([...destroying, defender.instance_id]);
|
||||
await delay(600);
|
||||
destroying = new Set([...destroying].filter(id => id !== defender.instance_id));
|
||||
@@ -234,25 +283,27 @@
|
||||
combatAnimating = false;
|
||||
}
|
||||
|
||||
function delay(ms) { return new Promise(r => setTimeout(r, ms)); }
|
||||
function delay(ms: number) { return new Promise(r => setTimeout(r, ms)); }
|
||||
|
||||
function send(msg) { gameWs?.send(JSON.stringify(msg)); }
|
||||
function send(msg: unknown) { gameWs?.send(JSON.stringify(msg)); }
|
||||
|
||||
function selectHandCard(index) {
|
||||
function selectHandCard(index: number) {
|
||||
if (!isMyTurn || combatAnimating) return;
|
||||
selectedHandIndex = selectedHandIndex === index ? null : index;
|
||||
}
|
||||
|
||||
function clickSlot(slot) {
|
||||
function clickSlot(slot: number) {
|
||||
if (!isMyTurn || combatAnimating || selectedHandIndex === null) return;
|
||||
play('cardPlay');
|
||||
send({ type: 'play_card', hand_index: selectedHandIndex, slot });
|
||||
selectedHandIndex = null;
|
||||
}
|
||||
|
||||
async function sacrifice(slot) {
|
||||
async function sacrifice(slot: number) {
|
||||
if (!isMyTurn || combatAnimating) return;
|
||||
const card = me.board[slot];
|
||||
if (!card) return;
|
||||
play('cardShatter');
|
||||
destroying = new Set([...destroying, card.instance_id]);
|
||||
await delay(600);
|
||||
destroying = new Set([...destroying].filter(id => id !== card.instance_id));
|
||||
@@ -267,7 +318,7 @@
|
||||
send({ type: 'end_turn' });
|
||||
}
|
||||
|
||||
function handleHandCardMouseMove(e, node) {
|
||||
function handleHandCardMouseMove(e: MouseEvent, node: HTMLElement) {
|
||||
const rect = node.getBoundingClientRect();
|
||||
const cy = rect.top + rect.height / 2;
|
||||
const dy = (e.clientY - cy) / (rect.height / 2);
|
||||
@@ -275,7 +326,7 @@
|
||||
node.style.setProperty('--peek-y', `${ty}px`);
|
||||
}
|
||||
|
||||
function handleHandCardMouseLeave(node) {
|
||||
function handleHandCardMouseLeave(node: HTMLElement) {
|
||||
node.style.setProperty('--peek-y', '0px');
|
||||
}
|
||||
|
||||
@@ -337,10 +388,17 @@
|
||||
|
||||
{:else if (phase === 'playing' || (phase === 'ended' && viewingBoard)) && gameState}
|
||||
<div class="game">
|
||||
{#if reconnecting}
|
||||
<div class="reconnecting-banner">Reconnecting...</div>
|
||||
{/if}
|
||||
|
||||
<div class="sidebar left-sidebar">
|
||||
<div class="sidebar-section top-section">
|
||||
<div class="sidebar-name opp-name">{opp.username}</div>
|
||||
{#if opp.user_id === 'ai'}
|
||||
<span class="sidebar-name opp-name">{opp.username}</span>
|
||||
{:else}
|
||||
<a href="/profile/{opp.username}" target="_blank" class="sidebar-name opp-name opp-profile-link">{opp.username}</a>
|
||||
{/if}
|
||||
<DeckTypeBadge deckType={opp.deck_type} />
|
||||
<div class="sidebar-life">♥ {displayedLife[opp.user_id] ?? opp.life}</div>
|
||||
<div class="sidebar-deck">Deck: {opp.deck_size}</div>
|
||||
@@ -389,7 +447,7 @@
|
||||
|
||||
<div class="divider">
|
||||
<span class="turn-indicator" class:my-turn={isMyTurn}>
|
||||
{phase === 'ended' ? 'Game Ended' : isMyTurn ? 'Your turn' : `${opp.username}'s turn`}
|
||||
{#if phase === 'ended'}Game Ended{:else if isMyTurn}Your turn{:else}{opp.username}'s turn{/if}
|
||||
</span>
|
||||
{#if secondsRemaining <= TIMER_WARNING}
|
||||
<span class="turn-timer" class:urgent={secondsRemaining <= 10}>
|
||||
@@ -436,7 +494,7 @@
|
||||
|
||||
<div class="sidebar right-sidebar">
|
||||
{#if phase === 'ended'}
|
||||
<button class="end-turn-btn" onclick={() => viewingBoard = false}>Go Back</button>
|
||||
<button class="end-turn-btn" onclick={() => { viewingBoard = false; history.replaceState({}, '', '/play'); }}>Go Back</button>
|
||||
{:else if isMyTurn && !combatAnimating}
|
||||
<button class="end-turn-btn" onclick={endTurn}>End Turn</button>
|
||||
{/if}
|
||||
@@ -472,7 +530,7 @@
|
||||
<p class="lobby-hint">{gameState.result.reason}</p>
|
||||
<div class="lobby-buttons">
|
||||
<button class="play-btn" onclick={() => viewingBoard = true}>View Board</button>
|
||||
<button class="play-btn" onclick={() => { gameState = null; phase = 'idle'; }}>Go Back</button>
|
||||
<button class="play-btn" onclick={() => { gameState = null; phase = 'idle'; history.replaceState({}, '', '/play'); }}>Go Back</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -506,12 +564,10 @@
|
||||
</main>
|
||||
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;700;900&family=Crimson+Text:ital,wght@0,400;0,600;1,400&display=swap');
|
||||
|
||||
main {
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
overflow: hidden;
|
||||
background: #0d0a04;
|
||||
background: var(--color-bg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
@@ -529,21 +585,21 @@
|
||||
|
||||
.lobby-title {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 32px;
|
||||
font-size: var(--text-3xl);
|
||||
font-weight: 900;
|
||||
color: #f0d080;
|
||||
color: var(--color-gold);
|
||||
margin: 0;
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
|
||||
.lobby-title.win { color: #6aaa6a; }
|
||||
.lobby-title.lose { color: #c85050; }
|
||||
.lobby-title.win { color: var(--color-success); }
|
||||
.lobby-title.lose { color: var(--color-error); }
|
||||
|
||||
.how-to-play-link {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 14px;
|
||||
font-size: var(--text-base);
|
||||
font-style: italic;
|
||||
color: rgba(240, 180, 80, 0.4);
|
||||
color: var(--color-gold-faint);
|
||||
text-decoration: underline;
|
||||
transition: color 0.15s;
|
||||
margin-top: -1rem;
|
||||
@@ -553,24 +609,24 @@
|
||||
|
||||
.lobby-hint {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 16px;
|
||||
font-size: var(--text-md);
|
||||
font-style: italic;
|
||||
color: rgba(240, 180, 80, 0.6);
|
||||
color: var(--color-gold-dim);
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.lobby-hint a { color: #f0d080; }
|
||||
.lobby-hint a { color: var(--color-gold); }
|
||||
|
||||
.final-life {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 13px;
|
||||
font-size: var(--text-base);
|
||||
color: rgba(240, 180, 80, 0.5);
|
||||
}
|
||||
|
||||
.deck-label {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 11px;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
@@ -583,11 +639,11 @@
|
||||
|
||||
select {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 15px;
|
||||
color: #f0d080;
|
||||
background: #1a1008;
|
||||
border: 1.5px solid #6b4c1e;
|
||||
border-radius: 6px;
|
||||
font-size: var(--text-md);
|
||||
color: var(--color-gold);
|
||||
background: var(--color-surface);
|
||||
border: 1.5px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 8px 12px;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
@@ -596,27 +652,28 @@
|
||||
|
||||
.play-btn, .cancel-btn {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 13px;
|
||||
font-size: var(--btn-font-lg);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
padding: 10px 32px;
|
||||
border-radius: 6px;
|
||||
padding: var(--btn-padding-lg);
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.play-btn {
|
||||
background: #c8861a;
|
||||
color: #fff8e0;
|
||||
border: none;
|
||||
background: var(--color-surface-raised);
|
||||
color: var(--color-gold);
|
||||
border: 1.5px solid var(--color-bronze);
|
||||
}
|
||||
|
||||
.play-btn:hover:not(:disabled) { background: #e09820; }
|
||||
.play-btn:hover:not(:disabled) { background: #5a3510; }
|
||||
|
||||
.play-btn:disabled {
|
||||
background: #6b4c1e;
|
||||
color: rgba(255, 248, 224, 0.4);
|
||||
background: var(--color-surface-raised);
|
||||
color: rgba(240, 180, 80, 0.3);
|
||||
border-color: rgba(200, 134, 26, 0.3);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@@ -626,34 +683,35 @@
|
||||
}
|
||||
|
||||
.solo-btn {
|
||||
background: #2a3d20;
|
||||
border: 1px solid #5a8a40;
|
||||
color: #a8d880;
|
||||
background: none;
|
||||
border: 1px solid rgba(107, 76, 30, 0.5);
|
||||
color: rgba(240, 180, 80, 0.7);
|
||||
}
|
||||
|
||||
.solo-btn:hover:not(:disabled) {
|
||||
background: #3a5a2a;
|
||||
border-color: var(--color-bronze);
|
||||
color: var(--color-gold);
|
||||
}
|
||||
|
||||
.solo-btn:disabled {
|
||||
background: #1a2510;
|
||||
color: rgba(168, 216, 128, 0.3);
|
||||
background: none;
|
||||
color: rgba(240, 180, 80, 0.25);
|
||||
cursor: not-allowed;
|
||||
border-color: #3a5a2a;
|
||||
border-color: rgba(107, 76, 30, 0.2);
|
||||
}
|
||||
|
||||
.cancel-btn {
|
||||
background: none;
|
||||
color: rgba(240, 180, 80, 0.6);
|
||||
border: 1px solid rgba(107, 76, 30, 0.4);
|
||||
color: var(--color-gold-dim);
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
}
|
||||
|
||||
.cancel-btn:hover { border-color: #c8861a; color: #f0d080; }
|
||||
.cancel-btn:hover { border-color: var(--color-bronze); color: var(--color-gold); }
|
||||
|
||||
.error {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 14px;
|
||||
color: #c85050;
|
||||
font-size: var(--text-base);
|
||||
color: var(--color-error);
|
||||
margin: 0;
|
||||
height: 1.4em;
|
||||
margin-top: -1rem;
|
||||
@@ -664,8 +722,8 @@
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid rgba(200, 134, 26, 0.2);
|
||||
border-top-color: #c8861a;
|
||||
border-radius: 50%;
|
||||
border-top-color: var(--color-bronze);
|
||||
border-radius: var(--radius-full);
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@@ -673,6 +731,7 @@
|
||||
|
||||
/* ── Game layout ── */
|
||||
.game {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
@@ -717,7 +776,7 @@
|
||||
|
||||
.sidebar-name {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 14px;
|
||||
font-size: var(--text-base);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
@@ -727,18 +786,20 @@
|
||||
}
|
||||
|
||||
.opp-name { color: rgba(200, 80, 80, 0.8); }
|
||||
.you-name { color: #c8861a; }
|
||||
.opp-profile-link { text-decoration: none; transition: color 0.15s; }
|
||||
.opp-profile-link:hover { color: #e05050; }
|
||||
.you-name { color: var(--color-bronze); }
|
||||
|
||||
.sidebar-life {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 30px;
|
||||
font-size: var(--text-2xl);
|
||||
font-weight: 700;
|
||||
color: #f0d080;
|
||||
color: var(--color-gold);
|
||||
}
|
||||
|
||||
.sidebar-deck, .sidebar-hand {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 16px;
|
||||
font-size: var(--text-md);
|
||||
font-style: italic;
|
||||
color: rgba(240, 180, 80, 0.45);
|
||||
}
|
||||
@@ -752,14 +813,14 @@
|
||||
.cost-bubble-display {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
background: #6ea0ec;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--color-energy);
|
||||
border: 2.5px solid #000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #08152c;
|
||||
font-size: 13px;
|
||||
font-size: var(--text-base);
|
||||
font-weight: 700;
|
||||
font-family: 'Cinzel', serif;
|
||||
flex-shrink: 0;
|
||||
@@ -767,9 +828,9 @@
|
||||
|
||||
.energy-count {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 18px;
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 700;
|
||||
color: #f0d080;
|
||||
color: var(--color-gold);
|
||||
}
|
||||
|
||||
.sacrifice-mode-btn {
|
||||
@@ -777,7 +838,7 @@
|
||||
color: rgba(107, 76, 30, 1);
|
||||
border: 2px solid rgba(107, 76, 30, 1);
|
||||
border-radius: 15px;
|
||||
font-size: 16px;
|
||||
font-size: var(--text-md);
|
||||
cursor: pointer;
|
||||
padding: 6px 5.5px;
|
||||
line-height: 1;
|
||||
@@ -786,32 +847,46 @@
|
||||
}
|
||||
|
||||
.sacrifice-mode-btn:hover {
|
||||
border-color: #c8861a;
|
||||
border-color: var(--color-bronze);
|
||||
background: rgba(200, 134, 26, 0.1);
|
||||
}
|
||||
|
||||
.sacrifice-mode-btn.active {
|
||||
background: rgba(180, 40, 40, 0.3);
|
||||
border-color: #c84040;
|
||||
border-color: var(--color-error);
|
||||
}
|
||||
|
||||
.end-turn-btn {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 13px;
|
||||
font-size: var(--btn-font-lg);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
background: #c8861a;
|
||||
background: var(--color-bronze);
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: #fff8e0;
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-btn-text);
|
||||
padding: 10px 8px;
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
margin-top: auto;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
.end-turn-btn:hover { background: #e09820; }
|
||||
.end-turn-btn:hover { background: var(--color-bronze-hover); }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.hand {
|
||||
height: calc(400px * 0.55 + 1rem); /* ~221px instead of ~370px on mobile */
|
||||
}
|
||||
|
||||
.end-turn-btn {
|
||||
min-height: 44px;
|
||||
font-size: var(--text-sm);
|
||||
padding: 8px 6px;
|
||||
}
|
||||
}
|
||||
|
||||
.sacrifice-overlay {
|
||||
position: absolute;
|
||||
@@ -821,9 +896,9 @@
|
||||
justify-content: center;
|
||||
font-size: 48px;
|
||||
background: rgba(180, 40, 40, 0.35);
|
||||
border-radius: 10px;
|
||||
border-radius: var(--radius-lg);
|
||||
cursor: pointer;
|
||||
z-index: 10;
|
||||
z-index: var(--z-card);
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
@@ -937,23 +1012,23 @@
|
||||
width: calc(300px * 0.55);
|
||||
height: calc(400px * 0.55);
|
||||
border: 1.5px dashed rgba(107, 76, 30, 0.25);
|
||||
border-radius: 10px;
|
||||
border-radius: var(--radius-lg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
z-index: 1;
|
||||
z-index: var(--z-base);
|
||||
}
|
||||
|
||||
.empty-slot.highlight {
|
||||
border-color: #c8861a;
|
||||
border-color: var(--color-bronze);
|
||||
background: rgba(200, 134, 26, 0.08);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.slot-hint {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 9px;
|
||||
font-size: var(--text-xs);
|
||||
color: rgba(200, 134, 26, 0.7);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
@@ -1010,14 +1085,14 @@
|
||||
|
||||
.turn-timer {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 11px;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 700;
|
||||
color: rgba(240, 180, 80, 0.6);
|
||||
color: var(--color-gold-dim);
|
||||
transition: color 0.3s;
|
||||
}
|
||||
|
||||
.turn-timer.urgent {
|
||||
color: #c85050;
|
||||
color: var(--color-error);
|
||||
animation: pulse 0.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@@ -1028,7 +1103,7 @@
|
||||
|
||||
.turn-indicator {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 13px;
|
||||
font-size: var(--text-base);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
@@ -1036,7 +1111,7 @@
|
||||
transition: color 0.3s;
|
||||
}
|
||||
|
||||
.turn-indicator.my-turn { color: #c8861a; }
|
||||
.turn-indicator.my-turn { color: var(--color-bronze); }
|
||||
|
||||
/* ── Hand ── */
|
||||
.hand {
|
||||
@@ -1048,7 +1123,7 @@
|
||||
padding: 0.5rem;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
border-top: 1px solid rgba(107, 76, 30, 0.3);
|
||||
border-top: 1px solid var(--color-border-dim);
|
||||
background: rgba(0,0,0,0.3);
|
||||
height: calc(400px * 0.9 + 1rem);
|
||||
}
|
||||
@@ -1081,19 +1156,19 @@
|
||||
.hand-card:hover:not(:disabled) :global(.card) {
|
||||
transform: scale(1.1) translate(-50px, calc(var(--peek-y) - 80px)) !important;
|
||||
transform-origin: top left !important;
|
||||
z-index: 50 !important;
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.hand-card.selected {
|
||||
/* transform: translateY(-16px); */
|
||||
filter: drop-shadow(0 0 8px rgba(200, 134, 26, 0.9));
|
||||
z-index: 25 !important;
|
||||
z-index: 25;
|
||||
}
|
||||
|
||||
.hand-card.selected :global(.card) {
|
||||
/* transform: scale(1.) translate(-30px, calc(var(--peek-y) - 80px)) !important; */
|
||||
/* transform-origin: top left !important; */
|
||||
z-index: 50 !important;
|
||||
z-index: 50;
|
||||
filter: drop-shadow(0 0 8px rgba(200, 134, 26, 0.9));
|
||||
}
|
||||
|
||||
@@ -1108,13 +1183,13 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 200;
|
||||
z-index: var(--z-modal);
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: #110d04;
|
||||
border: 1.5px solid #6b4c1e;
|
||||
border-radius: 10px;
|
||||
border: 1.5px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 2rem 2.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -1125,9 +1200,9 @@
|
||||
|
||||
.modal-title {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 20px;
|
||||
font-size: var(--text-xl);
|
||||
font-weight: 700;
|
||||
color: #f0d080;
|
||||
color: var(--color-gold);
|
||||
margin: 0;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
@@ -1143,20 +1218,20 @@
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 48px;
|
||||
font-weight: 900;
|
||||
color: #f0d080;
|
||||
color: var(--color-gold);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.difficulty-label {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 16px;
|
||||
font-size: var(--text-md);
|
||||
font-style: italic;
|
||||
color: rgba(240, 180, 80, 0.6);
|
||||
color: var(--color-gold-dim);
|
||||
}
|
||||
|
||||
.difficulty-slider {
|
||||
width: 100%;
|
||||
accent-color: #c8861a;
|
||||
accent-color: var(--color-bronze);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -1165,8 +1240,8 @@
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 10px;
|
||||
color: rgba(240, 180, 80, 0.4);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-gold-faint);
|
||||
margin-top: -0.75rem;
|
||||
}
|
||||
|
||||
@@ -1185,10 +1260,34 @@
|
||||
background: rgba(180, 40, 40, 0.9);
|
||||
color: #fff;
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 12px;
|
||||
font-size: var(--text-sm);
|
||||
padding: 8px 20px;
|
||||
border-radius: 6px;
|
||||
border-radius: var(--radius-md);
|
||||
pointer-events: none;
|
||||
z-index: 100;
|
||||
z-index: var(--z-toast);
|
||||
}
|
||||
|
||||
.reconnecting-banner {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background: rgba(13, 10, 4, 0.85);
|
||||
border: 1px solid rgba(200, 134, 26, 0.5);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 10px 24px;
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: var(--text-base);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--color-gold-muted);
|
||||
z-index: 50;
|
||||
pointer-events: none;
|
||||
animation: fade-pulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes fade-pulse {
|
||||
0%, 100% { opacity: 0.5; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
</style>
|
||||
@@ -1,11 +1,25 @@
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import { API_URL, WS_URL } from '$lib/api.js';
|
||||
import { apiFetch } from '$lib/api.js';
|
||||
import { goto } from '$app/navigation';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
let profile = $state(null);
|
||||
let profile: any = $state(null);
|
||||
let loading = $state(true);
|
||||
let wishlistText = $state('');
|
||||
let wishlistSaving = $state(false);
|
||||
let wishlistSaved = $state(false);
|
||||
let wishlistError = $state('');
|
||||
|
||||
let friends: any[] = $state([]);
|
||||
let proposals: any[] = $state([]);
|
||||
let challenges: any[] = $state([]);
|
||||
|
||||
let confirmingRemove: Set<string> = $state(new Set());
|
||||
let confirmingWithdraw: Set<string> = $state(new Set());
|
||||
|
||||
// Increments every second — passed to formatChallengeExpiry to force re-evaluation
|
||||
let tick = $state(0);
|
||||
|
||||
const token = () => localStorage.getItem('token');
|
||||
|
||||
@@ -14,9 +28,81 @@
|
||||
const res = await apiFetch(`${API_URL}/profile`);
|
||||
if (res.status === 401) { goto('/auth'); return; }
|
||||
profile = await res.json();
|
||||
wishlistText = profile.trade_wishlist || '';
|
||||
|
||||
const friendsRes = await apiFetch(`${API_URL}/friends`);
|
||||
if (friendsRes.ok) friends = await friendsRes.json();
|
||||
|
||||
const proposalsRes = await apiFetch(`${API_URL}/trade-proposals`);
|
||||
if (proposalsRes.ok) proposals = await proposalsRes.json();
|
||||
|
||||
const challengesRes = await apiFetch(`${API_URL}/challenges`);
|
||||
if (challengesRes.ok) challenges = await challengesRes.json();
|
||||
|
||||
loading = false;
|
||||
|
||||
const tickInterval = setInterval(() => { tick++; }, 1000);
|
||||
const pollInterval = setInterval(async () => {
|
||||
const [pRes, cRes] = await Promise.all([
|
||||
apiFetch(`${API_URL}/trade-proposals`),
|
||||
apiFetch(`${API_URL}/challenges`),
|
||||
]);
|
||||
if (pRes.ok) proposals = await pRes.json();
|
||||
if (cRes.ok) challenges = await cRes.json();
|
||||
}, 30_000);
|
||||
|
||||
return () => {
|
||||
clearInterval(tickInterval);
|
||||
clearInterval(pollInterval);
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
function formatExpiry(isoString: string) {
|
||||
const d = new Date(isoString);
|
||||
const diff = d.getTime() - Date.now();
|
||||
if (diff < 0) return 'expired';
|
||||
const hrs = Math.floor(diff / 3600000);
|
||||
if (hrs < 1) return 'in < 1h';
|
||||
if (hrs < 24) return `in ${hrs}h`;
|
||||
return `in ${Math.floor(hrs / 24)}d`;
|
||||
}
|
||||
|
||||
async function saveWishlist() {
|
||||
wishlistSaving = true;
|
||||
wishlistError = '';
|
||||
wishlistSaved = false;
|
||||
const res = await apiFetch(`${API_URL}/profile`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ trade_wishlist: wishlistText }),
|
||||
});
|
||||
wishlistSaving = false;
|
||||
if (res.ok) {
|
||||
wishlistSaved = true;
|
||||
setTimeout(() => { wishlistSaved = false; }, 2500);
|
||||
} else {
|
||||
wishlistError = 'Failed to save.';
|
||||
}
|
||||
}
|
||||
|
||||
function formatChallengeExpiry(isoString: string, _tick?: number) {
|
||||
const secs = Math.max(0, Math.floor((new Date(isoString).getTime() - Date.now()) / 1000));
|
||||
if (secs <= 0) return 'expired';
|
||||
if (secs < 60) return `${secs}s remaining`;
|
||||
return `${Math.floor(secs / 60)}m ${secs % 60}s remaining`;
|
||||
}
|
||||
|
||||
async function withdrawChallenge(challengeId: string) {
|
||||
const res = await apiFetch(`${API_URL}/challenges/${challengeId}/decline`, { method: 'POST' });
|
||||
if (res.ok) challenges = challenges.filter((c: any) => c.id !== challengeId);
|
||||
}
|
||||
|
||||
async function removeFriend(friendshipId: string) {
|
||||
await apiFetch(`${API_URL}/friendships/${friendshipId}`, { method: 'DELETE' });
|
||||
friends = friends.filter((f: any) => f.friendship_id !== friendshipId);
|
||||
}
|
||||
|
||||
function logout() {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('refresh_token');
|
||||
@@ -69,7 +155,7 @@
|
||||
<span class="shards-icon">◈</span>
|
||||
<span class="shards-value">{profile.shards}</span>
|
||||
<span class="shards-label">Shards</span>
|
||||
<a href="/shards" class="shards-link">shatter cards</a>
|
||||
<a href="/shatter" class="shards-link">shatter cards</a>
|
||||
</div>
|
||||
|
||||
<div class="section-divider"></div>
|
||||
@@ -98,6 +184,28 @@
|
||||
|
||||
<div class="section-divider"></div>
|
||||
|
||||
<div class="wishlist-group">
|
||||
<h2 class="section-title">Trade Wishlist</h2>
|
||||
<div class="wishlist-section">
|
||||
<p class="wishlist-hint">Cards or types you're looking to trade for. Visible on your public profile.</p>
|
||||
<textarea
|
||||
class="wishlist-textarea"
|
||||
bind:value={wishlistText}
|
||||
placeholder="e.g. Looking for legendary locations, rare scientists..."
|
||||
rows="3"
|
||||
></textarea>
|
||||
<div class="wishlist-actions">
|
||||
<button class="save-btn" onclick={saveWishlist} disabled={wishlistSaving}>
|
||||
{wishlistSaving ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
{#if wishlistSaved}<span class="wishlist-ok">Saved ✓</span>{/if}
|
||||
<p class="wishlist-error" style="min-height: 1.2em">{wishlistError}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-divider"></div>
|
||||
|
||||
<h2 class="section-title">Highlights</h2>
|
||||
<div class="highlights">
|
||||
<div class="highlight-card">
|
||||
@@ -129,17 +237,161 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-divider"></div>
|
||||
|
||||
<h2 class="section-title">Friends</h2>
|
||||
{#if friends.length === 0}
|
||||
<p class="no-friends">No friends yet.</p>
|
||||
{:else}
|
||||
<ul class="friends-list">
|
||||
{#each friends as f (f.friendship_id)}
|
||||
<li class="friend-item">
|
||||
<a href="/profile/{f.username}" class="friend-name">{f.username}</a>
|
||||
{#if confirmingRemove.has(f.friendship_id)}
|
||||
<span class="confirm-label">Remove friend?</span>
|
||||
<button class="confirm-yes-btn" onclick={() => removeFriend(f.friendship_id)}>Confirm</button>
|
||||
<button class="confirm-no-btn" onclick={() => { confirmingRemove.delete(f.friendship_id); confirmingRemove = confirmingRemove; }}>Cancel</button>
|
||||
{:else}
|
||||
<button class="unfriend-btn" onclick={() => { confirmingRemove.add(f.friendship_id); confirmingRemove = confirmingRemove; }}>Remove</button>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
|
||||
<div class="section-divider"></div>
|
||||
|
||||
<h2 class="section-title">Trade Proposals</h2>
|
||||
|
||||
{#if proposals.length === 0}
|
||||
<p class="no-friends">No trade proposals.</p>
|
||||
{:else}
|
||||
{@const incoming = proposals.filter((p: any) => p.direction === 'incoming' && p.status === 'pending')}
|
||||
{@const outgoing = proposals.filter((p: any) => p.direction === 'outgoing' && p.status === 'pending')}
|
||||
{@const resolved = proposals.filter((p: any) => p.status !== 'pending')}
|
||||
|
||||
{#if incoming.length > 0}
|
||||
<p class="proposal-subhead">Incoming</p>
|
||||
{#each incoming as p (p.id)}
|
||||
<div class="proposal-card">
|
||||
<div class="proposal-meta">
|
||||
<a href="/profile/{p.proposer_username}" target="_blank" class="friend-name">{p.proposer_username}</a>
|
||||
<span class="proposal-status pending">Pending</span>
|
||||
<span class="proposal-expires">{formatExpiry(p.expires_at)}</span>
|
||||
</div>
|
||||
<p class="proposal-desc">
|
||||
{#if p.requested_cards.length > 0}Wants: <strong>{p.requested_cards.map((c: any) => c.name).join(', ')}</strong><br/>{/if}
|
||||
{#if p.offered_cards.length > 0}Offering: {p.offered_cards.map((c: any) => c.name).join(', ')}{/if}
|
||||
</p>
|
||||
<a href="/trade/proposal/{p.id}" class="see-proposal-btn">See Proposal</a>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
{#if outgoing.length > 0}
|
||||
<p class="proposal-subhead">Outgoing</p>
|
||||
{#each outgoing as p (p.id)}
|
||||
<div class="proposal-card">
|
||||
<div class="proposal-meta">
|
||||
<span class="proposal-to">To: <a href="/profile/{p.recipient_username}" target="_blank" class="friend-name">{p.recipient_username}</a></span>
|
||||
<span class="proposal-status pending">Pending</span>
|
||||
<span class="proposal-expires">{formatExpiry(p.expires_at)}</span>
|
||||
</div>
|
||||
<p class="proposal-desc">
|
||||
{#if p.requested_cards.length > 0}Requesting: <strong>{p.requested_cards.map((c: any) => c.name).join(', ')}</strong><br/>{/if}
|
||||
{#if p.offered_cards.length > 0}Offering: {p.offered_cards.map((c: any) => c.name).join(', ')}{/if}
|
||||
</p>
|
||||
<a href="/trade/proposal/{p.id}" class="see-proposal-btn">See Proposal</a>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
{#if resolved.length > 0}
|
||||
<p class="proposal-subhead">History</p>
|
||||
{#each resolved as p (p.id)}
|
||||
<div class="proposal-card resolved">
|
||||
<div class="proposal-meta">
|
||||
<span class="proposal-to">{p.direction === 'incoming' ? `From: ${p.proposer_username}` : `To: ${p.recipient_username}`}</span>
|
||||
<span class="proposal-status {p.status}">{p.status}</span>
|
||||
</div>
|
||||
<p class="proposal-desc">{p.requested_cards.length} requested · {p.offered_cards.length} offered</p>
|
||||
<a href="/trade/proposal/{p.id}" class="see-proposal-btn">See Proposal</a>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
|
||||
{/if}
|
||||
|
||||
{#if challenges.length > 0}
|
||||
<div class="section-divider"></div>
|
||||
|
||||
<h2 class="section-title">Game Challenges</h2>
|
||||
|
||||
{@const pendingOut = challenges.filter((c: any) => c.direction === 'outgoing' && c.status === 'pending')}
|
||||
{@const pendingIn = challenges.filter((c: any) => c.direction === 'incoming' && c.status === 'pending')}
|
||||
{@const resolvedC = challenges.filter((c: any) => c.status !== 'pending')}
|
||||
|
||||
{#if pendingOut.length > 0}
|
||||
<p class="proposal-subhead">Sent</p>
|
||||
{#each pendingOut as c (c.id)}
|
||||
<div class="proposal-card">
|
||||
<div class="proposal-meta">
|
||||
<span class="proposal-to">To: <a href="/profile/{c.challenged_username}" target="_blank" class="friend-name">{c.challenged_username}</a></span>
|
||||
<span class="proposal-status pending">Awaiting response</span>
|
||||
<span class="proposal-expires">{formatChallengeExpiry(c.expires_at, tick)}</span>
|
||||
</div>
|
||||
<p class="proposal-desc">Deck: <strong>{c.deck_name}</strong></p>
|
||||
{#if confirmingWithdraw.has(c.id)}
|
||||
<span class="confirm-label">Withdraw challenge?</span>
|
||||
<button class="confirm-yes-btn" onclick={() => withdrawChallenge(c.id)}>Confirm</button>
|
||||
<button class="confirm-no-btn" onclick={() => { confirmingWithdraw.delete(c.id); confirmingWithdraw = confirmingWithdraw; }}>Cancel</button>
|
||||
{:else}
|
||||
<button class="withdraw-btn" onclick={() => { confirmingWithdraw.add(c.id); confirmingWithdraw = confirmingWithdraw; }}>Withdraw</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
{#if pendingIn.length > 0}
|
||||
<p class="proposal-subhead">Incoming</p>
|
||||
{#each pendingIn as c (c.id)}
|
||||
<div class="proposal-card">
|
||||
<div class="proposal-meta">
|
||||
<a href="/profile/{c.challenger_username}" target="_blank" class="friend-name">{c.challenger_username}</a>
|
||||
<span class="proposal-status pending">Pending</span>
|
||||
<span class="proposal-expires">{formatChallengeExpiry(c.expires_at, tick)}</span>
|
||||
</div>
|
||||
<p class="proposal-desc">Their deck: <strong>{c.deck_name}</strong></p>
|
||||
<p class="proposal-desc">Check your notification bell to accept.</p>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
{#if resolvedC.length > 0}
|
||||
<p class="proposal-subhead">History</p>
|
||||
{#each resolvedC as c (c.id)}
|
||||
<div class="proposal-card resolved">
|
||||
<div class="proposal-meta">
|
||||
<span class="proposal-to">
|
||||
{c.direction === 'outgoing' ? `To: ${c.challenged_username}` : `From: ${c.challenger_username}`}
|
||||
</span>
|
||||
<span class="proposal-status {c.status}">{c.status}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
</div>
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;700&family=Crimson+Text:ital,wght@0,400;0,600;1,400&display=swap');
|
||||
|
||||
main {
|
||||
height: 100vh;
|
||||
overflow-y: auto;
|
||||
background: #0d0a04;
|
||||
background: var(--color-bg);
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
@@ -149,6 +401,7 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
padding-bottom: 5rem;
|
||||
}
|
||||
|
||||
.profile-header {
|
||||
@@ -160,16 +413,16 @@
|
||||
.avatar {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 50%;
|
||||
background: #3d2507;
|
||||
border: 2px solid #c8861a;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--color-surface-raised);
|
||||
border: 2px solid var(--color-bronze);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 28px;
|
||||
font-size: var(--text-2xl);
|
||||
font-weight: 700;
|
||||
color: #f0d080;
|
||||
color: var(--color-gold);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -182,22 +435,22 @@
|
||||
|
||||
.username {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 24px;
|
||||
font-size: var(--text-xl);
|
||||
font-weight: 700;
|
||||
color: #f0d080;
|
||||
color: var(--color-gold);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.email {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 15px;
|
||||
font-size: var(--text-md);
|
||||
color: rgba(240, 180, 80, 0.5);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.unverified-badge {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 9px;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
@@ -211,7 +464,7 @@
|
||||
|
||||
.resend-btn {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 13px;
|
||||
font-size: var(--text-base);
|
||||
font-style: italic;
|
||||
color: rgba(240, 180, 80, 0.5);
|
||||
background: none;
|
||||
@@ -228,7 +481,7 @@
|
||||
|
||||
.joined {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 13px;
|
||||
font-size: var(--text-base);
|
||||
font-style: italic;
|
||||
color: rgba(240, 180, 80, 0.35);
|
||||
margin: 0;
|
||||
@@ -236,29 +489,29 @@
|
||||
|
||||
.logout-btn {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 11px;
|
||||
font-size: var(--btn-font-md);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
background: none;
|
||||
border: 1px solid rgba(180, 60, 60, 0.4);
|
||||
border-radius: 4px;
|
||||
border-radius: var(--radius-sm);
|
||||
color: rgba(200, 80, 80, 0.7);
|
||||
padding: 8px 16px;
|
||||
padding: var(--btn-padding-md);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.logout-btn:hover {
|
||||
border-color: #c84040;
|
||||
border-color: var(--color-error);
|
||||
color: #e05050;
|
||||
background: rgba(180, 40, 40, 0.1);
|
||||
}
|
||||
|
||||
.reset-link {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 14px;
|
||||
font-size: var(--text-base);
|
||||
font-style: italic;
|
||||
color: rgba(240, 180, 80, 0.4);
|
||||
text-decoration: underline;
|
||||
@@ -276,38 +529,39 @@
|
||||
|
||||
.shards-link {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 10px;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(126, 207, 207, 0.6);
|
||||
border: 1px solid rgba(126, 207, 207, 0.3);
|
||||
border-radius: 4px;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 3px 8px;
|
||||
text-decoration: none;
|
||||
margin-top: 4px;
|
||||
margin-left: 0.5rem;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
}
|
||||
.shards-link:hover { color: #7ecfcf; border-color: rgba(126, 207, 207, 0.7); }
|
||||
.shards-link:hover { color: var(--color-cyan); border-color: rgba(126, 207, 207, 0.7); }
|
||||
|
||||
.shards-icon {
|
||||
font-size: 22px;
|
||||
color: #7ecfcf;
|
||||
font-size: var(--text-xl);
|
||||
color: var(--color-cyan);
|
||||
position: relative;
|
||||
top: -0.1em;
|
||||
animation: shard-pulse 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.shards-value {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 28px;
|
||||
font-size: var(--text-2xl);
|
||||
font-weight: 700;
|
||||
color: #7ecfcf;
|
||||
color: var(--color-cyan);
|
||||
}
|
||||
|
||||
.shards-label {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 11px;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
@@ -317,16 +571,16 @@
|
||||
|
||||
.section-divider {
|
||||
height: 1px;
|
||||
background: rgba(107, 76, 30, 0.3);
|
||||
background: var(--color-border-dim);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 13px;
|
||||
font-size: var(--text-base);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(240, 180, 80, 0.4);
|
||||
color: var(--color-gold-faint);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@@ -337,9 +591,9 @@
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: #1a1008;
|
||||
border: 1px solid rgba(107, 76, 30, 0.3);
|
||||
border-radius: 8px;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border-dim);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -348,24 +602,24 @@
|
||||
|
||||
.stat-label {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 9px;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(240, 180, 80, 0.4);
|
||||
color: var(--color-gold-faint);
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 28px;
|
||||
font-size: var(--text-2xl);
|
||||
font-weight: 700;
|
||||
color: #f0d080;
|
||||
color: var(--color-gold);
|
||||
}
|
||||
|
||||
.wins { color: #6aaa6a; }
|
||||
.losses { color: #c85050; }
|
||||
.good-wr { color: #6aaa6a; }
|
||||
.bad-wr { color: #c85050; }
|
||||
.wins { color: var(--color-success); }
|
||||
.losses { color: var(--color-error); }
|
||||
.good-wr { color: var(--color-success); }
|
||||
.bad-wr { color: var(--color-error); }
|
||||
|
||||
.highlights {
|
||||
display: grid;
|
||||
@@ -374,9 +628,9 @@
|
||||
}
|
||||
|
||||
.highlight-card {
|
||||
background: #1a1008;
|
||||
border: 1px solid rgba(107, 76, 30, 0.3);
|
||||
border-radius: 8px;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border-dim);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -385,23 +639,23 @@
|
||||
|
||||
.highlight-label {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 9px;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(240, 180, 80, 0.4);
|
||||
color: var(--color-gold-faint);
|
||||
}
|
||||
|
||||
.highlight-value {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 16px;
|
||||
font-size: var(--text-md);
|
||||
font-weight: 700;
|
||||
color: #f0d080;
|
||||
color: var(--color-gold);
|
||||
}
|
||||
|
||||
.highlight-sub {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 13px;
|
||||
font-size: var(--text-base);
|
||||
font-style: italic;
|
||||
color: rgba(240, 180, 80, 0.45);
|
||||
}
|
||||
@@ -418,8 +672,8 @@
|
||||
height: 48px;
|
||||
object-fit: cover;
|
||||
object-position: top;
|
||||
border-radius: 4px;
|
||||
border: 1px solid rgba(107, 76, 30, 0.4);
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -429,22 +683,303 @@
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.wishlist-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.wishlist-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.wishlist-hint {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: var(--text-base);
|
||||
font-style: italic;
|
||||
color: rgba(240, 180, 80, 0.35);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.wishlist-textarea {
|
||||
width: 100%;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-gold);
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: var(--text-md);
|
||||
padding: 0.6rem 0.75rem;
|
||||
resize: vertical;
|
||||
box-sizing: border-box;
|
||||
transition: border-color 0.15s;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.wishlist-textarea:focus { border-color: var(--color-bronze); }
|
||||
.wishlist-textarea::placeholder { color: rgba(240, 180, 80, 0.25); }
|
||||
|
||||
.wishlist-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.save-btn {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: var(--btn-font-md);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
background: var(--color-surface-raised);
|
||||
border: 1px solid var(--color-bronze);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-btn-text);
|
||||
padding: var(--btn-padding-md);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.save-btn:hover:not(:disabled) { background: #4d3010; }
|
||||
.save-btn:disabled { opacity: 0.5; cursor: default; }
|
||||
|
||||
.wishlist-ok {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: var(--text-base);
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.wishlist-error {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: var(--text-base);
|
||||
color: var(--color-error);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.no-friends {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: var(--text-base);
|
||||
font-style: italic;
|
||||
color: rgba(240, 180, 80, 0.25);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.friends-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.friend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border-dim);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.friend-name {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: var(--text-base);
|
||||
font-weight: 700;
|
||||
color: var(--color-gold);
|
||||
text-decoration: none;
|
||||
flex: 1;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.friend-name:hover { color: var(--color-bronze); }
|
||||
|
||||
.unfriend-btn {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: var(--btn-font-md);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
background: rgba(180, 40, 40, 0.8);
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
color: #fff;
|
||||
padding: var(--btn-padding-md);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.unfriend-btn:hover { background: rgba(210, 50, 50, 0.9); }
|
||||
|
||||
.no-data {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 14px;
|
||||
font-size: var(--text-base);
|
||||
font-style: italic;
|
||||
color: rgba(240, 180, 80, 0.25);
|
||||
}
|
||||
|
||||
/* ── Trade Proposals ── */
|
||||
.proposal-subhead {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(240, 180, 80, 0.3);
|
||||
margin: 0.5rem 0 0;
|
||||
}
|
||||
|
||||
.proposal-card {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border-dim);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 0.85rem 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.proposal-card.resolved { opacity: 0.5; }
|
||||
|
||||
.proposal-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.proposal-to {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: var(--text-base);
|
||||
color: rgba(240, 180, 80, 0.5);
|
||||
}
|
||||
|
||||
.proposal-status {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
border-radius: 3px;
|
||||
padding: 1px 5px;
|
||||
border: 1px solid;
|
||||
}
|
||||
|
||||
.proposal-status.pending { color: var(--color-bronze); border-color: rgba(200, 134, 26, 0.4); }
|
||||
.proposal-status.accepted { color: var(--color-success); border-color: rgba(106, 170, 106, 0.4); }
|
||||
.proposal-status.declined { color: var(--color-error); border-color: rgba(200, 64, 64, 0.4); }
|
||||
.proposal-status.expired { color: rgba(240, 180, 80, 0.3); border-color: var(--color-border-dim); }
|
||||
.proposal-status.withdrawn { color: rgba(240, 180, 80, 0.3); border-color: var(--color-border-dim); }
|
||||
|
||||
.proposal-expires {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: var(--text-sm);
|
||||
font-style: italic;
|
||||
color: rgba(240, 180, 80, 0.3);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.proposal-desc {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: var(--text-base);
|
||||
color: rgba(240, 180, 80, 0.65);
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.proposal-desc strong { color: var(--color-gold); font-style: normal; font-weight: 600; }
|
||||
|
||||
.see-proposal-btn {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: var(--btn-font-md);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.07em;
|
||||
text-transform: uppercase;
|
||||
background: var(--color-surface-raised);
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-gold);
|
||||
padding: var(--btn-padding-md);
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
transition: all 0.15s;
|
||||
margin-top: 0.25rem;
|
||||
align-self: flex-start;
|
||||
}
|
||||
.see-proposal-btn:hover { border-color: var(--color-bronze); background: #4d3010; }
|
||||
|
||||
.withdraw-btn {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: var(--btn-font-sm);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
background: none;
|
||||
border: 1px solid rgba(180, 60, 60, 0.4);
|
||||
border-radius: var(--radius-sm);
|
||||
color: rgba(200, 80, 80, 0.6);
|
||||
padding: var(--btn-padding-sm);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.withdraw-btn:hover { border-color: var(--color-error); color: #e05050; }
|
||||
|
||||
.status {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 16px;
|
||||
font-size: var(--text-md);
|
||||
font-style: italic;
|
||||
color: rgba(240, 180, 80, 0.5);
|
||||
text-align: center;
|
||||
margin-top: 4rem;
|
||||
}
|
||||
|
||||
.confirm-label {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: var(--text-base);
|
||||
font-style: italic;
|
||||
color: rgba(200, 80, 80, 0.8);
|
||||
}
|
||||
|
||||
.confirm-yes-btn {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: var(--btn-font-sm);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
background: rgba(180, 40, 40, 0.8);
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
color: #fff;
|
||||
padding: var(--btn-padding-sm);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.confirm-yes-btn:hover { background: rgba(210, 50, 50, 0.9); }
|
||||
|
||||
.confirm-no-btn {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: var(--btn-font-sm);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
background: none;
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-gold-faint);
|
||||
padding: var(--btn-padding-sm);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.confirm-no-btn:hover { border-color: rgba(107, 76, 30, 0.7); color: rgba(240, 180, 80, 0.7); }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.stats-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
.highlights { grid-template-columns: 1fr; }
|
||||
|
||||
@@ -0,0 +1,832 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { onMount } from 'svelte';
|
||||
import { get } from 'svelte/store';
|
||||
import { API_URL, apiFetch } from '$lib/api.js';
|
||||
import Card from '$lib/Card.svelte';
|
||||
|
||||
// Cards shown when collapsed (1 visual row at scale 0.62)
|
||||
const ROW_SIZE = 4;
|
||||
|
||||
function formatLastActive(iso: string | null): string {
|
||||
if (!iso) return '';
|
||||
const diff = Math.floor((Date.now() - new Date(iso + 'Z').getTime()) / 1000);
|
||||
if (diff < 60) return 'Active just now';
|
||||
if (diff < 3600) return `Active ${Math.floor(diff / 60)}m ago`;
|
||||
if (diff < 86400) return `Active ${Math.floor(diff / 3600)}h ago`;
|
||||
const days = Math.floor(diff / 86400);
|
||||
if (days < 30) return `Active ${days}d ago`;
|
||||
if (days < 365) return `Active ${Math.floor(days / 30)}mo ago`;
|
||||
return `Active ${Math.floor(days / 365)}y ago`;
|
||||
}
|
||||
|
||||
let profile: any = $state(null);
|
||||
let loading = $state(true);
|
||||
let notFound = $state(false);
|
||||
let favExpanded = $state(false);
|
||||
let wttExpanded = $state(false);
|
||||
|
||||
// 'idle' | 'pending' | 'pending_received' | 'friends' — friendship status with this user
|
||||
let friendStatus: 'idle' | 'pending' | 'pending_received' | 'friends' = $state('idle');
|
||||
let friendshipId: string | null = $state(null);
|
||||
let isLoggedIn = $state(false);
|
||||
let sendingFriendRequest = $state(false);
|
||||
|
||||
// Challenge state
|
||||
let showChallengeModal = $state(false);
|
||||
let challengeDecks: any[] = $state([]);
|
||||
let selectedDeckId = $state('');
|
||||
let challengeStatus: 'idle' | 'sending' | 'sent' | 'error' = $state('idle');
|
||||
let challengeError = $state('');
|
||||
|
||||
let favSectionEl: HTMLElement | null = $state(null);
|
||||
let wttSectionEl: HTMLElement | null = $state(null);
|
||||
|
||||
const visibleFav = $derived(
|
||||
profile ? (favExpanded ? profile.favorite_cards : profile.favorite_cards.slice(0, ROW_SIZE)) : []
|
||||
);
|
||||
const visibleWtt = $derived(
|
||||
profile ? (wttExpanded ? profile.wtt_cards : profile.wtt_cards.slice(0, ROW_SIZE)) : []
|
||||
);
|
||||
|
||||
// Collapse without the page jumping: lock the section's viewport position before and after
|
||||
function collapseSection(sectionEl: HTMLElement | null, setter: () => void) {
|
||||
if (!sectionEl) { setter(); return; }
|
||||
const beforeTop = sectionEl.getBoundingClientRect().top;
|
||||
setter();
|
||||
requestAnimationFrame(() => {
|
||||
const afterTop = sectionEl.getBoundingClientRect().top;
|
||||
window.scrollBy(0, afterTop - beforeTop);
|
||||
});
|
||||
}
|
||||
|
||||
async function sendFriendRequest() {
|
||||
sendingFriendRequest = true;
|
||||
try {
|
||||
const res = await apiFetch(`${API_URL}/users/${profile.username}/friend-request`, { method: 'POST' });
|
||||
if (res.ok) friendStatus = 'pending';
|
||||
} finally {
|
||||
sendingFriendRequest = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function openChallengeModal() {
|
||||
if (!challengeDecks.length) {
|
||||
const res = await apiFetch(`${API_URL}/decks`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
challengeDecks = data.filter((d: any) => !d.deleted);
|
||||
if (challengeDecks.length) selectedDeckId = challengeDecks[0].id;
|
||||
}
|
||||
}
|
||||
challengeStatus = 'idle';
|
||||
challengeError = '';
|
||||
showChallengeModal = true;
|
||||
}
|
||||
|
||||
async function sendChallenge() {
|
||||
if (!selectedDeckId) return;
|
||||
challengeStatus = 'sending';
|
||||
challengeError = '';
|
||||
const res = await apiFetch(`${API_URL}/users/${profile.username}/challenge`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ deck_id: selectedDeckId }),
|
||||
});
|
||||
if (res.ok) {
|
||||
challengeStatus = 'sent';
|
||||
} else {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
challengeError = data.detail || 'Failed to send challenge';
|
||||
challengeStatus = 'error';
|
||||
}
|
||||
}
|
||||
|
||||
async function removeFriendFromProfile() {
|
||||
if (!friendshipId) return;
|
||||
await apiFetch(`${API_URL}/friendships/${friendshipId}`, { method: 'DELETE' });
|
||||
friendStatus = 'idle';
|
||||
friendshipId = null;
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
const username = get(page).params.username;
|
||||
const token = localStorage.getItem('token');
|
||||
|
||||
const res = await fetch(`${API_URL}/users/${username}`);
|
||||
if (res.status === 404) { notFound = true; loading = false; return; }
|
||||
const data = await res.json();
|
||||
|
||||
// Redirect to own profile if logged-in user visits their own public page
|
||||
if (token) {
|
||||
try {
|
||||
const meRes = await fetch(`${API_URL}/profile`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
if (meRes.ok) {
|
||||
const me = await meRes.json();
|
||||
if (me.username === username) { goto('/profile'); return; }
|
||||
isLoggedIn = true;
|
||||
|
||||
// Check existing friendship status
|
||||
const statusRes = await apiFetch(`${API_URL}/friendship-status/${username}`);
|
||||
if (statusRes.ok) {
|
||||
const s = await statusRes.json();
|
||||
if (s.status === 'friends') { friendStatus = 'friends'; friendshipId = s.friendship_id; }
|
||||
else if (s.status === 'pending_sent') { friendStatus = 'pending'; }
|
||||
else if (s.status === 'pending_received') { friendStatus = 'pending_received'; friendshipId = s.friendship_id; }
|
||||
}
|
||||
}
|
||||
} catch { /* non-critical */ }
|
||||
}
|
||||
|
||||
profile = data;
|
||||
loading = false;
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if loading}
|
||||
<main class="page">
|
||||
<p class="status-text">Loading...</p>
|
||||
</main>
|
||||
|
||||
{:else if notFound}
|
||||
<main class="page">
|
||||
<div class="not-found">
|
||||
<div class="not-found-sigil">✦</div>
|
||||
<h1 class="not-found-title">Unknown Adventurer</h1>
|
||||
<p class="not-found-sub">No record found for this username.</p>
|
||||
<a href="/" class="btn-secondary">Return Home</a>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{:else if profile}
|
||||
<main class="page">
|
||||
<div class="profile-wrap">
|
||||
|
||||
<!-- ═══ HEADER ═══ -->
|
||||
<header class="profile-header">
|
||||
<div class="avatar-col">
|
||||
<div class="avatar">{profile.username[0].toUpperCase()}</div>
|
||||
</div>
|
||||
<div class="header-body">
|
||||
<div class="ornament-line"></div>
|
||||
<h1 class="username">{profile.username}</h1>
|
||||
<div class="ornament-line"></div>
|
||||
{#if profile.last_active_at}
|
||||
<p class="last-active">{formatLastActive(profile.last_active_at)}</p>
|
||||
{/if}
|
||||
<div class="stats-row">
|
||||
<div class="stat">
|
||||
<span class="stat-num wins">{profile.wins}</span>
|
||||
<span class="stat-label">Wins</span>
|
||||
</div>
|
||||
<div class="stat-sep">·</div>
|
||||
<div class="stat">
|
||||
<span class="stat-num losses">{profile.losses}</span>
|
||||
<span class="stat-label">Losses</span>
|
||||
</div>
|
||||
<div class="stat-sep">·</div>
|
||||
<div class="stat">
|
||||
<span class="stat-num" class:good-wr={profile.win_rate !== null && profile.win_rate >= 50} class:bad-wr={profile.win_rate !== null && profile.win_rate < 50}>
|
||||
{profile.win_rate !== null ? `${profile.win_rate}%` : '—'}
|
||||
</span>
|
||||
<span class="stat-label">Win Rate</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if isLoggedIn}
|
||||
<div class="friend-actions">
|
||||
{#if friendStatus === 'idle'}
|
||||
<button class="btn-friend" onclick={sendFriendRequest} disabled={sendingFriendRequest}>{sendingFriendRequest ? 'Adding...' : '+ Add Friend'}</button>
|
||||
{:else if friendStatus === 'pending'}
|
||||
<span class="friend-pending">Request Sent</span>
|
||||
{:else if friendStatus === 'pending_received'}
|
||||
<span class="friend-pending">Sent you a request</span>
|
||||
{:else if friendStatus === 'friends'}
|
||||
<span class="friend-status">Friends</span>
|
||||
<button class="btn-unfriend" onclick={removeFriendFromProfile}>Remove</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- ═══ TRADE WISHLIST ═══ -->
|
||||
{#if profile.trade_wishlist}
|
||||
<section class="section">
|
||||
<h2 class="section-title">Looking For</h2>
|
||||
<blockquote class="wishlist-block">
|
||||
{profile.trade_wishlist}
|
||||
</blockquote>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- ═══ FAVORITES ═══ -->
|
||||
{#if profile.favorite_cards?.length > 0}
|
||||
{@const hasFavMore = profile.favorite_cards.length > ROW_SIZE}
|
||||
<section class="section" bind:this={favSectionEl}>
|
||||
<!-- Header always same height: collapse button invisible when not needed -->
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">Favorites</h2>
|
||||
<button
|
||||
class="expand-btn"
|
||||
style:visibility={hasFavMore && favExpanded ? 'visible' : 'hidden'}
|
||||
onclick={() => collapseSection(favSectionEl, () => { favExpanded = false; })}
|
||||
>Collapse ▲</button>
|
||||
</div>
|
||||
<div class="card-grid">
|
||||
{#each visibleFav as card (card.id)}
|
||||
<div class="card-wrap">
|
||||
<div class="card-inner"><Card {card} /></div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{#if hasFavMore}
|
||||
{#if !favExpanded}
|
||||
<button class="expand-btn" onclick={() => { favExpanded = true; }}>
|
||||
Show all {profile.favorite_cards.length} cards ▼
|
||||
</button>
|
||||
{:else}
|
||||
<button class="expand-btn" onclick={() => collapseSection(favSectionEl, () => { favExpanded = false; })}>
|
||||
Collapse ▲
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- ═══ WILLING TO TRADE ═══ -->
|
||||
{#if profile.wtt_cards?.length > 0}
|
||||
{@const hasWttMore = profile.wtt_cards.length > ROW_SIZE}
|
||||
<section class="section" bind:this={wttSectionEl}>
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">Willing to Trade</h2>
|
||||
<button
|
||||
class="expand-btn"
|
||||
style:visibility={hasWttMore && wttExpanded ? 'visible' : 'hidden'}
|
||||
onclick={() => collapseSection(wttSectionEl, () => { wttExpanded = false; })}
|
||||
>Collapse ▲</button>
|
||||
</div>
|
||||
<div class="card-grid">
|
||||
{#each visibleWtt as card (card.id)}
|
||||
<div class="card-wrap">
|
||||
<div class="card-inner"><Card {card} /></div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{#if hasWttMore}
|
||||
{#if !wttExpanded}
|
||||
<button class="expand-btn" onclick={() => { wttExpanded = true; }}>
|
||||
Show all {profile.wtt_cards.length} cards ▼
|
||||
</button>
|
||||
{:else}
|
||||
<button class="expand-btn" onclick={() => collapseSection(wttSectionEl, () => { wttExpanded = false; })}>
|
||||
Collapse ▲
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if isLoggedIn}
|
||||
<div class="offer-cta">
|
||||
<a href="/trade/offer/{profile.username}" class="btn-primary">⇄ Offer a Trade</a>
|
||||
{#if challengeStatus === 'sent'}
|
||||
<span class="challenge-sent">Challenge Sent ✦</span>
|
||||
{:else}
|
||||
<button class="btn-secondary" onclick={openChallengeModal}>⚔ Challenge to Play</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if showChallengeModal}
|
||||
<div class="modal-backdrop" onclick={() => { showChallengeModal = false; }}>
|
||||
<div class="modal" onclick={(e) => e.stopPropagation()}>
|
||||
<div class="modal-icon">⚔</div>
|
||||
<h2 class="modal-title">Challenge {profile.username}</h2>
|
||||
{#if challengeDecks.length === 0}
|
||||
<p class="modal-body">You have no decks. Build a deck first.</p>
|
||||
<button class="btn-secondary" onclick={() => { showChallengeModal = false; }}>Close</button>
|
||||
{:else if challengeStatus === 'sent'}
|
||||
<p class="modal-body">Your challenge has been sent. {profile.username} has 5 minutes to accept.</p>
|
||||
<button class="btn-primary" onclick={() => { showChallengeModal = false; }}>Done</button>
|
||||
{:else}
|
||||
<p class="modal-body">Select a deck to battle with:</p>
|
||||
<select class="deck-select" bind:value={selectedDeckId}>
|
||||
{#each challengeDecks as deck}
|
||||
<option value={deck.id}>{deck.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{#if challengeError}
|
||||
<p class="challenge-error">{challengeError}</p>
|
||||
{/if}
|
||||
<div class="modal-actions">
|
||||
<button class="btn-primary" onclick={sendChallenge} disabled={challengeStatus === 'sending'}>
|
||||
{challengeStatus === 'sending' ? 'Sending...' : 'Send Challenge'}
|
||||
</button>
|
||||
<button class="btn-secondary" onclick={() => { showChallengeModal = false; }}>Cancel</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.page {
|
||||
height: 100vh;
|
||||
background: var(--color-bg);
|
||||
padding: 2rem;
|
||||
overflow-y: auto;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.profile-wrap {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2.5rem;
|
||||
padding-top: 1rem;
|
||||
padding-bottom: 3rem;
|
||||
}
|
||||
|
||||
/* ── Header ── */
|
||||
.profile-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.avatar-col { flex-shrink: 0; }
|
||||
|
||||
.avatar {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--color-surface-raised);
|
||||
border: 2px solid var(--color-bronze);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: var(--text-3xl);
|
||||
font-weight: 700;
|
||||
color: var(--color-gold);
|
||||
box-shadow: 0 0 20px rgba(200, 134, 26, 0.2), var(--shadow-card);
|
||||
}
|
||||
|
||||
.header-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.ornament-line {
|
||||
height: 1px;
|
||||
background: var(--color-border-subtle);
|
||||
}
|
||||
|
||||
.username {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: var(--text-2xl);
|
||||
font-weight: 700;
|
||||
color: var(--color-gold);
|
||||
margin: 0;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.last-active {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: var(--text-base);
|
||||
font-style: italic;
|
||||
color: rgba(240, 180, 80, 0.35);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.stats-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding-top: 0.25rem;
|
||||
}
|
||||
|
||||
.stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
.stat-num {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: var(--text-xl);
|
||||
font-weight: 700;
|
||||
color: var(--color-gold);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-gold-faint);
|
||||
}
|
||||
|
||||
.stat-sep {
|
||||
font-size: var(--text-lg);
|
||||
color: var(--color-border-subtle);
|
||||
align-self: center;
|
||||
margin-top: -0.5rem;
|
||||
}
|
||||
|
||||
.wins { color: var(--color-success); }
|
||||
.losses { color: var(--color-error); }
|
||||
.good-wr { color: var(--color-success); }
|
||||
.bad-wr { color: var(--color-error); }
|
||||
|
||||
/* ── Friend actions ── */
|
||||
.friend-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.btn-friend {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: var(--btn-font-md);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.07em;
|
||||
text-transform: uppercase;
|
||||
background: var(--color-surface-raised);
|
||||
border: 1px solid var(--color-bronze);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-gold);
|
||||
padding: var(--btn-padding-md);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.btn-friend:hover { background: #4d3010; }
|
||||
|
||||
.friend-pending {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.07em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-gold-faint);
|
||||
}
|
||||
|
||||
.friend-status {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.07em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.btn-unfriend {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: var(--btn-font-sm);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
background: none;
|
||||
border: 1px solid rgba(180, 60, 60, 0.4);
|
||||
border-radius: var(--radius-sm);
|
||||
color: rgba(200, 80, 80, 0.6);
|
||||
padding: var(--btn-padding-sm);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.btn-unfriend:hover { border-color: #c84040; color: #e05050; }
|
||||
|
||||
/* ── Sections ── */
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
/* Header row: always the same height regardless of which buttons are visible */
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: var(--text-base);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-gold-dim);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ── Wishlist ── */
|
||||
.wishlist-block {
|
||||
margin: 0;
|
||||
padding: 1rem 1.5rem;
|
||||
background: rgba(61, 37, 7, 0.25);
|
||||
border-left: 3px solid rgba(200, 134, 26, 0.4);
|
||||
border-radius: 0 var(--radius-lg) var(--radius-lg) 0;
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: var(--text-lg);
|
||||
font-style: italic;
|
||||
color: var(--color-gold-muted);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* ── Card grid ── */
|
||||
.card-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
justify-content: flex-start;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
/*
|
||||
* Two-level card sizing:
|
||||
* .card-wrap — controls layout footprint (width/height + margin compensation)
|
||||
* .card-inner — controls visual scale from center (so hover grows symmetrically)
|
||||
*
|
||||
* card dimensions assumed: 300×420px at natural scale
|
||||
* at scale 0.62: visual 186×260px
|
||||
* margin-right = -(300 - 186) = -114px → next card starts 186+gap from here
|
||||
* margin-bottom = -(420 - 260) = -160px → row height becomes 260px
|
||||
*/
|
||||
.card-wrap {
|
||||
width: 300px;
|
||||
height: 420px;
|
||||
margin-right: -114px;
|
||||
margin-bottom: -160px;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
/* Pointer events on the wrapper would cover the full 300×420px layout box,
|
||||
most of which is empty space at scale 0.62. Delegate to card-inner only. */
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Elevate wrapper when the visible card inside is hovered */
|
||||
.card-wrap:has(.card-inner:hover) {
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.card-inner {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
transform: scale(0.62);
|
||||
transform-origin: top left;
|
||||
transition: transform 0.2s ease;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/*
|
||||
* Hover grows from the visual center, not the top-left corner.
|
||||
* At scale 0.62, visual center = (300*0.62/2, 420*0.62/2) = (93px, 130px).
|
||||
* At scale 0.78 from top-left, center would shift to (117px, 163px) — delta (+24, +33).
|
||||
* Pre-translate by (-24px, -34px) to cancel that shift, keeping center fixed.
|
||||
*/
|
||||
.card-inner:hover {
|
||||
transform: translate(-24px, -34px) scale(0.78);
|
||||
}
|
||||
|
||||
/* ── Expand / Collapse buttons ── */
|
||||
.expand-btn {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: var(--btn-font-sm);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(240, 180, 80, 0.5);
|
||||
background: none;
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: var(--btn-padding-sm);
|
||||
cursor: pointer;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
align-self: flex-start;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.expand-btn:hover {
|
||||
color: var(--color-gold);
|
||||
border-color: rgba(200, 134, 26, 0.6);
|
||||
}
|
||||
|
||||
/* ── Offer CTA ── */
|
||||
.offer-cta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
padding-top: 0.25rem;
|
||||
}
|
||||
|
||||
.challenge-sent {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.07em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.deck-select {
|
||||
width: 100%;
|
||||
background: var(--color-bg);
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-gold);
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: var(--text-md);
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.deck-select:focus { outline: none; border-color: var(--color-bronze); }
|
||||
|
||||
.challenge-error {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: var(--text-base);
|
||||
color: var(--color-error);
|
||||
margin: 0;
|
||||
min-height: 1.2em;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* ── Buttons ── */
|
||||
.btn-primary {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: var(--btn-font-lg);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
background: var(--color-bronze);
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-btn-text);
|
||||
padding: var(--btn-padding-lg);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, transform 0.1s;
|
||||
}
|
||||
|
||||
.btn-primary:hover { background: var(--color-bronze-hover); transform: translateY(-1px); }
|
||||
.btn-primary:active { transform: translateY(0); }
|
||||
a.btn-primary { text-decoration: none; }
|
||||
|
||||
.btn-secondary {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: var(--btn-font-md);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.07em;
|
||||
text-transform: uppercase;
|
||||
background: var(--color-surface-raised);
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-gold);
|
||||
padding: var(--btn-padding-md);
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.btn-secondary:hover { border-color: var(--color-bronze); color: var(--color-btn-text); }
|
||||
|
||||
/* ── States ── */
|
||||
.status-text {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: var(--text-md);
|
||||
font-style: italic;
|
||||
color: var(--color-gold-faint);
|
||||
text-align: center;
|
||||
margin-top: 4rem;
|
||||
}
|
||||
|
||||
.not-found {
|
||||
max-width: 400px;
|
||||
margin: 6rem auto 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.not-found-sigil { font-size: var(--text-3xl); color: rgba(107, 76, 30, 0.4); }
|
||||
|
||||
.not-found-title {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: var(--text-xl);
|
||||
font-weight: 700;
|
||||
color: rgba(240, 180, 80, 0.5);
|
||||
margin: 0;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.not-found-sub {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: var(--text-md);
|
||||
font-style: italic;
|
||||
color: rgba(240, 180, 80, 0.3);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.empty-profile { padding: 2rem 0; text-align: center; }
|
||||
|
||||
.empty-text {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: var(--text-md);
|
||||
font-style: italic;
|
||||
color: rgba(240, 180, 80, 0.25);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ── Modal ── */
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(5, 3, 0, 0.8);
|
||||
backdrop-filter: blur(4px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 100;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: 2rem 2.5rem;
|
||||
max-width: 400px;
|
||||
width: 100%;
|
||||
box-shadow: var(--shadow-elevated);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.modal-icon { font-size: var(--text-3xl); color: rgba(200, 134, 26, 0.6); }
|
||||
|
||||
.modal-title {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--color-gold);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: var(--text-md);
|
||||
font-style: italic;
|
||||
color: var(--color-gold-dim);
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ── Responsive ── */
|
||||
@media (max-width: 600px) {
|
||||
.profile-header { flex-direction: column; align-items: flex-start; gap: 1rem; }
|
||||
.username { font-size: var(--text-xl); }
|
||||
/* Smaller scale on narrow screens (same translate trick: delta = 300*(0.68-0.55)/2 = 19.5px, 420*(0.68-0.55)/2 = 27.3px) */
|
||||
.card-inner { transform: scale(0.55); }
|
||||
.card-inner:hover { transform: translate(-20px, -27px) scale(0.68); }
|
||||
.card-wrap {
|
||||
margin-right: calc(-(300px - 300px * 0.55));
|
||||
margin-bottom: calc(-(420px - 420px * 0.55));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -80,11 +80,9 @@
|
||||
</main>
|
||||
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;700&family=Crimson+Text:ital,wght@0,400;0,600;1,400&display=swap');
|
||||
|
||||
main {
|
||||
min-height: 100vh;
|
||||
background: #0d0a04;
|
||||
background: var(--color-bg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -93,9 +91,9 @@
|
||||
|
||||
.card {
|
||||
width: 380px;
|
||||
background: #3d2507;
|
||||
border: 2px solid #c8861a;
|
||||
border-radius: 12px;
|
||||
background: var(--color-surface);
|
||||
border: 2px solid var(--color-bronze);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: 2rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -104,17 +102,17 @@
|
||||
|
||||
.title {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 20px;
|
||||
font-size: var(--text-xl);
|
||||
font-weight: 700;
|
||||
color: #f5d060;
|
||||
color: var(--color-gold);
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 15px;
|
||||
color: rgba(245, 208, 96, 0.7);
|
||||
font-size: var(--text-md);
|
||||
color: var(--color-gold-dim);
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
}
|
||||
@@ -127,63 +125,65 @@
|
||||
|
||||
.field-label {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 10px;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(245, 208, 96, 0.5);
|
||||
color: var(--color-gold-faint);
|
||||
}
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
padding: 9px 12px;
|
||||
background: #221508;
|
||||
border: 1.5px solid #c8861a;
|
||||
border-radius: 6px;
|
||||
color: #f5d060;
|
||||
background: var(--color-surface);
|
||||
border: 1.5px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-gold);
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 15px;
|
||||
font-size: var(--text-md);
|
||||
box-sizing: border-box;
|
||||
outline: none;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
input:focus { border-color: #f5d060; }
|
||||
input::placeholder { color: rgba(245, 208, 96, 0.35); }
|
||||
input:focus { border-color: var(--color-bronze); }
|
||||
input::placeholder { color: var(--color-gold-faint); }
|
||||
|
||||
.btn {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
background: #c8861a;
|
||||
color: #fff8e0;
|
||||
padding: var(--btn-padding-lg);
|
||||
background: var(--color-bronze);
|
||||
color: var(--color-btn-text);
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
border-radius: var(--radius-md);
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 13px;
|
||||
font-size: var(--btn-font-lg);
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.btn:hover:not(:disabled) { background: #e09820; }
|
||||
.btn:hover:not(:disabled) { background: var(--color-bronze-hover); }
|
||||
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
.back-link {
|
||||
all: unset;
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 14px;
|
||||
color: rgba(245, 208, 96, 0.5);
|
||||
font-size: var(--text-base);
|
||||
color: var(--color-gold-faint);
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.back-link:hover { color: #f5d060; }
|
||||
.back-link:hover { color: var(--color-gold); }
|
||||
|
||||
.error {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 14px;
|
||||
color: #f06060;
|
||||
font-size: var(--text-base);
|
||||
color: var(--color-error);
|
||||
margin: 0;
|
||||
min-height: 1.4em;
|
||||
text-align: center;
|
||||
|
||||
+48
-51
@@ -1,30 +1,27 @@
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import { API_URL } from '$lib/api.js';
|
||||
import { apiFetch } from '$lib/api.js';
|
||||
import { goto } from '$app/navigation';
|
||||
import { onMount } from 'svelte';
|
||||
import CardSelector from '$lib/CardSelector.svelte';
|
||||
|
||||
let allCards = $state([]);
|
||||
let shards = $state(null);
|
||||
let selectedIds = $state(new Set());
|
||||
let selectedCards: any[] = $state([]); // bound from CardSelector
|
||||
let selectorOpen = $state(false);
|
||||
let shattering = $state(false);
|
||||
let result = $state(null); // { gained, shards }
|
||||
let result: { gained: number } | null = $state(null);
|
||||
let inDeckIds = $state(new Set());
|
||||
let selectorRef: any;
|
||||
|
||||
const selectedCards = $derived(allCards.filter(c => selectedIds.has(c.id)));
|
||||
const totalYield = $derived(selectedCards.reduce((sum, c) => sum + c.cost, 0));
|
||||
const totalYield = $derived(selectedCards.reduce((sum: number, c: any) => sum + c.cost, 0));
|
||||
|
||||
onMount(async () => {
|
||||
if (!localStorage.getItem('token')) { goto('/auth'); return; }
|
||||
const [cardsRes, profileRes, inDecksRes] = await Promise.all([
|
||||
apiFetch(`${API_URL}/cards`),
|
||||
const [profileRes, inDecksRes] = await Promise.all([
|
||||
apiFetch(`${API_URL}/profile`),
|
||||
apiFetch(`${API_URL}/cards/in-decks`),
|
||||
]);
|
||||
if (cardsRes.status === 401) { goto('/auth'); return; }
|
||||
allCards = await cardsRes.json();
|
||||
const profile = await profileRes.json();
|
||||
shards = profile.shards;
|
||||
if (inDecksRes.ok) inDeckIds = new Set(await inDecksRes.json());
|
||||
@@ -42,8 +39,8 @@
|
||||
const data = await res.json();
|
||||
shards = data.shards;
|
||||
result = { gained: data.gained };
|
||||
allCards = allCards.filter(c => !selectedIds.has(c.id));
|
||||
selectedIds = new Set();
|
||||
selectorRef?.refresh(); // refetch so shattered cards disappear
|
||||
}
|
||||
shattering = false;
|
||||
}
|
||||
@@ -53,7 +50,7 @@
|
||||
|
||||
<main>
|
||||
<div class="top">
|
||||
<h1 class="page-title">Shards</h1>
|
||||
<h1 class="page-title">Shatter</h1>
|
||||
{#if shards !== null}
|
||||
<div class="shards-display">
|
||||
<span class="shards-icon">◈</span>
|
||||
@@ -96,8 +93,9 @@
|
||||
{#if selectorOpen}
|
||||
<div class="selector-overlay">
|
||||
<CardSelector
|
||||
allCards={allCards}
|
||||
bind:this={selectorRef}
|
||||
bind:selectedIds={selectedIds}
|
||||
bind:selectedCards={selectedCards}
|
||||
{inDeckIds}
|
||||
onclose={() => { selectorOpen = false; }}
|
||||
/>
|
||||
@@ -105,11 +103,9 @@
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;700;900&family=Crimson+Text:ital,wght@0,400;0,600;1,400&display=swap');
|
||||
|
||||
main {
|
||||
min-height: 100vh;
|
||||
background: #0d0a04;
|
||||
background: var(--color-bg);
|
||||
padding: 2.5rem 2rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -130,7 +126,7 @@
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: clamp(22px, 4vw, 32px);
|
||||
font-weight: 900;
|
||||
color: #f0d080;
|
||||
color: var(--color-gold);
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
margin: 0;
|
||||
@@ -143,22 +139,23 @@
|
||||
}
|
||||
|
||||
.shards-icon {
|
||||
font-size: 20px;
|
||||
color: #7ecfcf;
|
||||
font-size: var(--text-xl);
|
||||
color: var(--color-cyan);
|
||||
position: relative;
|
||||
top: -0.1em;
|
||||
animation: shard-pulse 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.shards-amount {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 24px;
|
||||
font-size: var(--text-xl);
|
||||
font-weight: 700;
|
||||
color: #7ecfcf;
|
||||
color: var(--color-cyan);
|
||||
}
|
||||
|
||||
.shards-label {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 11px;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
@@ -168,23 +165,23 @@
|
||||
|
||||
.explainer {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 16px;
|
||||
font-size: var(--text-md);
|
||||
font-style: italic;
|
||||
color: rgba(240, 180, 80, 0.5);
|
||||
color: var(--color-gold-faint);
|
||||
margin: 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.store-hint {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 15px;
|
||||
font-size: var(--text-md);
|
||||
font-style: italic;
|
||||
color: rgba(240, 180, 80, 0.35);
|
||||
color: var(--color-gold-faint);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.store-link {
|
||||
color: #7ecfcf;
|
||||
color: var(--color-cyan);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
transition: color 0.15s;
|
||||
@@ -197,16 +194,16 @@
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
background: #0d2a0d;
|
||||
border: 1.5px solid #6aaa6a;
|
||||
border-radius: 8px;
|
||||
border: 1.5px solid var(--color-success);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 0.75rem 1.25rem;
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 15px;
|
||||
font-size: var(--text-md);
|
||||
font-weight: 700;
|
||||
color: #6aaa6a;
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.result-icon { color: #7ecfcf; position: relative; top: -0.1em; }
|
||||
.result-icon { color: var(--color-cyan); position: relative; top: -0.1em; animation: shard-pulse 3s ease-in-out infinite; }
|
||||
|
||||
.dismiss {
|
||||
margin-left: auto;
|
||||
@@ -214,11 +211,11 @@
|
||||
border: none;
|
||||
color: rgba(106, 170, 106, 0.5);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-size: var(--text-base);
|
||||
padding: 0 0 0 0.75rem;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
.dismiss:hover { color: #6aaa6a; }
|
||||
.dismiss:hover { color: var(--color-success); }
|
||||
|
||||
/* ── Action area ── */
|
||||
.action-area {
|
||||
@@ -232,15 +229,15 @@
|
||||
|
||||
.select-btn {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 13px;
|
||||
font-size: var(--btn-font-lg);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
background: #3d2507;
|
||||
border: 1.5px solid #c8861a;
|
||||
border-radius: 6px;
|
||||
color: #f0d080;
|
||||
padding: 10px 24px;
|
||||
background: var(--color-surface-raised);
|
||||
border: 1.5px solid var(--color-bronze);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-gold);
|
||||
padding: var(--btn-padding-lg);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
width: 100%;
|
||||
@@ -254,26 +251,26 @@
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 15px;
|
||||
font-size: var(--text-md);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.summary-count { color: #f0d080; }
|
||||
.summary-arrow { color: rgba(240, 180, 80, 0.35); }
|
||||
.summary-yield { color: #7ecfcf; display: flex; align-items: center; gap: 0.3rem; }
|
||||
.shards-icon-sm { font-size: 14px; color: #7ecfcf; position: relative; top: -0.1em; }
|
||||
.summary-count { color: var(--color-gold); }
|
||||
.summary-arrow { color: var(--color-gold-faint); }
|
||||
.summary-yield { color: var(--color-cyan); display: flex; align-items: center; gap: 0.3rem; }
|
||||
.shards-icon-sm { font-size: var(--text-base); color: var(--color-cyan); position: relative; top: -0.1em; }
|
||||
|
||||
.shatter-btn {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 13px;
|
||||
font-size: var(--btn-font-lg);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
background: #1a1008;
|
||||
border: 1.5px solid #7ecfcf;
|
||||
border-radius: 6px;
|
||||
color: #7ecfcf;
|
||||
padding: 10px 24px;
|
||||
background: var(--color-surface);
|
||||
border: 1.5px solid var(--color-cyan);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-cyan);
|
||||
padding: var(--btn-padding-lg);
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
transition: background 0.15s;
|
||||
@@ -286,7 +283,7 @@
|
||||
.selector-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 200;
|
||||
z-index: var(--z-dropdown);
|
||||
background: rgba(0, 0, 0, 0.85);
|
||||
}
|
||||
</style>
|
||||
@@ -1,4 +1,4 @@
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import { API_URL } from '$lib/api.js';
|
||||
import { apiFetch } from '$lib/api.js';
|
||||
import { goto } from '$app/navigation';
|
||||
@@ -7,11 +7,11 @@
|
||||
import { page } from '$app/stores';
|
||||
import Card from '$lib/Card.svelte';
|
||||
|
||||
let shards = $state(null);
|
||||
let buying = $state(null); // which quantity is being bought
|
||||
let flash = $state(null); // { quantity, ok }
|
||||
let shardPackages = $state([]);
|
||||
let buyingShards = $state(null);
|
||||
let shards: number | null = $state(null);
|
||||
let buying: number | null = $state(null);
|
||||
let flash: { quantity: number; ok: boolean } | null = $state(null);
|
||||
let shardPackages: any[] = $state([]);
|
||||
let buyingShards: string | null = $state(null);
|
||||
let paymentSuccess = $state(false);
|
||||
|
||||
const packages = [
|
||||
@@ -31,7 +31,7 @@
|
||||
const profile = await profileRes.json();
|
||||
shards = profile.shards;
|
||||
const config = await configRes.json();
|
||||
shardPackages = Object.entries(config.shard_packages).map(([id, pkg]) => ({ id, ...pkg }));
|
||||
shardPackages = Object.entries(config.shard_packages).map(([id, pkg]) => ({ id, ...(pkg as object) }));
|
||||
|
||||
if ($page.url.searchParams.get('payment') === 'success') {
|
||||
paymentSuccess = true;
|
||||
@@ -41,7 +41,7 @@
|
||||
}
|
||||
});
|
||||
|
||||
async function buyWithStripe(packageId) {
|
||||
async function buyWithStripe(packageId: string) {
|
||||
if (buyingShards) return;
|
||||
buyingShards = packageId;
|
||||
const res = await apiFetch(`${API_URL}/store/stripe/checkout`, {
|
||||
@@ -56,8 +56,8 @@
|
||||
buyingShards = null;
|
||||
}
|
||||
|
||||
async function buy(quantity, cost) {
|
||||
if (buying !== null || shards < cost) return;
|
||||
async function buy(quantity: number, cost: number) {
|
||||
if (buying !== null || shards === null || shards < cost) return;
|
||||
buying = quantity;
|
||||
const res = await apiFetch(`${API_URL}/store/buy`, {
|
||||
method: 'POST',
|
||||
@@ -79,9 +79,10 @@
|
||||
const SPECIFIC_CARD_COST = 1000;
|
||||
let specificPhase = $state('idle'); // idle | input | generating | revealing | done
|
||||
let wikiTitle = $state('');
|
||||
let specificCard = $state(null);
|
||||
let specificCard: any = $state(null);
|
||||
let specificFlipped = $state(false);
|
||||
let specificError = $state('');
|
||||
let specificAction = $state({ favorited: false, tradeListed: false, shattered: false, shardGain: 0 });
|
||||
|
||||
function openSpecificModal() {
|
||||
wikiTitle = '';
|
||||
@@ -93,6 +94,36 @@
|
||||
specificPhase = 'idle';
|
||||
specificCard = null;
|
||||
specificFlipped = false;
|
||||
specificAction = { favorited: false, tradeListed: false, shattered: false, shardGain: 0 };
|
||||
}
|
||||
|
||||
async function specificToggleFavorite() {
|
||||
const res = await apiFetch(`${API_URL}/cards/${specificCard.id}/favorite`, { method: 'POST' });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
specificAction = { ...specificAction, favorited: data.is_favorite };
|
||||
}
|
||||
}
|
||||
|
||||
async function specificToggleTrade() {
|
||||
const res = await apiFetch(`${API_URL}/cards/${specificCard.id}/willing-to-trade`, { method: 'POST' });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
specificAction = { ...specificAction, tradeListed: data.willing_to_trade };
|
||||
}
|
||||
}
|
||||
|
||||
async function specificShatter() {
|
||||
const res = await apiFetch(`${API_URL}/shards/shatter`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ card_ids: [specificCard.id] }),
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
shards = data.shards;
|
||||
specificAction = { ...specificAction, shattered: true, shardGain: data.gained };
|
||||
}
|
||||
}
|
||||
|
||||
async function buySpecificCard() {
|
||||
@@ -122,7 +153,7 @@
|
||||
}
|
||||
|
||||
// How many mini-packs to fan out per package
|
||||
function fanCount(quantity) {
|
||||
function fanCount(quantity: number) {
|
||||
if (quantity === 1) return 1;
|
||||
if (quantity === 5) return 2;
|
||||
if (quantity === 10) return 3;
|
||||
@@ -130,7 +161,7 @@
|
||||
}
|
||||
|
||||
// Rotation and offset for each pack in the fan
|
||||
function packTransform(index, total) {
|
||||
function packTransform(index: number, total: number) {
|
||||
if (total === 1) return 'rotate(0deg) translateY(0px)';
|
||||
const spread = 10; // degrees between each pack
|
||||
const mid = (total - 1) / 2;
|
||||
@@ -148,7 +179,7 @@
|
||||
<span class="shards-icon">◈</span>
|
||||
<span class="shards-amount">{shards}</span>
|
||||
<span class="shards-label">Shards</span>
|
||||
<a href="/shards" class="shards-link">shatter cards</a>
|
||||
<a href="/shatter" class="shards-link">shatter cards</a>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -179,12 +210,12 @@
|
||||
|
||||
<button
|
||||
class="buy-btn"
|
||||
class:flash-ok={isFlashing && flash.ok}
|
||||
class:flash-err={isFlashing && !flash.ok}
|
||||
class:flash-ok={isFlashing && flash?.ok}
|
||||
class:flash-err={isFlashing && !flash?.ok}
|
||||
onclick={() => buy(pkg.quantity, pkg.cost)}
|
||||
disabled={!canAfford || buying !== null}
|
||||
>
|
||||
{#if isFlashing && flash.ok}
|
||||
{#if isFlashing && flash?.ok}
|
||||
Purchased!
|
||||
{:else if buying === pkg.quantity}
|
||||
...
|
||||
@@ -236,7 +267,7 @@
|
||||
{#each shardPackages as pkg}
|
||||
<div class="shard-card">
|
||||
{#if pkg.bonus > 0}
|
||||
<div class="shard-sticker"><span class="sticker-icon">◈</span>{pkg.bonus}<br/>BONUS</div>
|
||||
<div class="shard-sticker"><span class="sticker-icon">◈</span>{pkg.bonus}<br/>BONUS*</div>
|
||||
{/if}
|
||||
<span class="shard-amount"><span class="shard-icon">◈</span> {pkg.shards}</span>
|
||||
<span class="shard-price">{pkg.price_label}</span>
|
||||
@@ -250,6 +281,9 @@
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{#if shardPackages.some(p => p.bonus > 0)}
|
||||
<p class="bonus-footnote">* compared to the per-shard price of the 100 shards pack</p>
|
||||
{/if}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -300,6 +334,30 @@
|
||||
Your card is being generated…
|
||||
</p>
|
||||
|
||||
<div class="pack-card-actions" class:actions-visible={specificPhase === 'done'}>
|
||||
{#if specificAction.shattered}
|
||||
<span class="shard-gained">+{specificAction.shardGain} ◈</span>
|
||||
{:else}
|
||||
<button
|
||||
class="pack-action-btn fav"
|
||||
class:active={specificAction.favorited}
|
||||
onclick={specificToggleFavorite}
|
||||
title="Favorite"
|
||||
>{specificAction.favorited ? '★' : '☆'}</button>
|
||||
<button
|
||||
class="pack-action-btn trade"
|
||||
class:active={specificAction.tradeListed}
|
||||
onclick={specificToggleTrade}
|
||||
title="Mark for Trade"
|
||||
>⇄</button>
|
||||
<button
|
||||
class="pack-action-btn shatter"
|
||||
onclick={specificShatter}
|
||||
title="Shatter for shards"
|
||||
>◈</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="close-reveal-btn"
|
||||
class:hidden={specificPhase !== 'done'}
|
||||
@@ -312,12 +370,10 @@
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;700;900&family=Crimson+Text:ital,wght@0,400;0,600;1,400&display=swap');
|
||||
|
||||
main {
|
||||
height: 100vh;
|
||||
overflow-y: auto;
|
||||
background: #0d0a04;
|
||||
background: var(--color-bg);
|
||||
padding: 2.5rem 2rem 5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -336,7 +392,7 @@
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: clamp(22px, 4vw, 32px);
|
||||
font-weight: 900;
|
||||
color: #f0d080;
|
||||
color: var(--color-gold);
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
margin: 0;
|
||||
@@ -350,38 +406,39 @@
|
||||
|
||||
.shards-link {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 10px;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(126, 207, 207, 0.6);
|
||||
border: 1px solid rgba(126, 207, 207, 0.3);
|
||||
border-radius: 4px;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 3px 8px;
|
||||
text-decoration: none;
|
||||
margin-top: 4px;
|
||||
margin-left: 0.5rem;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
}
|
||||
.shards-link:hover { color: #7ecfcf; border-color: rgba(126, 207, 207, 0.7); }
|
||||
.shards-link:hover { color: var(--color-cyan); border-color: rgba(126, 207, 207, 0.7); }
|
||||
|
||||
.shards-icon {
|
||||
font-size: 20px;
|
||||
color: #7ecfcf;
|
||||
font-size: var(--text-xl);
|
||||
color: var(--color-cyan);
|
||||
position: relative;
|
||||
top: -0.1em;
|
||||
animation: shard-pulse 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.shards-amount {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 24px;
|
||||
font-size: var(--text-xl);
|
||||
font-weight: 700;
|
||||
color: #7ecfcf;
|
||||
color: var(--color-cyan);
|
||||
}
|
||||
|
||||
.shards-label {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 11px;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
@@ -399,8 +456,8 @@
|
||||
|
||||
.pkg-card {
|
||||
position: relative;
|
||||
background: #1a1008;
|
||||
border: 1.5px solid rgba(107, 76, 30, 0.5);
|
||||
background: var(--color-surface);
|
||||
border: 1.5px solid var(--color-border-subtle);
|
||||
border-radius: 14px;
|
||||
padding: 2rem 1.5rem 1.5rem;
|
||||
display: flex;
|
||||
@@ -412,7 +469,7 @@
|
||||
}
|
||||
|
||||
.pkg-card:not(.cannot-afford):hover {
|
||||
border-color: #c8861a;
|
||||
border-color: var(--color-bronze);
|
||||
background: #211408;
|
||||
}
|
||||
|
||||
@@ -431,7 +488,7 @@
|
||||
background: #f5d800;
|
||||
color: #c0000a;
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 11px;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 900;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -528,9 +585,9 @@
|
||||
/* ── Labels ── */
|
||||
.qty-label {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 15px;
|
||||
font-size: var(--text-md);
|
||||
font-weight: 700;
|
||||
color: #f0d080;
|
||||
color: var(--color-gold);
|
||||
margin: 0;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
@@ -539,12 +596,12 @@
|
||||
.buy-btn {
|
||||
width: 100%;
|
||||
padding: 8px 0;
|
||||
background: #3d2507;
|
||||
border: 1.5px solid #c8861a;
|
||||
border-radius: 6px;
|
||||
color: #f0d080;
|
||||
background: var(--color-surface-raised);
|
||||
border: 1.5px solid var(--color-bronze);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-gold);
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 13px;
|
||||
font-size: var(--btn-font-lg);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
cursor: pointer;
|
||||
@@ -572,13 +629,13 @@
|
||||
|
||||
.buy-btn.flash-err {
|
||||
background: #4a1a1a;
|
||||
border-color: #c85050;
|
||||
color: #c85050;
|
||||
border-color: #c84040;
|
||||
color: #c84040;
|
||||
}
|
||||
|
||||
.cost-icon {
|
||||
color: #7ecfcf;
|
||||
font-size: 12px;
|
||||
color: var(--color-cyan);
|
||||
font-size: var(--text-sm);
|
||||
position: relative;
|
||||
top: -0.1em;
|
||||
}
|
||||
@@ -587,7 +644,7 @@
|
||||
width: 100%;
|
||||
max-width: 800px;
|
||||
height: 1px;
|
||||
background: rgba(107, 76, 30, 0.3);
|
||||
background: var(--color-border-dim);
|
||||
}
|
||||
|
||||
.shard-section {
|
||||
@@ -597,33 +654,44 @@
|
||||
gap: 1rem;
|
||||
width: 100%;
|
||||
max-width: 800px;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 16px;
|
||||
font-size: var(--text-base);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(240, 180, 80, 0.5);
|
||||
color: var(--color-gold-faint);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.section-hint {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 15px;
|
||||
font-size: var(--text-md);
|
||||
font-style: italic;
|
||||
color: rgba(240, 180, 80, 0.35);
|
||||
color: var(--color-gold-faint);
|
||||
margin: -0.5rem 0 0;
|
||||
}
|
||||
|
||||
.bonus-footnote {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: var(--text-md);
|
||||
font-style: italic;
|
||||
color: var(--color-gold-faint);
|
||||
opacity: 0.6;
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.payment-success {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 15px;
|
||||
color: #6aaa6a;
|
||||
font-size: var(--text-md);
|
||||
color: var(--color-success);
|
||||
background: #0d2a0d;
|
||||
border: 1px solid #6aaa6a;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--color-success);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 0.6rem 1.2rem;
|
||||
}
|
||||
|
||||
@@ -631,12 +699,13 @@
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 1.25rem;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.shard-card {
|
||||
position: relative;
|
||||
background: #1a1008;
|
||||
border: 1.5px solid rgba(107, 76, 30, 0.4);
|
||||
background: var(--color-surface);
|
||||
border: 1.5px solid var(--color-border-subtle);
|
||||
border-radius: 14px;
|
||||
padding: 2rem 2rem 1.75rem;
|
||||
display: flex;
|
||||
@@ -651,14 +720,14 @@
|
||||
position: absolute;
|
||||
top: -14px;
|
||||
right: -14px;
|
||||
background: #7ecfcf;
|
||||
color: #0d0a04;
|
||||
background: var(--color-cyan);
|
||||
color: var(--color-bg);
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 12px;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 900;
|
||||
line-height: 1.3;
|
||||
padding: 8px 11px;
|
||||
border-radius: 5px;
|
||||
border-radius: var(--radius-md);
|
||||
text-align: center;
|
||||
transform: rotate(8deg);
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.6);
|
||||
@@ -670,13 +739,13 @@
|
||||
top: -0.1em;
|
||||
}
|
||||
|
||||
.shard-card:hover { border-color: #7ecfcf; }
|
||||
.shard-card:hover { border-color: var(--color-cyan); }
|
||||
|
||||
.shard-amount {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 26px;
|
||||
font-size: var(--text-2xl);
|
||||
font-weight: 700;
|
||||
color: #7ecfcf;
|
||||
color: var(--color-cyan);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
@@ -685,12 +754,13 @@
|
||||
.shard-icon {
|
||||
position: relative;
|
||||
top: -0.1em;
|
||||
animation: shard-pulse 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.shard-price {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 19px;
|
||||
color: rgba(240, 180, 80, 0.6);
|
||||
font-size: var(--text-lg);
|
||||
color: var(--color-gold-dim);
|
||||
}
|
||||
|
||||
.stripe-btn {
|
||||
@@ -698,10 +768,10 @@
|
||||
padding: 10px 0;
|
||||
background: #1a3a4a;
|
||||
border: 1.5px solid #4a9aba;
|
||||
border-radius: 6px;
|
||||
border-radius: var(--radius-md);
|
||||
color: #a0d8ef;
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 13px;
|
||||
font-size: var(--btn-font-lg);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
@@ -723,8 +793,8 @@
|
||||
}
|
||||
|
||||
.specific-card-preview {
|
||||
background: #1a1008;
|
||||
border: 1.5px solid rgba(107, 76, 30, 0.4);
|
||||
background: var(--color-surface);
|
||||
border: 1.5px solid var(--color-border-subtle);
|
||||
border-radius: 14px;
|
||||
padding: 2rem 2.5rem;
|
||||
width: 100%;
|
||||
@@ -732,11 +802,11 @@
|
||||
|
||||
.specific-card-title {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 14px;
|
||||
font-size: var(--text-base);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(240, 180, 80, 0.6);
|
||||
color: var(--color-gold-dim);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@@ -775,7 +845,7 @@
|
||||
|
||||
.specific-desc {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 16px;
|
||||
font-size: var(--text-md);
|
||||
font-style: italic;
|
||||
color: rgba(240, 180, 80, 0.55);
|
||||
line-height: 1.5;
|
||||
@@ -784,15 +854,15 @@
|
||||
|
||||
.specific-buy-btn {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 13px;
|
||||
font-size: var(--btn-font-lg);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
background: #3d2507;
|
||||
border: 1.5px solid #c8861a;
|
||||
border-radius: 6px;
|
||||
color: #f0d080;
|
||||
padding: 10px 20px;
|
||||
background: var(--color-surface-raised);
|
||||
border: 1.5px solid var(--color-bronze);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-gold);
|
||||
padding: var(--btn-padding-lg);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -805,7 +875,7 @@
|
||||
|
||||
.specific-cant-afford {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 13px;
|
||||
font-size: var(--text-base);
|
||||
font-style: italic;
|
||||
color: rgba(200, 100, 80, 0.7);
|
||||
margin: 0;
|
||||
@@ -815,7 +885,7 @@
|
||||
.specific-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 300;
|
||||
z-index: var(--z-modal);
|
||||
background: rgba(0,0,0,0.92);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -824,8 +894,8 @@
|
||||
|
||||
/* Input modal */
|
||||
.specific-modal {
|
||||
background: #1a1008;
|
||||
border: 1.5px solid rgba(107, 76, 30, 0.6);
|
||||
background: var(--color-surface);
|
||||
border: 1.5px solid var(--color-border-subtle);
|
||||
border-radius: 14px;
|
||||
padding: 2.5rem 2.5rem 2rem;
|
||||
display: flex;
|
||||
@@ -836,40 +906,40 @@
|
||||
|
||||
.modal-title {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 18px;
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: #f0d080;
|
||||
color: var(--color-gold);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.modal-hint {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 15px;
|
||||
font-size: var(--text-md);
|
||||
font-style: italic;
|
||||
color: rgba(240, 180, 80, 0.5);
|
||||
color: var(--color-gold-faint);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.wiki-input {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 17px;
|
||||
background: #0d0a04;
|
||||
border: 1.5px solid rgba(107, 76, 30, 0.6);
|
||||
border-radius: 6px;
|
||||
color: #f0d080;
|
||||
font-size: var(--text-lg);
|
||||
background: var(--color-bg);
|
||||
border: 1.5px solid var(--color-border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-gold);
|
||||
padding: 10px 14px;
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.wiki-input:focus { border-color: #c8861a; }
|
||||
.wiki-input::placeholder { color: rgba(240, 180, 80, 0.25); }
|
||||
.wiki-input:focus { border-color: var(--color-bronze); }
|
||||
.wiki-input::placeholder { color: var(--color-gold-faint); }
|
||||
|
||||
.modal-error {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 14px;
|
||||
color: #c85050;
|
||||
font-size: var(--text-base);
|
||||
color: var(--color-error);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@@ -882,30 +952,30 @@
|
||||
|
||||
.modal-cancel {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 11px;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
background: none;
|
||||
border: 1px solid rgba(107, 76, 30, 0.5);
|
||||
border-radius: 5px;
|
||||
color: rgba(240, 180, 80, 0.5);
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-gold-faint);
|
||||
padding: 8px 18px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, color 0.15s;
|
||||
}
|
||||
.modal-cancel:hover { border-color: #c8861a; color: #f0d080; }
|
||||
.modal-cancel:hover { border-color: var(--color-bronze); color: var(--color-gold); }
|
||||
|
||||
.modal-confirm {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 11px;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
background: #3d2507;
|
||||
border: 1.5px solid #c8861a;
|
||||
border-radius: 5px;
|
||||
color: #f0d080;
|
||||
background: var(--color-surface-raised);
|
||||
border: 1.5px solid var(--color-bronze);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-gold);
|
||||
padding: 8px 18px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
@@ -936,7 +1006,7 @@
|
||||
inset: 0;
|
||||
background: white;
|
||||
opacity: 0;
|
||||
z-index: 9999;
|
||||
z-index: 300;
|
||||
pointer-events: none;
|
||||
border-radius: 16px;
|
||||
}
|
||||
@@ -1000,26 +1070,98 @@
|
||||
|
||||
.reveal-label {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 17px;
|
||||
font-size: var(--text-lg);
|
||||
font-style: italic;
|
||||
color: rgba(240, 180, 80, 0.6);
|
||||
color: var(--color-gold-dim);
|
||||
margin: 0;
|
||||
transition: opacity 0.6s ease;
|
||||
}
|
||||
.reveal-label.hidden { opacity: 0; }
|
||||
.close-reveal-btn.hidden { opacity: 0; pointer-events: none; }
|
||||
|
||||
.pack-card-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
justify-content: center;
|
||||
height: 34px;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.pack-card-actions.actions-visible {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.pack-action-btn {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: var(--text-base);
|
||||
font-weight: 700;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: var(--radius-full);
|
||||
border: 1.5px solid;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.pack-action-btn.fav {
|
||||
background: rgba(30, 20, 0, 0.7);
|
||||
border-color: rgba(200, 160, 0, 0.5);
|
||||
color: rgba(240, 200, 0, 0.6);
|
||||
}
|
||||
.pack-action-btn.fav:hover, .pack-action-btn.fav.active {
|
||||
background: rgba(60, 45, 0, 0.9);
|
||||
border-color: #c8a000;
|
||||
color: #f0c800;
|
||||
}
|
||||
|
||||
.pack-action-btn.trade {
|
||||
background: rgba(0, 25, 25, 0.7);
|
||||
border-color: rgba(0, 150, 150, 0.5);
|
||||
color: rgba(0, 190, 190, 0.6);
|
||||
}
|
||||
.pack-action-btn.trade:hover, .pack-action-btn.trade.active {
|
||||
background: rgba(0, 50, 50, 0.9);
|
||||
border-color: #00a0a0;
|
||||
color: var(--color-cyan);
|
||||
}
|
||||
|
||||
.pack-action-btn.shatter {
|
||||
background: rgba(0, 20, 30, 0.7);
|
||||
border-color: rgba(100, 200, 200, 0.4);
|
||||
color: rgba(126, 207, 207, 0.6);
|
||||
}
|
||||
.pack-action-btn.shatter:hover {
|
||||
background: rgba(0, 40, 50, 0.9);
|
||||
border-color: var(--color-cyan);
|
||||
color: var(--color-cyan);
|
||||
}
|
||||
|
||||
.shard-gained {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: var(--text-base);
|
||||
font-weight: 700;
|
||||
color: var(--color-cyan);
|
||||
padding: 6px 12px;
|
||||
background: rgba(0, 40, 50, 0.8);
|
||||
border: 1.5px solid rgba(126, 207, 207, 0.5);
|
||||
border-radius: 16px;
|
||||
animation: shard-pulse 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.close-reveal-btn {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 13px;
|
||||
font-size: var(--btn-font-lg);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
background: rgba(60,30,5,0.85);
|
||||
border: 1.5px solid #c8861a;
|
||||
border-radius: 6px;
|
||||
color: #f0d080;
|
||||
padding: 10px 32px;
|
||||
border: 1.5px solid var(--color-bronze);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-gold);
|
||||
padding: var(--btn-padding-lg);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import { API_URL, WS_URL, apiFetch } from '$lib/api.js';
|
||||
import { goto } from '$app/navigation';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
@@ -9,13 +9,17 @@
|
||||
|
||||
let phase = $state('idle'); // idle | queuing | trading | complete
|
||||
let error = $state('');
|
||||
let reconnecting = $state(false);
|
||||
let queueReconnectDelay = 1000;
|
||||
let queueReconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let tradeReconnectDelay = 1000;
|
||||
let tradeReconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
let queueWs = null;
|
||||
let tradeWs = null;
|
||||
let queueWs: WebSocket | null = null;
|
||||
let tradeWs: WebSocket | null = null;
|
||||
let tradeId = $state('');
|
||||
|
||||
let allCards = $state([]); // user's full card collection (for selector)
|
||||
let tradeState = $state(null); // latest trade state from server
|
||||
let tradeState: any = $state(null); // latest trade state from server
|
||||
|
||||
let selectorOpen = $state(false);
|
||||
let selectorIds = $state(new Set());
|
||||
@@ -35,12 +39,11 @@
|
||||
|
||||
onMount(async () => {
|
||||
if (!token()) { goto('/auth'); return; }
|
||||
const res = await apiFetch(`${API_URL}/cards`);
|
||||
if (!res.ok) { goto('/auth'); return; }
|
||||
allCards = await res.json();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
clearTimeout(queueReconnectTimer);
|
||||
clearTimeout(tradeReconnectTimer);
|
||||
queueWs?.close();
|
||||
tradeWs?.close();
|
||||
});
|
||||
@@ -49,12 +52,18 @@
|
||||
error = '';
|
||||
phase = 'queuing';
|
||||
queueWs = new WebSocket(`${WS_URL}/ws/trade/queue`);
|
||||
queueWs.onopen = () => queueWs.send(token());
|
||||
queueWs.onopen = () => {
|
||||
queueWs!.send(token()!);
|
||||
reconnecting = false;
|
||||
queueReconnectDelay = 1000;
|
||||
};
|
||||
queueWs.onmessage = (e) => {
|
||||
const msg = JSON.parse(e.data);
|
||||
if (msg.type === 'trade_start') {
|
||||
tradeId = msg.trade_id;
|
||||
queueWs.close();
|
||||
// Set phase before close so onclose doesn't trigger a reconnect
|
||||
phase = 'trading';
|
||||
queueWs!.close();
|
||||
connectToTrade();
|
||||
} else if (msg.type === 'error') {
|
||||
error = msg.message;
|
||||
@@ -62,22 +71,37 @@
|
||||
}
|
||||
};
|
||||
queueWs.onerror = () => { error = 'Connection failed'; phase = 'idle'; };
|
||||
queueWs.onclose = () => {
|
||||
if (phase === 'queuing') {
|
||||
reconnecting = true;
|
||||
queueReconnectTimer = setTimeout(() => {
|
||||
queueReconnectDelay = Math.min(queueReconnectDelay * 2, 30000);
|
||||
joinQueue();
|
||||
}, queueReconnectDelay);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function cancelQueue() {
|
||||
queueWs?.close();
|
||||
clearTimeout(queueReconnectTimer);
|
||||
phase = 'idle';
|
||||
reconnecting = false;
|
||||
queueWs?.close();
|
||||
}
|
||||
|
||||
function connectToTrade() {
|
||||
phase = 'trading';
|
||||
tradeWs = new WebSocket(`${WS_URL}/ws/trade/${tradeId}`);
|
||||
tradeWs.onopen = () => tradeWs.send(token());
|
||||
tradeWs.onopen = () => {
|
||||
tradeWs!.send(token()!);
|
||||
reconnecting = false;
|
||||
tradeReconnectDelay = 1000;
|
||||
};
|
||||
tradeWs.onmessage = (e) => {
|
||||
const msg = JSON.parse(e.data);
|
||||
if (msg.type === 'state') {
|
||||
tradeState = msg.state;
|
||||
} else if (msg.type === 'trade_complete') {
|
||||
// Set phase before close so onclose doesn't trigger a reconnect
|
||||
phase = 'complete';
|
||||
tradeWs?.close();
|
||||
} else if (msg.type === 'error') {
|
||||
@@ -90,9 +114,15 @@
|
||||
}
|
||||
}
|
||||
};
|
||||
tradeWs.onerror = () => { error = 'Connection lost'; phase = 'idle'; };
|
||||
tradeWs.onclose = (e) => {
|
||||
if (phase === 'trading') { error = 'Connection lost'; phase = 'idle'; }
|
||||
tradeWs.onerror = () => { error = 'Connection lost'; reconnecting = false; phase = 'idle'; };
|
||||
tradeWs.onclose = () => {
|
||||
if (phase === 'trading') {
|
||||
reconnecting = true;
|
||||
tradeReconnectTimer = setTimeout(() => {
|
||||
tradeReconnectDelay = Math.min(tradeReconnectDelay * 2, 30000);
|
||||
connectToTrade();
|
||||
}, tradeReconnectDelay);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -100,7 +130,7 @@
|
||||
if (myOffer.accepted) {
|
||||
tradeWs?.send(JSON.stringify({ type: 'unaccept' }));
|
||||
}
|
||||
selectorIds = new Set(myOffer.cards.map(c => c.id));
|
||||
selectorIds = new Set(myOffer.cards.map((c: any) => c.id));
|
||||
selectorOpen = true;
|
||||
}
|
||||
|
||||
@@ -118,7 +148,9 @@
|
||||
}
|
||||
|
||||
function reset() {
|
||||
clearTimeout(tradeReconnectTimer);
|
||||
phase = 'idle';
|
||||
reconnecting = false;
|
||||
tradeState = null;
|
||||
tradeId = '';
|
||||
error = '';
|
||||
@@ -140,7 +172,7 @@
|
||||
{:else if phase === 'queuing'}
|
||||
<div class="center-screen">
|
||||
<div class="spinner"></div>
|
||||
<p class="searching-text">Searching for a trade partner...</p>
|
||||
<p class="searching-text">{reconnecting ? 'Reconnecting...' : 'Searching for a trade partner...'}</p>
|
||||
<button class="cancel-btn" onclick={cancelQueue}>Cancel</button>
|
||||
</div>
|
||||
|
||||
@@ -149,7 +181,6 @@
|
||||
{#if selectorOpen}
|
||||
<div class="selector-overlay">
|
||||
<CardSelector
|
||||
allCards={allCards}
|
||||
bind:selectedIds={selectorIds}
|
||||
onclose={closeSelector}
|
||||
/>
|
||||
@@ -185,7 +216,7 @@
|
||||
|
||||
<div class="panel their-panel">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">{partnerUsername || 'Partner'}'s Offer</span>
|
||||
<span class="panel-title">{#if partnerUsername}<a href="/profile/{partnerUsername}" target="_blank" class="partner-link">{partnerUsername}</a>{:else}Partner{/if}'s Offer</span>
|
||||
{#if theirOffer.accepted}
|
||||
<span class="accepted-badge">Accepted ✓</span>
|
||||
{/if}
|
||||
@@ -211,7 +242,9 @@
|
||||
<div class="action-bar">
|
||||
<button class="choose-btn" onclick={openSelector}>Choose Cards</button>
|
||||
|
||||
{#if error}
|
||||
{#if reconnecting}
|
||||
<span class="reconnecting-text">Reconnecting...</span>
|
||||
{:else if error}
|
||||
<span class="error-inline">{error}</span>
|
||||
{/if}
|
||||
|
||||
@@ -246,11 +279,10 @@
|
||||
</main>
|
||||
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;700&family=Crimson+Text:ital,wght@0,400;0,600;1,400&display=swap');
|
||||
|
||||
main {
|
||||
height: calc(100vh - 56px);
|
||||
background: #0d0a04;
|
||||
background: var(--color-bg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
@@ -270,40 +302,40 @@
|
||||
|
||||
.title {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 36px;
|
||||
font-size: var(--text-3xl);
|
||||
font-weight: 700;
|
||||
color: #f0d080;
|
||||
color: var(--color-gold);
|
||||
margin: 0;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 18px;
|
||||
font-size: var(--text-lg);
|
||||
font-style: italic;
|
||||
color: rgba(240, 180, 80, 0.6);
|
||||
color: var(--color-gold-dim);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.error {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 15px;
|
||||
color: #c85050;
|
||||
font-size: var(--text-md);
|
||||
color: var(--color-error);
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.primary-btn {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 13px;
|
||||
font-size: var(--btn-font-lg);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
background: #3d2507;
|
||||
border: 1px solid #c8861a;
|
||||
border-radius: 4px;
|
||||
color: #f0d080;
|
||||
padding: 10px 28px;
|
||||
background: var(--color-surface-raised);
|
||||
border: 1px solid var(--color-bronze);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-gold);
|
||||
padding: var(--btn-padding-lg);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
margin-top: 0.5rem;
|
||||
@@ -313,24 +345,24 @@
|
||||
|
||||
.cancel-btn {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 11px;
|
||||
font-size: var(--btn-font-md);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
background: none;
|
||||
border: 1px solid rgba(107, 76, 30, 0.5);
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
border-radius: var(--radius-sm);
|
||||
color: rgba(240, 180, 80, 0.5);
|
||||
padding: 6px 18px;
|
||||
padding: var(--btn-padding-md);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.cancel-btn:hover { border-color: #c8861a; color: #f0d080; }
|
||||
.cancel-btn:hover { border-color: var(--color-bronze); color: var(--color-gold); }
|
||||
|
||||
.searching-text {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 18px;
|
||||
font-size: var(--text-lg);
|
||||
font-style: italic;
|
||||
color: rgba(240, 180, 80, 0.7);
|
||||
margin: 0;
|
||||
@@ -340,8 +372,8 @@
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid rgba(200, 134, 26, 0.2);
|
||||
border-top-color: #c8861a;
|
||||
border-radius: 50%;
|
||||
border-top-color: var(--color-bronze);
|
||||
border-radius: var(--radius-full);
|
||||
animation: spin 0.9s linear infinite;
|
||||
}
|
||||
|
||||
@@ -349,20 +381,20 @@
|
||||
|
||||
.complete-icon {
|
||||
font-size: 56px;
|
||||
color: #6aaa6a;
|
||||
color: var(--color-success);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.secondary-link {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 15px;
|
||||
font-size: var(--text-md);
|
||||
font-style: italic;
|
||||
color: rgba(240, 180, 80, 0.5);
|
||||
text-decoration: underline;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.secondary-link:hover { color: #f0d080; }
|
||||
.secondary-link:hover { color: var(--color-gold); }
|
||||
|
||||
/* ── Trade layout ── */
|
||||
|
||||
@@ -394,27 +426,30 @@
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1rem 1.5rem 0.75rem;
|
||||
border-bottom: 1px solid rgba(107, 76, 30, 0.3);
|
||||
border-bottom: 1px solid var(--color-border-dim);
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 14px;
|
||||
font-size: var(--text-base);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(240, 180, 80, 0.7);
|
||||
}
|
||||
|
||||
.partner-link { text-decoration: none; transition: color 0.15s; }
|
||||
.partner-link:hover { color: var(--color-gold); text-decoration: underline; }
|
||||
|
||||
.accepted-badge {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 11px;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
color: #6aaa6a;
|
||||
color: var(--color-success);
|
||||
background: rgba(106, 170, 106, 0.12);
|
||||
border: 1px solid rgba(106, 170, 106, 0.4);
|
||||
border-radius: 3px;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 2px 7px;
|
||||
}
|
||||
|
||||
@@ -433,7 +468,7 @@
|
||||
|
||||
.empty-offer p {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 15px;
|
||||
font-size: var(--text-md);
|
||||
font-style: italic;
|
||||
color: rgba(240, 180, 80, 0.25);
|
||||
margin: 0;
|
||||
@@ -456,7 +491,7 @@
|
||||
.divider {
|
||||
flex-shrink: 0;
|
||||
width: 1px;
|
||||
background: rgba(107, 76, 30, 0.35);
|
||||
background: var(--color-border-dim);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@@ -468,43 +503,58 @@
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1rem 1.5rem;
|
||||
border-top: 1px solid rgba(107, 76, 30, 0.35);
|
||||
background: #0d0a04;
|
||||
border-top: 1px solid var(--color-border-dim);
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
.choose-btn {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 11px;
|
||||
font-size: var(--btn-font-md);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
background: #1e1208;
|
||||
border: 1px solid rgba(107, 76, 30, 0.6);
|
||||
border-radius: 4px;
|
||||
border-radius: var(--radius-sm);
|
||||
color: rgba(240, 180, 80, 0.8);
|
||||
padding: 8px 18px;
|
||||
padding: var(--btn-padding-md);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.choose-btn:hover { background: #2e1c0c; border-color: #c8861a; color: #f0d080; }
|
||||
.choose-btn:hover { background: #2e1c0c; border-color: var(--color-bronze); color: var(--color-gold); }
|
||||
|
||||
.error-inline {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 14px;
|
||||
color: #c85050;
|
||||
font-size: var(--text-base);
|
||||
color: var(--color-error);
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.reconnecting-text {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: var(--text-base);
|
||||
font-style: italic;
|
||||
color: rgba(240, 180, 80, 0.5);
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
animation: fade-pulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes fade-pulse {
|
||||
0%, 100% { opacity: 0.5; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
|
||||
.accept-btn {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 13px;
|
||||
font-size: var(--btn-font-lg);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
border-radius: 4px;
|
||||
padding: 10px 28px;
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--btn-padding-lg);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
margin-left: auto;
|
||||
@@ -520,22 +570,22 @@
|
||||
|
||||
/* Ready: gold, inviting click */
|
||||
.accept-btn.accept-ready {
|
||||
background: #3d2507;
|
||||
border: 2px solid #c8861a;
|
||||
color: #f0d080;
|
||||
background: var(--color-surface-raised);
|
||||
border: 2px solid var(--color-bronze);
|
||||
color: var(--color-gold);
|
||||
box-shadow: 0 0 12px rgba(200, 134, 26, 0.2);
|
||||
}
|
||||
|
||||
.accept-btn.accept-ready:hover {
|
||||
background: #5a3510;
|
||||
box-shadow: 0 0 20px rgba(200, 134, 26, 0.4);
|
||||
box-shadow: var(--shadow-glow);
|
||||
}
|
||||
|
||||
/* Accepted: bright green, pulsing, waiting */
|
||||
.accept-btn.accept-accepted {
|
||||
background: rgba(40, 90, 40, 0.4);
|
||||
border: 2px solid #6aaa6a;
|
||||
color: #6aaa6a;
|
||||
border: 2px solid var(--color-success);
|
||||
color: var(--color-success);
|
||||
cursor: default;
|
||||
animation: pulse-green 1.8s ease-in-out infinite;
|
||||
}
|
||||
@@ -548,7 +598,7 @@
|
||||
.selector-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 200;
|
||||
z-index: var(--z-dropdown);
|
||||
background: rgba(0, 0, 0, 0.9);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,450 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { onMount } from 'svelte';
|
||||
import { get } from 'svelte/store';
|
||||
import { API_URL, apiFetch } from '$lib/api.js';
|
||||
import Card from '$lib/Card.svelte';
|
||||
import CardSelector from '$lib/CardSelector.svelte';
|
||||
|
||||
const token = () => localStorage.getItem('token');
|
||||
|
||||
let recipientUsername = $derived(get(page).params.username);
|
||||
|
||||
let theirCards: any[] = $state([]); // recipient's WTT cards (static list from profile)
|
||||
let loading = $state(true);
|
||||
let notFound = $state(false);
|
||||
|
||||
// Which panel is the selector picking for: 'mine' | 'theirs' | null
|
||||
let selectorMode: 'mine' | 'theirs' | null = $state(null);
|
||||
|
||||
let mySelectedIds: Set<string> = $state(new Set());
|
||||
let mySelectedCards: any[] = $state([]); // bound from CardSelector
|
||||
let theirSelectedIds: Set<string> = $state(new Set());
|
||||
let theirSelectedCards = $derived(theirCards.filter((c: any) => theirSelectedIds.has(c.id)));
|
||||
|
||||
let submitting = $state(false);
|
||||
let error = $state('');
|
||||
let done = $state(false);
|
||||
|
||||
|
||||
onMount(async () => {
|
||||
if (!token()) { goto('/auth'); return; }
|
||||
|
||||
const username = get(page).params.username;
|
||||
const profileRes = await fetch(`${API_URL}/users/${username}`);
|
||||
|
||||
if (profileRes.status === 404) { notFound = true; loading = false; return; }
|
||||
|
||||
const profile = await profileRes.json();
|
||||
theirCards = profile.wtt_cards ?? [];
|
||||
loading = false;
|
||||
});
|
||||
|
||||
function openSelector(mode: 'mine' | 'theirs') {
|
||||
selectorMode = mode;
|
||||
}
|
||||
|
||||
function closeSelector() {
|
||||
selectorMode = null;
|
||||
}
|
||||
|
||||
async function sendOffer() {
|
||||
if (mySelectedIds.size === 0 && theirSelectedIds.size === 0) {
|
||||
error = 'At least one side must include cards.';
|
||||
return;
|
||||
}
|
||||
submitting = true;
|
||||
error = '';
|
||||
const res = await apiFetch(`${API_URL}/trade-proposals`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
recipient_username: get(page).params.username,
|
||||
offered_card_ids: [...mySelectedIds],
|
||||
requested_card_ids: [...theirSelectedIds],
|
||||
}),
|
||||
});
|
||||
submitting = false;
|
||||
if (res.ok) {
|
||||
done = true;
|
||||
} else {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
error = data.detail ?? 'Failed to send offer.';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<main>
|
||||
{#if loading}
|
||||
<div class="center-screen">
|
||||
<p class="status-text">Loading...</p>
|
||||
</div>
|
||||
|
||||
{:else if notFound}
|
||||
<div class="center-screen">
|
||||
<p class="status-text">User not found.</p>
|
||||
<a href="/" class="back-link">← Home</a>
|
||||
</div>
|
||||
|
||||
{:else if done}
|
||||
<div class="center-screen">
|
||||
<div class="done-icon">⇄</div>
|
||||
<h2 class="done-title">Offer Sent</h2>
|
||||
<p class="done-body">Your trade offer has been sent to <strong>{recipientUsername}</strong>. They have 72 hours to respond.</p>
|
||||
<div class="done-actions">
|
||||
<a href="/profile/{recipientUsername}" class="choose-btn">View their profile</a>
|
||||
<a href="/profile" class="choose-btn">Your proposals</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{:else}
|
||||
<!-- Full-screen card selector overlay — rendered conditionally per mode so bind works cleanly -->
|
||||
{#if selectorMode === 'mine'}
|
||||
<div class="selector-overlay">
|
||||
<CardSelector bind:selectedIds={mySelectedIds} bind:selectedCards={mySelectedCards} onclose={closeSelector} />
|
||||
</div>
|
||||
{:else if selectorMode === 'theirs'}
|
||||
<div class="selector-overlay">
|
||||
<CardSelector staticCards={theirCards} bind:selectedIds={theirSelectedIds} onclose={closeSelector} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="trade-layout">
|
||||
<div class="trade-header">
|
||||
<h1 class="trade-title">Propose a Trade <span class="trade-with">with</span> <strong class="trade-username">{recipientUsername}</strong></h1>
|
||||
<p class="action-error" style="min-height: 1.2em">{error}</p>
|
||||
<button
|
||||
class="send-btn"
|
||||
class:disabled={mySelectedIds.size === 0 && theirSelectedIds.size === 0}
|
||||
disabled={submitting || (mySelectedIds.size === 0 && theirSelectedIds.size === 0)}
|
||||
onclick={sendOffer}
|
||||
>
|
||||
{submitting ? 'Sending...' : 'Send Offer'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="trade-panels">
|
||||
<!-- YOUR OFFER panel -->
|
||||
<div class="panel your-panel">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">Your Offer</span>
|
||||
<span class="panel-count">{mySelectedIds.size} card{mySelectedIds.size === 1 ? '' : 's'}</span>
|
||||
<button class="choose-btn" onclick={() => openSelector('mine')}>
|
||||
{mySelectedIds.size > 0 ? 'Change' : 'Choose Cards'}
|
||||
</button>
|
||||
</div>
|
||||
<div class="panel-cards">
|
||||
{#if mySelectedCards.length === 0}
|
||||
<div class="empty-offer">
|
||||
<p>No cards offered</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="card-scroll">
|
||||
{#each mySelectedCards as card (card.id)}
|
||||
<div class="card-wrap">
|
||||
<Card {card} noHover={true} />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- THEIR WTT panel -->
|
||||
<div class="panel their-panel">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">You Want</span>
|
||||
<span class="panel-count">{theirSelectedIds.size} card{theirSelectedIds.size === 1 ? '' : 's'}</span>
|
||||
{#if theirCards.length > 0}
|
||||
<button class="choose-btn" onclick={() => openSelector('theirs')}>
|
||||
{theirSelectedIds.size > 0 ? 'Change' : 'Choose Cards'}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="panel-cards">
|
||||
{#if theirSelectedCards.length === 0}
|
||||
<div class="empty-offer">
|
||||
{#if theirCards.length === 0}
|
||||
<p>{recipientUsername} has no cards marked as willing to trade.</p>
|
||||
{:else}
|
||||
<p>No cards requested</p>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="card-scroll">
|
||||
{#each theirSelectedCards as card (card.id)}
|
||||
<div class="card-wrap">
|
||||
<Card {card} noHover={true} />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
<style>
|
||||
main {
|
||||
height: 100vh;
|
||||
background: var(--color-bg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── Overlay ── */
|
||||
.selector-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: var(--z-dropdown);
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
/* ── Trade layout ── */
|
||||
.trade-layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.trade-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-bottom: 1px solid var(--color-border-dim);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.trade-title {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--color-gold);
|
||||
margin: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.trade-with {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: var(--text-lg);
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
color: rgba(240, 180, 80, 0.5);
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.trade-username {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 700;
|
||||
color: var(--color-bronze);
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
/* ── Panels ── */
|
||||
.trade-panels {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 1.25rem 1.5rem;
|
||||
gap: 1rem;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.your-panel { border-right: 1px solid var(--color-border-dim); }
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: var(--text-base);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(240, 180, 80, 0.5);
|
||||
}
|
||||
|
||||
.panel-count {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: var(--text-base);
|
||||
font-style: italic;
|
||||
color: rgba(240, 180, 80, 0.3);
|
||||
}
|
||||
|
||||
.panel-cards {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.empty-offer {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.empty-offer p {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: var(--text-md);
|
||||
font-style: italic;
|
||||
color: rgba(240, 180, 80, 0.2);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.card-scroll {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-content: flex-start;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
/*
|
||||
* Cards displayed at scale 0.62 inside a 300×420 layout box.
|
||||
* Negative margins compensate so the visual footprint is 186×260px.
|
||||
*/
|
||||
.card-wrap {
|
||||
width: 300px;
|
||||
height: 420px;
|
||||
margin-right: -114px;
|
||||
margin-bottom: -160px;
|
||||
flex-shrink: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.card-wrap :global(.card) {
|
||||
transform: scale(0.62);
|
||||
transform-origin: top left;
|
||||
}
|
||||
|
||||
/* ── Choose button ── */
|
||||
.choose-btn {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: var(--btn-font-md);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.07em;
|
||||
text-transform: uppercase;
|
||||
background: var(--color-surface-raised);
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-gold);
|
||||
padding: var(--btn-padding-md);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
align-self: flex-start;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.choose-btn:hover { border-color: var(--color-bronze); background: #4d3010; }
|
||||
a.choose-btn { text-decoration: none; }
|
||||
|
||||
|
||||
.action-error {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: var(--text-base);
|
||||
color: var(--color-error);
|
||||
margin: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.send-btn {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: var(--btn-font-lg);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
background: var(--color-bronze);
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-btn-text);
|
||||
padding: var(--btn-padding-lg);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, transform 0.1s, box-shadow 0.15s;
|
||||
box-shadow: var(--shadow-glow);
|
||||
}
|
||||
|
||||
.send-btn:hover:not(:disabled) { background: var(--color-bronze-hover); transform: translateY(-1px); box-shadow: 0 0 30px rgba(200, 134, 26, 0.5); }
|
||||
.send-btn:active:not(:disabled) { transform: translateY(0); }
|
||||
.send-btn:disabled, .send-btn.disabled { background: #2a1a05; color: rgba(240, 180, 80, 0.2); cursor: default; box-shadow: none; }
|
||||
|
||||
/* ── Center screen states ── */
|
||||
.center-screen {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1.5rem;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.status-text {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: var(--text-md);
|
||||
font-style: italic;
|
||||
color: var(--color-gold-faint);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.done-icon { font-size: var(--text-3xl); color: rgba(200, 134, 26, 0.5); }
|
||||
|
||||
.done-title {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: var(--text-xl);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--color-gold);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.done-body {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: var(--text-lg);
|
||||
font-style: italic;
|
||||
color: var(--color-gold-dim);
|
||||
text-align: center;
|
||||
margin: 0;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.done-body strong { color: var(--color-gold-muted); font-style: normal; }
|
||||
|
||||
.done-actions {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
/* ── Responsive ── */
|
||||
@media (max-width: 640px) {
|
||||
.trade-panels {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: 1fr 1fr;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.your-panel { border-right: none; border-bottom: 1px solid var(--color-border-dim); }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,439 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { onMount } from 'svelte';
|
||||
import { get } from 'svelte/store';
|
||||
import { API_URL, apiFetch } from '$lib/api.js';
|
||||
import Card from '$lib/Card.svelte';
|
||||
|
||||
const token = () => localStorage.getItem('token');
|
||||
|
||||
let proposal: any = $state(null);
|
||||
let loading = $state(true);
|
||||
let notFound = $state(false);
|
||||
let submitting = $state(false);
|
||||
let error = $state('');
|
||||
let done = $state(false);
|
||||
let doneStatus: 'accepted' | 'declined' | 'withdrawn' = $state('accepted');
|
||||
let confirmingAccept = $state(false);
|
||||
|
||||
onMount(async () => {
|
||||
if (!token()) { goto('/auth'); return; }
|
||||
const id = get(page).params.id;
|
||||
const res = await apiFetch(`${API_URL}/trade-proposals/${id}`);
|
||||
if (res.status === 404 || res.status === 403) { notFound = true; loading = false; return; }
|
||||
if (res.status === 401) { goto('/auth'); return; }
|
||||
proposal = await res.json();
|
||||
loading = false;
|
||||
});
|
||||
|
||||
async function accept() {
|
||||
submitting = true;
|
||||
error = '';
|
||||
const res = await apiFetch(`${API_URL}/trade-proposals/${proposal.id}/accept`, { method: 'POST' });
|
||||
submitting = false;
|
||||
if (res.ok) {
|
||||
doneStatus = 'accepted';
|
||||
done = true;
|
||||
} else {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
error = data.detail ?? 'Failed to accept proposal.';
|
||||
}
|
||||
}
|
||||
|
||||
async function decline() {
|
||||
submitting = true;
|
||||
error = '';
|
||||
const res = await apiFetch(`${API_URL}/trade-proposals/${proposal.id}/decline`, { method: 'POST' });
|
||||
submitting = false;
|
||||
if (res.ok) {
|
||||
doneStatus = proposal.direction === 'outgoing' ? 'withdrawn' : 'declined';
|
||||
done = true;
|
||||
} else {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
error = data.detail ?? 'Failed to decline proposal.';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<main>
|
||||
{#if loading}
|
||||
<div class="center-screen">
|
||||
<p class="status-text">Loading...</p>
|
||||
</div>
|
||||
|
||||
{:else if notFound}
|
||||
<div class="center-screen">
|
||||
<p class="status-text">Proposal not found.</p>
|
||||
<a href="/profile" class="action-btn secondary">← Back to Profile</a>
|
||||
</div>
|
||||
|
||||
{:else if done}
|
||||
<div class="center-screen">
|
||||
<div class="done-icon">⇄</div>
|
||||
{#if doneStatus === 'accepted'}
|
||||
<h2 class="done-title">Trade Accepted</h2>
|
||||
<p class="done-body">The cards have been exchanged. Check your collection.</p>
|
||||
{:else if doneStatus === 'declined'}
|
||||
<h2 class="done-title">Proposal Declined</h2>
|
||||
<p class="done-body">The trade offer from <strong>{proposal.proposer_username}</strong> has been declined.</p>
|
||||
{:else}
|
||||
<h2 class="done-title">Proposal Withdrawn</h2>
|
||||
<p class="done-body">Your trade offer to <strong>{proposal.recipient_username}</strong> has been withdrawn.</p>
|
||||
{/if}
|
||||
<a href="/profile" class="action-btn secondary">← Back to Profile</a>
|
||||
</div>
|
||||
|
||||
{:else}
|
||||
<div class="trade-layout">
|
||||
<div class="trade-header">
|
||||
<h1 class="trade-title">
|
||||
{#if proposal.direction === 'incoming'}
|
||||
Trade offer <span class="trade-with">from</span> <strong class="trade-username">{proposal.proposer_username}</strong>
|
||||
{:else}
|
||||
Your offer <span class="trade-with">to</span> <strong class="trade-username">{proposal.recipient_username}</strong>
|
||||
{/if}
|
||||
</h1>
|
||||
|
||||
<p class="action-error" style="min-height: 1.2em">{error}</p>
|
||||
|
||||
{#if proposal.status !== 'pending'}
|
||||
<span class="status-badge {proposal.status}">{proposal.status}</span>
|
||||
{:else if proposal.direction === 'incoming'}
|
||||
{#if confirmingAccept}
|
||||
<span class="confirm-label">Accept and exchange cards?</span>
|
||||
<button class="action-btn primary" disabled={submitting} onclick={accept}>
|
||||
{submitting ? 'Accepting...' : 'Confirm'}
|
||||
</button>
|
||||
<button class="action-btn secondary" disabled={submitting} onclick={() => confirmingAccept = false}>
|
||||
Cancel
|
||||
</button>
|
||||
{:else}
|
||||
<button class="action-btn destructive" disabled={submitting} onclick={decline}>
|
||||
{submitting ? '...' : 'Decline'}
|
||||
</button>
|
||||
<button class="action-btn primary" disabled={submitting} onclick={() => confirmingAccept = true}>
|
||||
Accept
|
||||
</button>
|
||||
{/if}
|
||||
{:else}
|
||||
<button class="action-btn destructive" disabled={submitting} onclick={decline}>
|
||||
{submitting ? '...' : 'Withdraw'}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="trade-panels">
|
||||
<!-- THEIR OFFER panel (what the proposer is giving) -->
|
||||
<div class="panel your-panel">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">
|
||||
{proposal.direction === 'incoming' ? `${proposal.proposer_username}'s Offer` : 'Your Offer'}
|
||||
</span>
|
||||
<span class="panel-count">{proposal.offered_cards.length} card{proposal.offered_cards.length === 1 ? '' : 's'}</span>
|
||||
</div>
|
||||
<div class="panel-cards">
|
||||
{#if proposal.offered_cards.length === 0}
|
||||
<div class="empty-offer"><p>No cards offered</p></div>
|
||||
{:else}
|
||||
<div class="card-scroll">
|
||||
{#each proposal.offered_cards as card (card.id)}
|
||||
<div class="card-wrap">
|
||||
<Card {card} noHover={true} />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- REQUESTED panel (what the proposer wants) -->
|
||||
<div class="panel their-panel">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">
|
||||
{proposal.direction === 'incoming' ? 'They Want' : `${proposal.recipient_username}'s Cards`}
|
||||
</span>
|
||||
<span class="panel-count">{proposal.requested_cards.length} card{proposal.requested_cards.length === 1 ? '' : 's'}</span>
|
||||
</div>
|
||||
<div class="panel-cards">
|
||||
{#if proposal.requested_cards.length === 0}
|
||||
<div class="empty-offer"><p>No cards requested</p></div>
|
||||
{:else}
|
||||
<div class="card-scroll">
|
||||
{#each proposal.requested_cards as card (card.id)}
|
||||
<div class="card-wrap">
|
||||
<Card {card} noHover={true} />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
<style>
|
||||
main {
|
||||
height: 100vh;
|
||||
background: var(--color-bg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── Trade layout ── */
|
||||
.trade-layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.trade-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-bottom: 1px solid var(--color-border-dim);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.trade-title {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--color-gold);
|
||||
margin: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.trade-with {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: var(--text-lg);
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
color: rgba(240, 180, 80, 0.5);
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.trade-username {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 700;
|
||||
color: var(--color-bronze);
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
/* ── Panels ── */
|
||||
.trade-panels {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 1.25rem 1.5rem;
|
||||
gap: 1rem;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.your-panel { border-right: 1px solid var(--color-border-dim); }
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: var(--text-base);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(240, 180, 80, 0.5);
|
||||
}
|
||||
|
||||
.panel-count {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: var(--text-base);
|
||||
font-style: italic;
|
||||
color: rgba(240, 180, 80, 0.3);
|
||||
}
|
||||
|
||||
.panel-cards {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.empty-offer {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.empty-offer p {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: var(--text-md);
|
||||
font-style: italic;
|
||||
color: rgba(240, 180, 80, 0.2);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.card-scroll {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-content: flex-start;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.card-wrap {
|
||||
width: 300px;
|
||||
height: 420px;
|
||||
margin-right: -114px;
|
||||
margin-bottom: -160px;
|
||||
flex-shrink: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.card-wrap :global(.card) {
|
||||
transform: scale(0.62);
|
||||
transform-origin: top left;
|
||||
}
|
||||
|
||||
/* ── Buttons ── */
|
||||
.action-btn {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: var(--btn-font-lg);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.07em;
|
||||
text-transform: uppercase;
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--btn-padding-lg);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, transform 0.1s;
|
||||
border: none;
|
||||
flex-shrink: 0;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.action-btn.primary {
|
||||
background: var(--color-bronze);
|
||||
color: var(--color-btn-text);
|
||||
box-shadow: var(--shadow-glow);
|
||||
}
|
||||
.action-btn.primary:hover:not(:disabled) { background: var(--color-bronze-hover); transform: translateY(-1px); }
|
||||
|
||||
.action-btn.destructive {
|
||||
background: rgba(180, 40, 40, 0.8);
|
||||
color: #fff;
|
||||
}
|
||||
.action-btn.destructive:hover:not(:disabled) { background: rgba(210, 50, 50, 0.9); }
|
||||
|
||||
.action-btn.secondary {
|
||||
background: var(--color-surface-raised);
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
color: var(--color-gold);
|
||||
}
|
||||
.action-btn.secondary:hover { border-color: var(--color-bronze); background: #4d3010; }
|
||||
|
||||
.action-btn:disabled { opacity: 0.5; cursor: default; transform: none; }
|
||||
|
||||
.confirm-label {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: var(--text-md);
|
||||
font-style: italic;
|
||||
color: rgba(240, 180, 80, 0.7);
|
||||
}
|
||||
|
||||
.action-error {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: var(--text-base);
|
||||
color: var(--color-error);
|
||||
margin: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
padding: 5px 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.status-badge.accepted { color: var(--color-success); border-color: rgba(106, 170, 106, 0.4); }
|
||||
.status-badge.declined { color: var(--color-error); border-color: rgba(200, 64, 64, 0.4); }
|
||||
.status-badge.expired { color: rgba(240, 180, 80, 0.3); border-color: var(--color-border-dim); }
|
||||
.status-badge.withdrawn { color: rgba(240, 180, 80, 0.3); border-color: var(--color-border-dim); }
|
||||
|
||||
/* ── Center screen states ── */
|
||||
.center-screen {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1.5rem;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.status-text {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: var(--text-md);
|
||||
font-style: italic;
|
||||
color: var(--color-gold-faint);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.done-icon { font-size: var(--text-3xl); color: rgba(200, 134, 26, 0.5); }
|
||||
|
||||
.done-title {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: var(--text-xl);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--color-gold);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.done-body {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: var(--text-lg);
|
||||
font-style: italic;
|
||||
color: var(--color-gold-dim);
|
||||
text-align: center;
|
||||
margin: 0;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.done-body strong { color: var(--color-gold-muted); font-style: normal; }
|
||||
|
||||
/* ── Responsive ── */
|
||||
@media (max-width: 640px) {
|
||||
.trade-panels {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: 1fr 1fr;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.your-panel { border-right: none; border-bottom: 1px solid var(--color-border-dim); }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,290 @@
|
||||
<script lang="ts">
|
||||
import { API_URL, apiFetch } from '$lib/api.js';
|
||||
import { goto } from '$app/navigation';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
let query = $state('');
|
||||
let results: any[] = $state([]);
|
||||
let loading = $state(false);
|
||||
let searched = $state(false);
|
||||
|
||||
onMount(() => {
|
||||
if (!localStorage.getItem('token')) goto('/auth');
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const q = query;
|
||||
if (q.trim().length === 0) {
|
||||
results = [];
|
||||
searched = false;
|
||||
return;
|
||||
}
|
||||
if (q.trim().length < 2) {
|
||||
results = [];
|
||||
searched = false;
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(async () => {
|
||||
loading = true;
|
||||
const res = await apiFetch(`${API_URL}/users?q=${encodeURIComponent(q.trim())}`);
|
||||
if (res.ok) {
|
||||
results = await res.json();
|
||||
}
|
||||
loading = false;
|
||||
searched = true;
|
||||
}, 300);
|
||||
return () => clearTimeout(timer);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="page">
|
||||
<header class="page-header">
|
||||
<h1>Players</h1>
|
||||
<p class="subtitle">Search for other players by username</p>
|
||||
</header>
|
||||
|
||||
<div class="search-wrap">
|
||||
<div class="search-field">
|
||||
<svg class="search-icon" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="8.5" cy="8.5" r="5.5" stroke="currentColor" stroke-width="1.5"/>
|
||||
<path d="M13 13l3.5 3.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={query}
|
||||
placeholder="Search by username..."
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="results-area">
|
||||
{#if query.trim() === ''}
|
||||
<p class="hint">Type a username to search</p>
|
||||
{:else if loading}
|
||||
<p class="hint">Searching...</p>
|
||||
{:else if searched && results.length === 0}
|
||||
<p class="hint">No players found</p>
|
||||
{:else if results.length > 0}
|
||||
<ul class="results">
|
||||
{#each results as user}
|
||||
<li class="result-row">
|
||||
<a
|
||||
class="username"
|
||||
href="/profile/{user.username}"
|
||||
>{user.username}</a>
|
||||
|
||||
<span class="stats">
|
||||
<span class="stat win">W: {user.wins}</span>
|
||||
<span class="sep">·</span>
|
||||
<span class="stat loss">L: {user.losses}</span>
|
||||
<span class="sep">·</span>
|
||||
<span class="stat rate">{user.win_rate}% win rate</span>
|
||||
</span>
|
||||
|
||||
<div class="actions">
|
||||
<a class="btn-primary" href="/trade/offer/{user.username}">Propose Trade</a>
|
||||
<button class="btn-disabled" disabled title="Coming soon">Challenge</button>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.page {
|
||||
max-width: 760px;
|
||||
margin: 0 auto;
|
||||
padding: 3rem 2rem 4rem;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 2.5rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: var(--text-3xl);
|
||||
font-weight: 900;
|
||||
color: var(--color-gold);
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
margin: 0 0 0.4rem;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: var(--text-lg);
|
||||
color: var(--color-gold-dim);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Search */
|
||||
.search-wrap {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.search-field {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
position: absolute;
|
||||
left: 14px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: var(--color-gold-faint);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
background: var(--color-surface);
|
||||
border: 1.5px solid var(--color-bronze);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 13px 16px 13px 42px;
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: var(--text-lg);
|
||||
color: var(--color-gold);
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
input::placeholder {
|
||||
color: var(--color-gold-faint);
|
||||
}
|
||||
|
||||
input:focus {
|
||||
border-color: var(--color-gold);
|
||||
}
|
||||
|
||||
/* States */
|
||||
.hint {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-style: italic;
|
||||
font-size: var(--text-lg);
|
||||
color: var(--color-gold-faint);
|
||||
text-align: center;
|
||||
margin: 3rem 0;
|
||||
}
|
||||
|
||||
/* Results */
|
||||
.results {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.result-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 0.9rem 1.2rem;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.result-row:hover {
|
||||
border-color: var(--color-bronze);
|
||||
box-shadow: var(--shadow-subtle);
|
||||
}
|
||||
|
||||
.username {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: var(--text-md);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--color-gold);
|
||||
text-decoration: none;
|
||||
min-width: 120px;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.username:hover {
|
||||
color: var(--color-bronze-hover);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.stats {
|
||||
flex: 1;
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: var(--text-md);
|
||||
color: var(--color-gold-dim);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.stat.win { color: var(--color-success); }
|
||||
.stat.loss { color: var(--color-error); }
|
||||
.stat.rate { color: var(--color-gold-dim); }
|
||||
|
||||
.sep {
|
||||
color: var(--color-border);
|
||||
font-size: var(--text-base);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: var(--btn-font-md);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
background: var(--color-bronze);
|
||||
color: var(--color-btn-text);
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--btn-padding-md);
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--color-bronze-hover);
|
||||
}
|
||||
|
||||
.btn-disabled {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: var(--btn-font-md);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
background: rgba(200, 134, 26, 0.15);
|
||||
color: rgba(240, 208, 128, 0.3);
|
||||
border: 1px solid rgba(107, 76, 30, 0.25);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--btn-padding-md);
|
||||
cursor: not-allowed;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.result-row {
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.username { min-width: unset; }
|
||||
.stats { width: 100%; }
|
||||
.actions { width: 100%; }
|
||||
}
|
||||
</style>
|
||||
@@ -50,11 +50,9 @@
|
||||
</main>
|
||||
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;700&family=Crimson+Text:ital,wght@0,400;0,600;1,400&display=swap');
|
||||
|
||||
main {
|
||||
min-height: 100vh;
|
||||
background: #0d0a04;
|
||||
background: var(--color-bg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -63,9 +61,9 @@
|
||||
|
||||
.card {
|
||||
width: 380px;
|
||||
background: #3d2507;
|
||||
border: 2px solid #c8861a;
|
||||
border-radius: 12px;
|
||||
background: var(--color-surface-raised);
|
||||
border: 2px solid var(--color-bronze);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: 2rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -74,17 +72,17 @@
|
||||
|
||||
.title {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 20px;
|
||||
font-size: var(--text-xl);
|
||||
font-weight: 700;
|
||||
color: #f5d060;
|
||||
color: var(--color-gold);
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 15px;
|
||||
color: rgba(245, 208, 96, 0.7);
|
||||
font-size: var(--text-md);
|
||||
color: var(--color-gold-dim);
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
line-height: 1.6;
|
||||
@@ -92,17 +90,17 @@
|
||||
|
||||
.btn {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
background: #c8861a;
|
||||
color: #fff8e0;
|
||||
padding: var(--btn-padding-lg);
|
||||
background: var(--color-bronze);
|
||||
color: var(--color-btn-text);
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
border-radius: var(--radius-md);
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 13px;
|
||||
font-size: var(--btn-font-lg);
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.btn:hover { background: #e09820; }
|
||||
.btn:hover { background: var(--color-bronze-hover); }
|
||||
</style>
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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.
|
||||
Reference in New Issue
Block a user