🐐
This commit is contained in:
+6
-2
@@ -3,6 +3,10 @@ __pycache__/
|
|||||||
.svelte-kit/
|
.svelte-kit/
|
||||||
.env
|
.env
|
||||||
|
|
||||||
backend/simulation_cards.json
|
backend/ai/simulation_cards.json
|
||||||
|
backend/ai/tournament_grid.png
|
||||||
backend/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 asyncio
|
||||||
import random
|
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
|
import random
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from itertools import combinations, permutations
|
from itertools import combinations, permutations
|
||||||
|
|
||||||
import numpy as np
|
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")
|
logger = logging.getLogger("app")
|
||||||
|
|
||||||
@@ -77,7 +80,21 @@ def choose_cards(cards: list[Card], difficulty: int, personality: AIPersonality)
|
|||||||
elif personality == AIPersonality.CONTROL:
|
elif personality == AIPersonality.CONTROL:
|
||||||
# Small cost_norm keeps flavour without causing severe deck shrinkage at D10
|
# Small cost_norm keeps flavour without causing severe deck shrinkage at D10
|
||||||
scores = 0.85 * pcv_norm + 0.15 * cost_norm
|
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)
|
scores = 0.60 * pcv_norm + 0.25 * atk_ratio + 0.15 * (1.0 - atk_ratio)
|
||||||
else: # ARBITRARY
|
else: # ARBITRARY
|
||||||
w = 0.09 * difficulty
|
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.DEFENSIVE: 15, # raised: stable cheap-card base across difficulty levels
|
||||||
AIPersonality.CONTROL: 8,
|
AIPersonality.CONTROL: 8,
|
||||||
AIPersonality.BALANCED: 25, # spread the deck across all cost levels
|
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,
|
AIPersonality.ARBITRARY: 8,
|
||||||
}[personality]
|
}[personality]
|
||||||
|
|
||||||
@@ -320,14 +337,14 @@ def choose_plan(player: PlayerState, opponent: PlayerState, personality: AIPerso
|
|||||||
plans = generate_plans(player, opponent)
|
plans = generate_plans(player, opponent)
|
||||||
|
|
||||||
if personality == AIPersonality.JEBRASKA:
|
if personality == AIPersonality.JEBRASKA:
|
||||||
from nn import NeuralNet
|
from ai.nn import NeuralNet
|
||||||
import os
|
import os
|
||||||
_weights = os.path.join(os.path.dirname(__file__), "nn_weights.json")
|
_weights = os.path.join(os.path.dirname(__file__), "nn_weights.json")
|
||||||
if not hasattr(choose_plan, "_neural_net"):
|
if not hasattr(choose_plan, "_neural_net"):
|
||||||
choose_plan._neural_net = NeuralNet.load(_weights) if os.path.exists(_weights) else None
|
choose_plan._neural_net = NeuralNet.load(_weights) if os.path.exists(_weights) else None
|
||||||
net = choose_plan._neural_net
|
net = choose_plan._neural_net
|
||||||
if net is not None:
|
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))
|
scores = net.forward(extract_plan_features(plans, player, opponent))
|
||||||
else: # fallback to BALANCED if weights not found
|
else: # fallback to BALANCED if weights not found
|
||||||
scores = score_plans_batch(plans, player, opponent, AIPersonality.BALANCED)
|
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))]
|
return plans[int(np.argmax(scores + noise))]
|
||||||
|
|
||||||
async def run_ai_turn(game_id: str):
|
async def run_ai_turn(game_id: str):
|
||||||
from game_manager import (
|
from game.manager import (
|
||||||
active_games, connections, active_deck_ids,
|
active_games, connections, active_deck_ids,
|
||||||
serialize_state, record_game_result, calculate_combat_animation_time
|
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)
|
await send_state(state)
|
||||||
|
|
||||||
if state.result:
|
if state.result:
|
||||||
from database import SessionLocal
|
from core.database import SessionLocal
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
record_game_result(state, db)
|
record_game_result(state, db)
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import numpy as np
|
|
||||||
import json
|
import json
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
# Layout: [state(8) | my_board(15) | opp_board(15) | plan(3) | result_board(15) | opp_deck_type(8)]
|
# Layout: [state(8) | my_board(15) | opp_board(15) | plan(3) | result_board(15) | opp_deck_type(8)]
|
||||||
N_FEATURES = 64
|
N_FEATURES = 64
|
||||||
|
|
||||||
@@ -137,7 +138,7 @@ def extract_plan_features(plans: list, player, opponent) -> np.ndarray:
|
|||||||
Returns (n_plans, N_FEATURES) float32 array.
|
Returns (n_plans, N_FEATURES) float32 array.
|
||||||
Layout: [state(8) | my_board(15) | opp_board(15) | plan(3) | result_board(15)]
|
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)
|
n = len(plans)
|
||||||
|
|
||||||
@@ -217,7 +218,7 @@ class NeuralPlayer:
|
|||||||
self.trajectory: list[tuple[np.ndarray, int]] = [] # (features, chosen_idx)
|
self.trajectory: list[tuple[np.ndarray, int]] = [] # (features, chosen_idx)
|
||||||
|
|
||||||
def choose_plan(self, player, opponent):
|
def choose_plan(self, player, opponent):
|
||||||
from ai import generate_plans
|
from ai.engine import generate_plans
|
||||||
plans = generate_plans(player, opponent)
|
plans = generate_plans(player, opponent)
|
||||||
features = extract_plan_features(plans, player, opponent)
|
features = extract_plan_features(plans, player, opponent)
|
||||||
scores = self.net.forward(features)
|
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 json
|
||||||
import math
|
import math
|
||||||
import os
|
import os
|
||||||
import random
|
import random
|
||||||
import uuid
|
import uuid
|
||||||
import asyncio
|
|
||||||
from concurrent.futures import ProcessPoolExecutor
|
from concurrent.futures import ProcessPoolExecutor
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
from datetime import datetime
|
from game.card import Card, CardType, CardRarity, generate_cards, compute_deck_type
|
||||||
|
from game.rules import (
|
||||||
from card import Card, CardType, CardRarity, generate_cards, compute_deck_type
|
|
||||||
from game import (
|
|
||||||
CardInstance, PlayerState, GameState,
|
CardInstance, PlayerState, GameState,
|
||||||
action_play_card, action_sacrifice, action_end_turn,
|
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_CARDS_PATH = os.path.join(os.path.dirname(__file__), "simulation_cards.json")
|
||||||
SIMULATION_CARD_COUNT = 1000
|
SIMULATION_CARD_COUNT = 1000
|
||||||
@@ -24,7 +24,7 @@ SIMULATION_CARD_COUNT = 1000
|
|||||||
def _card_to_dict(card: Card) -> dict:
|
def _card_to_dict(card: Card) -> dict:
|
||||||
return {
|
return {
|
||||||
"name": card.name,
|
"name": card.name,
|
||||||
"created_at": card.created_at.isoformat(),
|
"generated_at": card.generated_at.isoformat(),
|
||||||
"image_link": card.image_link,
|
"image_link": card.image_link,
|
||||||
"card_rarity": card.card_rarity.name,
|
"card_rarity": card.card_rarity.name,
|
||||||
"card_type": card.card_type.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:
|
def _dict_to_card(d: dict) -> Card:
|
||||||
return Card(
|
return Card(
|
||||||
name=d["name"],
|
name=d["name"],
|
||||||
created_at=datetime.fromisoformat(d["created_at"]),
|
generated_at=datetime.fromisoformat(d["generated_at"]),
|
||||||
image_link=d["image_link"],
|
image_link=d["image_link"],
|
||||||
card_rarity=CardRarity[d["card_rarity"]],
|
card_rarity=CardRarity[d["card_rarity"]],
|
||||||
card_type=CardType[d["card_type"]],
|
card_type=CardType[d["card_type"]],
|
||||||
@@ -609,7 +609,7 @@ def draw_grid(
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
difficulties = list(range(7, 11))
|
difficulties = list(range(8, 11))
|
||||||
|
|
||||||
card_pool = get_simulation_cards()
|
card_pool = get_simulation_cards()
|
||||||
players = _all_players(difficulties)
|
players = _all_players(difficulties)
|
||||||
@@ -1,27 +1,39 @@
|
|||||||
import os
|
import os
|
||||||
import random
|
import random
|
||||||
import uuid
|
import uuid
|
||||||
import numpy as np
|
|
||||||
from collections import deque
|
from collections import deque
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
from card import compute_deck_type
|
from game.card import compute_deck_type
|
||||||
from ai import AIPersonality, choose_cards, choose_plan
|
from ai.engine import AIPersonality, choose_cards, choose_plan
|
||||||
from game import PlayerState, GameState, action_play_card, action_sacrifice, action_end_turn
|
from game.rules import PlayerState, GameState, action_play_card, action_sacrifice, action_end_turn
|
||||||
from simulate import get_simulation_cards, _make_instances, MAX_TURNS
|
from ai.simulate import get_simulation_cards, _make_instances, MAX_TURNS
|
||||||
from nn import NeuralNet, NeuralPlayer
|
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")
|
NN_WEIGHTS_PATH = os.path.join(os.path.dirname(__file__), "nn_weights.json")
|
||||||
|
|
||||||
P1 = "p1"
|
P1 = "p1"
|
||||||
P2 = "p2"
|
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 = choose_cards(cards, difficulty, personality)
|
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)
|
instances = _make_instances(deck)
|
||||||
random.shuffle(instances)
|
random.shuffle(instances)
|
||||||
p = PlayerState(
|
p = PlayerState(
|
||||||
@@ -32,6 +44,21 @@ def _build_player(pid: str, name: str, cards: list, difficulty: int, personality
|
|||||||
return p
|
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(
|
def run_episode(
|
||||||
p1_state: PlayerState,
|
p1_state: PlayerState,
|
||||||
p2_state: PlayerState,
|
p2_state: PlayerState,
|
||||||
@@ -81,25 +108,40 @@ def run_episode(
|
|||||||
|
|
||||||
|
|
||||||
def train(
|
def train(
|
||||||
n_episodes: int = 20_000,
|
n_episodes: int = 50_000,
|
||||||
self_play_start: int = 5_000,
|
self_play_start: int = 0,
|
||||||
self_play_max_frac: float = 0.4,
|
self_play_max_frac: float = 0.9,
|
||||||
lr: float = 1e-3,
|
lr: float = 1e-3,
|
||||||
opp_difficulty: int = 10,
|
opp_difficulty: int = 10,
|
||||||
temperature: float = 1.0,
|
temperature: float = 1.0,
|
||||||
batch_size: int = 50,
|
batch_size: int = 500,
|
||||||
save_every: int = 5_000,
|
save_every: int = 5_000,
|
||||||
save_path: str = NN_WEIGHTS_PATH,
|
save_path: str = NN_WEIGHTS_PATH,
|
||||||
) -> NeuralNet:
|
) -> NeuralNet:
|
||||||
cards = get_simulation_cards()
|
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):
|
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)
|
net = NeuralNet.load(save_path)
|
||||||
else:
|
else:
|
||||||
print("Initializing new network")
|
print("Initializing new plan network")
|
||||||
net = NeuralNet(seed=42)
|
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
|
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 = 0.0 # EMA of recent outcomes; subtracted before each update
|
||||||
baseline_alpha = 0.99 # decay — roughly a 100-episode window
|
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_gb = [np.zeros_like(b) for b in net.biases]
|
||||||
batch_count = 0
|
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):
|
for episode in range(1, n_episodes + 1):
|
||||||
# Ramp self-play fraction linearly from 0 to self_play_max_frac
|
# Ramp self-play fraction linearly from 0 to self_play_max_frac
|
||||||
if episode >= self_play_start:
|
if episode >= self_play_start:
|
||||||
@@ -122,9 +168,11 @@ def train(
|
|||||||
if random.random() < self_play_prob:
|
if random.random() < self_play_prob:
|
||||||
nn1 = NeuralPlayer(net, training=True, temperature=temperature)
|
nn1 = NeuralPlayer(net, training=True, temperature=temperature)
|
||||||
nn2 = 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)
|
p1_state = _build_nn_player(P1, "NN1", cards, 10, cp1)
|
||||||
p2_state = _build_player(P2, "NN2", cards, 10, AIPersonality.BALANCED)
|
p2_state = _build_nn_player(P2, "NN2", cards, 10, cp2)
|
||||||
|
|
||||||
if not nn_goes_first:
|
if not nn_goes_first:
|
||||||
p1_state, p2_state = p2_state, p1_state
|
p1_state, p2_state = p2_state, p1_state
|
||||||
@@ -142,20 +190,30 @@ def train(
|
|||||||
batch_gb[i] += gb[i]
|
batch_gb[i] += gb[i]
|
||||||
batch_count += 1
|
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:
|
else:
|
||||||
opp_personality = random.choice(FIXED_PERSONALITIES)
|
opp_personality = random.choice(FIXED_PERSONALITIES)
|
||||||
nn_player = NeuralPlayer(net, training=True, temperature=temperature)
|
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)
|
opp_ctrl = lambda p, o, pers=opp_personality, diff=opp_difficulty: choose_plan(p, o, pers, diff)
|
||||||
|
|
||||||
if nn_goes_first:
|
if nn_goes_first:
|
||||||
nn_id = P1
|
nn_id = P1
|
||||||
p1_state = _build_player(P1, "NN", cards, 10, AIPersonality.BALANCED)
|
p1_state = _build_nn_player(P1, "NN", cards, 10, cp_player)
|
||||||
p2_state = _build_player(P2, "OPP", cards, opp_difficulty, opp_personality)
|
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)
|
winner = run_episode(p1_state, p2_state, nn_player.choose_plan, opp_ctrl)
|
||||||
else:
|
else:
|
||||||
nn_id = P2
|
nn_id = P2
|
||||||
p1_state = _build_player(P1, "OPP", cards, opp_difficulty, opp_personality)
|
p1_state = _build_player(P1, "OPP", cards, opp_difficulty, opp_personality, opp_deck_pool)
|
||||||
p2_state = _build_player(P2, "NN", cards, 10, AIPersonality.BALANCED)
|
p2_state = _build_nn_player(P2, "NN", cards, 10, cp_player)
|
||||||
winner = run_episode(p1_state, p2_state, opp_ctrl, nn_player.choose_plan)
|
winner = run_episode(p1_state, p2_state, opp_ctrl, nn_player.choose_plan)
|
||||||
|
|
||||||
nn_outcome = 1.0 if winner == nn_id else -1.0
|
nn_outcome = 1.0 if winner == nn_id else -1.0
|
||||||
@@ -169,6 +227,14 @@ def train(
|
|||||||
batch_gb[i] += gb[i]
|
batch_gb[i] += gb[i]
|
||||||
batch_count += 1
|
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)
|
recent_outcomes.append(1 if winner == nn_id else 0)
|
||||||
|
|
||||||
if batch_count >= batch_size:
|
if batch_count >= batch_size:
|
||||||
@@ -180,16 +246,29 @@ def train(
|
|||||||
batch_gb = [np.zeros_like(b) for b in net.biases]
|
batch_gb = [np.zeros_like(b) for b in net.biases]
|
||||||
batch_count = 0
|
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:
|
if episode % 1000 == 0 or episode == n_episodes:
|
||||||
wr = sum(recent_outcomes) / len(recent_outcomes) if recent_outcomes else 0.0
|
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)
|
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:
|
if episode % save_every == 0:
|
||||||
net.save(save_path)
|
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)
|
net.save(save_path)
|
||||||
|
card_pick_net.save(cp_path)
|
||||||
wr = sum(recent_outcomes) / len(recent_outcomes) if recent_outcomes else 0.0
|
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%}")
|
print(f"Done. Final win rate (last {len(recent_outcomes)}): {wr:.1%}")
|
||||||
return net
|
return net
|
||||||
@@ -7,7 +7,7 @@ from sqlalchemy import engine_from_config
|
|||||||
from sqlalchemy import pool, create_engine
|
from sqlalchemy import pool, create_engine
|
||||||
|
|
||||||
from alembic import context
|
from alembic import context
|
||||||
from models import Base
|
from core.models import Base
|
||||||
|
|
||||||
import os
|
import os
|
||||||
from dotenv import load_dotenv
|
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
|
import logging
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
from jose import JWTError, jwt
|
from jose import JWTError, jwt
|
||||||
from passlib.context import CryptContext
|
from passlib.context import CryptContext
|
||||||
|
|
||||||
from config import JWT_SECRET_KEY
|
from core.config import JWT_SECRET_KEY
|
||||||
|
|
||||||
logger = logging.getLogger("app")
|
logger = logging.getLogger("app")
|
||||||
|
|
||||||
@@ -40,6 +41,8 @@ def decode_refresh_token(token: str) -> str | None:
|
|||||||
def decode_access_token(token: str) -> str | None:
|
def decode_access_token(token: str) -> str | None:
|
||||||
try:
|
try:
|
||||||
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
||||||
|
if payload.get("type") != "access":
|
||||||
|
return None
|
||||||
return payload.get("sub")
|
return payload.get("sub")
|
||||||
except JWTError:
|
except JWTError:
|
||||||
return None
|
return None
|
||||||
@@ -1,9 +1,14 @@
|
|||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine
|
||||||
from sqlalchemy.orm import DeclarativeBase, sessionmaker
|
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)
|
SessionLocal = sessionmaker(bind=engine)
|
||||||
|
|
||||||
class Base(DeclarativeBase):
|
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 datetime import datetime, timedelta
|
||||||
from time import sleep
|
from time import sleep
|
||||||
|
|
||||||
from config import WIKIRANK_USER_AGENT
|
from core.config import WIKIRANK_USER_AGENT
|
||||||
HEADERS = {"User-Agent": WIKIRANK_USER_AGENT}
|
HEADERS = {"User-Agent": WIKIRANK_USER_AGENT}
|
||||||
|
|
||||||
logger = logging.getLogger("app")
|
logger = logging.getLogger("app")
|
||||||
@@ -33,7 +33,7 @@ class CardRarity(Enum):
|
|||||||
|
|
||||||
class Card(NamedTuple):
|
class Card(NamedTuple):
|
||||||
name: str
|
name: str
|
||||||
created_at: datetime
|
generated_at: datetime
|
||||||
image_link: str
|
image_link: str
|
||||||
card_rarity: CardRarity
|
card_rarity: CardRarity
|
||||||
card_type: CardType
|
card_type: CardType
|
||||||
@@ -81,7 +81,7 @@ class Card(NamedTuple):
|
|||||||
return_string += "┃"+f"{l:{' '}<50}"+"┃\n"
|
return_string += "┃"+f"{l:{' '}<50}"+"┃\n"
|
||||||
return_string += "┠"+"─"*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}"
|
stats = f"{self.attack}/{self.defense}"
|
||||||
spaces = 50 - (len(date_text) + len(stats))
|
spaces = 50 - (len(date_text) + len(stats))
|
||||||
return_string += "┃"+date_text + " "*spaces + stats + "┃\n"
|
return_string += "┃"+date_text + " "*spaces + stats + "┃\n"
|
||||||
@@ -123,6 +123,7 @@ WIKIDATA_INSTANCE_TYPE_MAP = {
|
|||||||
"Q1446621": CardType.artwork, # recital
|
"Q1446621": CardType.artwork, # recital
|
||||||
"Q1868552": CardType.artwork, # local newspaper
|
"Q1868552": CardType.artwork, # local newspaper
|
||||||
"Q3244175": CardType.artwork, # tabletop game
|
"Q3244175": CardType.artwork, # tabletop game
|
||||||
|
"Q2031291": CardType.artwork, # musical release
|
||||||
"Q63952888": CardType.artwork, # anime television series
|
"Q63952888": CardType.artwork, # anime television series
|
||||||
"Q47461344": CardType.artwork, # written work
|
"Q47461344": CardType.artwork, # written work
|
||||||
"Q71631512": CardType.artwork, # tabletop role-playing game supplement
|
"Q71631512": CardType.artwork, # tabletop role-playing game supplement
|
||||||
@@ -167,6 +168,7 @@ WIKIDATA_INSTANCE_TYPE_MAP = {
|
|||||||
|
|
||||||
"Q198": CardType.event, # war
|
"Q198": CardType.event, # war
|
||||||
"Q8465": CardType.event, # civil war
|
"Q8465": CardType.event, # civil war
|
||||||
|
"Q844482": CardType.event, # killing
|
||||||
"Q141022": CardType.event, # eclipse
|
"Q141022": CardType.event, # eclipse
|
||||||
"Q103495": CardType.event, # world war
|
"Q103495": CardType.event, # world war
|
||||||
"Q350604": CardType.event, # armed conflict
|
"Q350604": CardType.event, # armed conflict
|
||||||
@@ -180,7 +182,7 @@ WIKIDATA_INSTANCE_TYPE_MAP = {
|
|||||||
"Q1361229": CardType.event, # conquest
|
"Q1361229": CardType.event, # conquest
|
||||||
"Q2223653": CardType.event, # terrorist attack
|
"Q2223653": CardType.event, # terrorist attack
|
||||||
"Q2672648": CardType.event, # social conflict
|
"Q2672648": CardType.event, # social conflict
|
||||||
"Q2627975": CardType.event, # ceremony
|
"Q2627975": CardType.event, # ceremony"
|
||||||
"Q16510064": CardType.event, # sporting event
|
"Q16510064": CardType.event, # sporting event
|
||||||
"Q10688145": CardType.event, # season
|
"Q10688145": CardType.event, # season
|
||||||
"Q13418847": CardType.event, # historical event
|
"Q13418847": CardType.event, # historical event
|
||||||
@@ -275,6 +277,7 @@ WIKIDATA_INSTANCE_TYPE_MAP = {
|
|||||||
"Q1428357": CardType.vehicle, # submarine class
|
"Q1428357": CardType.vehicle, # submarine class
|
||||||
"Q1499623": CardType.vehicle, # destroyer escort
|
"Q1499623": CardType.vehicle, # destroyer escort
|
||||||
"Q4818021": CardType.vehicle, # attack submarine
|
"Q4818021": CardType.vehicle, # attack submarine
|
||||||
|
"Q45296117": CardType.vehicle, # aircraft type
|
||||||
"Q15141321": CardType.vehicle, # train service
|
"Q15141321": CardType.vehicle, # train service
|
||||||
"Q19832486": CardType.vehicle, # locomotive class
|
"Q19832486": CardType.vehicle, # locomotive class
|
||||||
"Q23866334": CardType.vehicle, # motorcycle model
|
"Q23866334": CardType.vehicle, # motorcycle model
|
||||||
@@ -544,7 +547,7 @@ async def _get_card_async(client: httpx.AsyncClient, page_title: str|None = None
|
|||||||
|
|
||||||
return Card(
|
return Card(
|
||||||
name=summary["title"],
|
name=summary["title"],
|
||||||
created_at=datetime.now(),
|
generated_at=datetime.now(),
|
||||||
image_link=summary.get("thumbnail", {}).get("source", ""),
|
image_link=summary.get("thumbnail", {}).get("source", ""),
|
||||||
card_rarity=rarity,
|
card_rarity=rarity,
|
||||||
card_type=card_type,
|
card_type=card_type,
|
||||||
@@ -1,20 +1,21 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import uuid
|
|
||||||
from datetime import datetime
|
|
||||||
import logging
|
import logging
|
||||||
import random
|
import random
|
||||||
|
import uuid
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
from fastapi import WebSocket
|
from fastapi import WebSocket
|
||||||
|
from sqlalchemy.exc import SQLAlchemyError
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from game import (
|
from game.rules import (
|
||||||
GameState, CardInstance, PlayerState, action_play_card, action_sacrifice,
|
GameState, CardInstance, PlayerState, action_play_card, action_sacrifice,
|
||||||
action_end_turn, create_game, CombatEvent, GameResult, BOARD_SIZE
|
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 core.models import Card as CardModel, Deck as DeckModel, DeckCard as DeckCardModel, User as UserModel
|
||||||
from card import compute_deck_type
|
from game.card import compute_deck_type
|
||||||
from ai import AI_USER_ID, run_ai_turn, get_random_personality, choose_cards
|
from ai.engine import AI_USER_ID, run_ai_turn, get_random_personality, choose_cards
|
||||||
|
|
||||||
logger = logging.getLogger("app")
|
logger = logging.getLogger("app")
|
||||||
|
|
||||||
@@ -90,7 +91,9 @@ def serialize_card(card: CardInstance|None) -> dict | None:
|
|||||||
"card_type": card.card_type,
|
"card_type": card.card_type,
|
||||||
"card_rarity": card.card_rarity,
|
"card_rarity": card.card_rarity,
|
||||||
"image_link": card.image_link,
|
"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:
|
def serialize_player(player: PlayerState, hide_hand=False) -> dict:
|
||||||
@@ -150,8 +153,8 @@ async def broadcast_state(game_id: str):
|
|||||||
"type": "state",
|
"type": "state",
|
||||||
"state": serialize_state(state, user_id),
|
"state": serialize_state(state, user_id),
|
||||||
})
|
})
|
||||||
except Exception:
|
except Exception as e:
|
||||||
pass
|
logger.debug(f"WebSocket send failed (stale connection): {e}")
|
||||||
|
|
||||||
if state.active_player_id == AI_USER_ID and not state.result:
|
if state.active_player_id == AI_USER_ID and not state.result:
|
||||||
asyncio.create_task(run_ai_turn(game_id))
|
asyncio.create_task(run_ai_turn(game_id))
|
||||||
@@ -221,6 +224,33 @@ async def try_match(db: Session):
|
|||||||
await broadcast_state(state.game_id)
|
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
|
## Action handler
|
||||||
|
|
||||||
async def handle_action(game_id: str, user_id: str, message: dict, db: Session):
|
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:
|
if card:
|
||||||
card.times_played += 1
|
card.times_played += 1
|
||||||
db.commit()
|
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}")
|
logger.warning(f"Failed to increment times_played for card {card_instance.card_id}: {e}")
|
||||||
db.rollback()
|
db.rollback()
|
||||||
elif action == "sacrifice":
|
elif action == "sacrifice":
|
||||||
@@ -275,8 +305,8 @@ async def handle_action(game_id: str, user_id: str, message: dict, db: Session):
|
|||||||
"type": "sacrifice_animation",
|
"type": "sacrifice_animation",
|
||||||
"instance_id": card.instance_id,
|
"instance_id": card.instance_id,
|
||||||
})
|
})
|
||||||
except Exception:
|
except Exception as e:
|
||||||
pass
|
logger.debug(f"WebSocket send failed (stale connection): {e}")
|
||||||
await asyncio.sleep(0.65)
|
await asyncio.sleep(0.65)
|
||||||
err = action_sacrifice(state, slot)
|
err = action_sacrifice(state, slot)
|
||||||
elif action == "end_turn":
|
elif action == "end_turn":
|
||||||
@@ -325,7 +355,7 @@ async def handle_disconnect(game_id: str, user_id: str):
|
|||||||
)
|
)
|
||||||
state.phase = "end"
|
state.phase = "end"
|
||||||
|
|
||||||
from database import SessionLocal
|
from core.database import SessionLocal
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
record_game_result(state, db)
|
record_game_result(state, db)
|
||||||
@@ -340,8 +370,8 @@ async def handle_disconnect(game_id: str, user_id: str):
|
|||||||
"type": "state",
|
"type": "state",
|
||||||
"state": serialize_state(state, winner_id),
|
"state": serialize_state(state, winner_id),
|
||||||
})
|
})
|
||||||
except Exception:
|
except Exception as e:
|
||||||
pass
|
logger.debug(f"WebSocket send failed (stale connection): {e}")
|
||||||
|
|
||||||
active_deck_ids.pop(user_id, None)
|
active_deck_ids.pop(user_id, None)
|
||||||
active_deck_ids.pop(winner_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 random
|
||||||
import uuid
|
import uuid
|
||||||
|
from dataclasses import dataclass, field
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
from models import Card as CardModel
|
from core.models import Card as CardModel
|
||||||
|
|
||||||
STARTING_LIFE = 1000
|
STARTING_LIFE = 1000
|
||||||
MAX_ENERGY_CAP = 6
|
MAX_ENERGY_CAP = 6
|
||||||
@@ -24,6 +24,8 @@ class CardInstance:
|
|||||||
card_rarity: str
|
card_rarity: str
|
||||||
image_link: str
|
image_link: str
|
||||||
text: str
|
text: str
|
||||||
|
is_favorite: bool = False
|
||||||
|
willing_to_trade: bool = False
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_db_card(cls, card: CardModel) -> "CardInstance":
|
def from_db_card(cls, card: CardModel) -> "CardInstance":
|
||||||
@@ -38,7 +40,9 @@ class CardInstance:
|
|||||||
card_type=card.card_type,
|
card_type=card.card_type,
|
||||||
card_rarity=card.card_rarity,
|
card_rarity=card.card_rarity,
|
||||||
image_link=card.image_link or "",
|
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
|
@dataclass
|
||||||
@@ -8,15 +8,17 @@ Example:
|
|||||||
python give_card.py nikolaj "Marie Curie"
|
python give_card.py nikolaj "Marie Curie"
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import sys
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import sys
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
from database import SessionLocal
|
from game.card import _get_specific_card_async
|
||||||
from models import User as UserModel, Card as CardModel
|
from core.database import SessionLocal
|
||||||
from card import _get_specific_card_async
|
from core.models import User as UserModel, Card as CardModel
|
||||||
import uuid
|
|
||||||
|
|
||||||
|
|
||||||
async def main(username: str, page_title: str) -> None:
|
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,
|
attack=card.attack,
|
||||||
defense=card.defense,
|
defense=card.defense,
|
||||||
cost=card.cost,
|
cost=card.cost,
|
||||||
|
received_at=datetime.now(),
|
||||||
)
|
)
|
||||||
db.add(db_card)
|
db.add(db_card)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|||||||
+26
-818
@@ -1,846 +1,54 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import uuid
|
|
||||||
import re
|
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from datetime import datetime, timedelta
|
from typing import Callable, cast
|
||||||
from typing import cast, Callable
|
|
||||||
import secrets
|
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
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
|
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
|
stripe.api_key = STRIPE_SECRET_KEY
|
||||||
|
|
||||||
logger = logging.getLogger("app")
|
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
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
asyncio.create_task(fill_card_pool())
|
asyncio.create_task(fill_card_pool())
|
||||||
|
asyncio.create_task(run_cleanup_loop())
|
||||||
yield
|
yield
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(lifespan=lifespan)
|
app = FastAPI(lifespan=lifespan)
|
||||||
|
|
||||||
# Rate limiting
|
|
||||||
limiter = Limiter(key_func=get_remote_address)
|
|
||||||
app.state.limiter = limiter
|
app.state.limiter = limiter
|
||||||
app.add_exception_handler(RateLimitExceeded, cast(Callable, _rate_limit_exceeded_handler))
|
app.add_exception_handler(RateLimitExceeded, cast(Callable, _rate_limit_exceeded_handler))
|
||||||
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=CORS_ORIGINS, # SvelteKit's default dev port
|
allow_origins=CORS_ORIGINS,
|
||||||
allow_methods=["*"],
|
allow_methods=["*"],
|
||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
app.include_router(health.router)
|
||||||
from disposable_email_domains import blocklist as _disposable_blocklist
|
app.include_router(auth.router)
|
||||||
except ImportError:
|
app.include_router(cards.router)
|
||||||
_disposable_blocklist: set[str] = set()
|
app.include_router(decks.router)
|
||||||
|
app.include_router(games.router)
|
||||||
def validate_register(username: str, email: str, password: str) -> str | None:
|
app.include_router(notifications.router)
|
||||||
if not username.strip():
|
app.include_router(profile.router)
|
||||||
return "Username is required"
|
app.include_router(friends.router)
|
||||||
if len(username) > 16:
|
app.include_router(store.router)
|
||||||
return "Username must be 16 characters or fewer"
|
app.include_router(trades.router)
|
||||||
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")
|
|
||||||
|
|||||||
@@ -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
|
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):
|
def send_verification_email(to_email: str, username: str, token: str):
|
||||||
resend.api_key = RESEND_API_KEY
|
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 asyncio
|
||||||
import uuid
|
|
||||||
import logging
|
import logging
|
||||||
|
import uuid
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
from fastapi import WebSocket
|
from fastapi import WebSocket
|
||||||
from sqlalchemy.orm import Session
|
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")
|
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
|
## Storage
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -47,7 +87,10 @@ def serialize_card_model(card: CardModel) -> dict:
|
|||||||
"defense": card.defense,
|
"defense": card.defense,
|
||||||
"cost": card.cost,
|
"cost": card.cost,
|
||||||
"text": card.text,
|
"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:
|
def serialize_trade(session: TradeSession, perspective_user_id: str) -> dict:
|
||||||
@@ -76,8 +119,8 @@ async def broadcast_trade(session: TradeSession) -> None:
|
|||||||
"type": "state",
|
"type": "state",
|
||||||
"state": serialize_trade(session, user_id),
|
"state": serialize_trade(session, user_id),
|
||||||
})
|
})
|
||||||
except Exception:
|
except Exception as e:
|
||||||
pass
|
logger.debug(f"WebSocket send failed (stale connection): {e}")
|
||||||
|
|
||||||
## Matchmaking
|
## Matchmaking
|
||||||
|
|
||||||
@@ -108,8 +151,8 @@ async def try_trade_match() -> None:
|
|||||||
for entry in [p1, p2]:
|
for entry in [p1, p2]:
|
||||||
try:
|
try:
|
||||||
await entry.websocket.send_json({"type": "trade_start", "trade_id": trade_id})
|
await entry.websocket.send_json({"type": "trade_start", "trade_id": trade_id})
|
||||||
except Exception:
|
except Exception as e:
|
||||||
pass
|
logger.debug(f"WebSocket send failed (stale connection): {e}")
|
||||||
|
|
||||||
## Action handling
|
## Action handling
|
||||||
|
|
||||||
@@ -230,28 +273,17 @@ async def _complete_trade(trade_id: str, db: Session) -> None:
|
|||||||
"type": "error",
|
"type": "error",
|
||||||
"message": "Trade failed: ownership check failed. Offers have been reset.",
|
"message": "Trade failed: ownership check failed. Offers have been reset.",
|
||||||
})
|
})
|
||||||
except Exception:
|
except Exception as e:
|
||||||
pass
|
logger.debug(f"WebSocket send failed (stale connection): {e}")
|
||||||
for offer in session.offers.values():
|
for offer in session.offers.values():
|
||||||
offer.accepted = False
|
offer.accepted = False
|
||||||
await broadcast_trade(session)
|
await broadcast_trade(session)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Transfer ownership and clear deck relationships
|
# Transfer ownership and clear deck relationships
|
||||||
for cid_str in [c["id"] for c in cards_u1]:
|
now = datetime.now()
|
||||||
cid = uuid.UUID(cid_str)
|
transfer_cards(uuid.UUID(u1), uuid.UUID(u2), [uuid.UUID(c["id"]) for c in cards_u1], db, now)
|
||||||
card = db.query(CardModel).filter(CardModel.id == cid).first()
|
transfer_cards(uuid.UUID(u2), uuid.UUID(u1), [uuid.UUID(c["id"]) for c in cards_u2], db, now)
|
||||||
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()
|
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
active_trades.pop(trade_id, None)
|
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()):
|
for ws in list(session.connections.values()):
|
||||||
try:
|
try:
|
||||||
await ws.send_json({"type": "trade_complete"})
|
await ws.send_json({"type": "trade_complete"})
|
||||||
except Exception:
|
except Exception as e:
|
||||||
pass
|
logger.debug(f"WebSocket send failed (stale connection): {e}")
|
||||||
|
|
||||||
## Disconnect handling
|
## Disconnect handling
|
||||||
|
|
||||||
@@ -279,5 +311,5 @@ async def handle_trade_disconnect(trade_id: str, user_id: str) -> None:
|
|||||||
"type": "error",
|
"type": "error",
|
||||||
"message": "Your trade partner disconnected. Trade cancelled.",
|
"message": "Your trade partner disconnected. Trade cancelled.",
|
||||||
})
|
})
|
||||||
except Exception:
|
except Exception as e:
|
||||||
pass
|
logger.debug(f"WebSocket send failed (stale connection): {e}")
|
||||||
+11
-2
@@ -1,13 +1,14 @@
|
|||||||
|
import uuid
|
||||||
|
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
from game import (
|
from game.rules import (
|
||||||
GameState, PlayerState, CardInstance, CombatEvent, GameResult,
|
GameState, PlayerState, CardInstance, CombatEvent, GameResult,
|
||||||
create_game, resolve_combat, check_win_condition,
|
create_game, resolve_combat, check_win_condition,
|
||||||
action_play_card, action_sacrifice, action_end_turn,
|
action_play_card, action_sacrifice, action_end_turn,
|
||||||
BOARD_SIZE, HAND_SIZE, STARTING_LIFE, MAX_ENERGY_CAP,
|
BOARD_SIZE, HAND_SIZE, STARTING_LIFE, MAX_ENERGY_CAP,
|
||||||
)
|
)
|
||||||
import uuid
|
|
||||||
|
|
||||||
# ── Helpers ──────────────────────────────────────────────────────────────────
|
# ── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -79,6 +80,8 @@ class TestCreateGame:
|
|||||||
card_rarity = "common"
|
card_rarity = "common"
|
||||||
image_link = ""
|
image_link = ""
|
||||||
text = ""
|
text = ""
|
||||||
|
is_favorite = False
|
||||||
|
willing_to_trade = False
|
||||||
|
|
||||||
cards = [FakeCard() for _ in range(20)]
|
cards = [FakeCard() for _ in range(20)]
|
||||||
state = create_game("p1", "player 1", "test", cards, "p2", "player 2", "test", cards)
|
state = create_game("p1", "player 1", "test", cards, "p2", "player 2", "test", cards)
|
||||||
@@ -96,6 +99,8 @@ class TestCreateGame:
|
|||||||
card_rarity = "common"
|
card_rarity = "common"
|
||||||
image_link = ""
|
image_link = ""
|
||||||
text = ""
|
text = ""
|
||||||
|
is_favorite = False
|
||||||
|
willing_to_trade = False
|
||||||
|
|
||||||
cards = [FakeCard() for _ in range(20)]
|
cards = [FakeCard() for _ in range(20)]
|
||||||
state = create_game("p1", "player 1", "test", cards, "p2", "player 2", "test", cards)
|
state = create_game("p1", "player 1", "test", cards, "p2", "player 2", "test", cards)
|
||||||
@@ -113,6 +118,8 @@ class TestCreateGame:
|
|||||||
card_rarity = "common"
|
card_rarity = "common"
|
||||||
image_link = ""
|
image_link = ""
|
||||||
text = ""
|
text = ""
|
||||||
|
is_favorite = False
|
||||||
|
willing_to_trade = False
|
||||||
|
|
||||||
cards = [FakeCard() for _ in range(20)]
|
cards = [FakeCard() for _ in range(20)]
|
||||||
state = create_game("p1", "player 1", "test", cards, "p2", "player 2", "test", cards)
|
state = create_game("p1", "player 1", "test", cards, "p2", "player 2", "test", cards)
|
||||||
@@ -131,6 +138,8 @@ class TestCreateGame:
|
|||||||
card_rarity = "common"
|
card_rarity = "common"
|
||||||
image_link = ""
|
image_link = ""
|
||||||
text = ""
|
text = ""
|
||||||
|
is_favorite = False
|
||||||
|
willing_to_trade = False
|
||||||
|
|
||||||
cards = [FakeCard() for _ in range(20)]
|
cards = [FakeCard() for _ in range(20)]
|
||||||
state = create_game("p1", "player 1", "test", cards, "p2", "player 2", "test", cards)
|
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 {
|
*, *::before, *::after {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: var(--color-border) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background: #0d0a04;
|
background: var(--color-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
html, body {
|
html, body {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow: hidden;
|
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();
|
let { card, noHover = false, defenseOverride = null } = $props();
|
||||||
|
|
||||||
const RARITY_BADGE = {
|
const RARITY_BADGE = {
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
rare: { symbol: "R", label: "Rare", bg: "#2a5a9b", color: "#fff" },
|
rare: { symbol: "R", label: "Rare", bg: "#2a5a9b", color: "#fff" },
|
||||||
super_rare: { symbol: "SR", label: "Super Rare", bg: "#7a3a9b", color: "#fff" },
|
super_rare: { symbol: "SR", label: "Super Rare", bg: "#7a3a9b", color: "#fff" },
|
||||||
epic: { symbol: "E", label: "Epic", bg: "#9b3a3a", 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 = {
|
const TYPE_COLORS = {
|
||||||
@@ -26,13 +26,13 @@
|
|||||||
const FOIL_RARITIES = new Set(["super_rare", "epic", "legendary"]);
|
const FOIL_RARITIES = new Set(["super_rare", "epic", "legendary"]);
|
||||||
|
|
||||||
let rarity = $derived(card.card_rarity);
|
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 foil = $derived(FOIL_RARITIES.has(rarity))
|
||||||
let foilOffset = $derived(foil ? `${-(Math.random() * 5).toFixed(2)}s` : '0s');
|
let foilOffset = $derived(foil ? `${-(Math.random() * 5).toFixed(2)}s` : '0s');
|
||||||
let super_rare = $derived(rarity == "super_rare");
|
let super_rare = $derived(rarity == "super_rare");
|
||||||
let epic = $derived(rarity == "epic");
|
let epic = $derived(rarity == "epic");
|
||||||
let legendary = $derived(rarity === "legendary");
|
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 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, "_")));
|
let wikiUrl = $derived("https://en.wikipedia.org/wiki/" + encodeURIComponent(card.name.replace(/ /g, "_")));
|
||||||
</script>
|
</script>
|
||||||
@@ -47,7 +47,7 @@
|
|||||||
|
|
||||||
<div class="card-image-wrap">
|
<div class="card-image-wrap">
|
||||||
{#if card.image_link}
|
{#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}
|
{:else}
|
||||||
<div class="card-image-placeholder">
|
<div class="card-image-placeholder">
|
||||||
<span>{card.name[0]}</span>
|
<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>
|
<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">
|
<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">
|
<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"/>
|
<circle cx="25" cy="25" r="24" fill="white" stroke="#888" stroke-width="1"/>
|
||||||
@@ -76,7 +87,7 @@
|
|||||||
|
|
||||||
<div class="card-footer">
|
<div class="card-footer">
|
||||||
<span class="stat">ATK <strong>{card.attack}</strong></span>
|
<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>
|
<span class="stat">DEF <strong>{defenseOverride !== null ? defenseOverride : card.defense}</strong></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -84,15 +95,13 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<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 {
|
.card {
|
||||||
width: 300px;
|
width: 300px;
|
||||||
border-radius: 12px;
|
border-radius: var(--radius-xl);
|
||||||
padding: 7px;
|
padding: 7px;
|
||||||
background: #111;
|
background: #111;
|
||||||
border: 2px solid #111;
|
border: 2px solid #000;
|
||||||
box-shadow: 0 4px 24px rgba(0,0,0,0.5);
|
box-shadow: var(--shadow-card);
|
||||||
font-family: 'Crimson Text', serif;
|
font-family: 'Crimson Text', serif;
|
||||||
position: relative;
|
position: relative;
|
||||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||||
@@ -103,14 +112,14 @@
|
|||||||
|
|
||||||
.card:hover {
|
.card:hover {
|
||||||
transform: translateY(-4px) scale(1.02);
|
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 {
|
.card.foil::before {
|
||||||
content: "";
|
content: "";
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
border-radius: 12px;
|
border-radius: var(--radius-xl);
|
||||||
animation: foil-shift 2.5s ease-in-out infinite alternate;
|
animation: foil-shift 2.5s ease-in-out infinite alternate;
|
||||||
animation-delay: var(--foil-offset, 0s);
|
animation-delay: var(--foil-offset, 0s);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
@@ -182,7 +191,7 @@
|
|||||||
|
|
||||||
.card-name {
|
.card-name {
|
||||||
font-family: 'Cinzel', serif;
|
font-family: 'Cinzel', serif;
|
||||||
font-size: 13px;
|
font-size: var(--text-base);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
@@ -196,7 +205,7 @@
|
|||||||
|
|
||||||
.card-type-badge {
|
.card-type-badge {
|
||||||
font-family: 'Cinzel', serif;
|
font-family: 'Cinzel', serif;
|
||||||
font-size: 9px;
|
font-size: var(--text-xs);
|
||||||
color: rgba(255,255,255,0.95);
|
color: rgba(255,255,255,0.95);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
@@ -232,7 +241,7 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
background: #ddd;
|
background: #ddd;
|
||||||
font-family: 'Cinzel', serif;
|
font-family: 'Cinzel', serif;
|
||||||
font-size: 64px;
|
font-size: var(--text-3xl);
|
||||||
color: rgba(0,0,0,0.15);
|
color: rgba(0,0,0,0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -242,12 +251,12 @@
|
|||||||
left: 7px;
|
left: 7px;
|
||||||
width: 26px;
|
width: 26px;
|
||||||
height: 26px;
|
height: 26px;
|
||||||
border-radius: 50%;
|
border-radius: var(--radius-full);
|
||||||
background: var(--rb);
|
background: var(--rb);
|
||||||
border: 2.5px solid #000;
|
border: 2.5px solid #000;
|
||||||
color: var(--rc);
|
color: var(--rc);
|
||||||
font-family: 'Cinzel', serif;
|
font-family: 'Cinzel', serif;
|
||||||
font-size: 9px;
|
font-size: var(--text-xs);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -256,13 +265,44 @@
|
|||||||
letter-spacing: -0.02em;
|
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 {
|
.wiki-link {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 7px;
|
top: 7px;
|
||||||
right: 7px;
|
right: 7px;
|
||||||
width: 26px;
|
width: 26px;
|
||||||
height: 26px;
|
height: 26px;
|
||||||
border-radius: 50%;
|
border-radius: var(--radius-full);
|
||||||
background: rgba(255,255,255,0.92);
|
background: rgba(255,255,255,0.92);
|
||||||
border: 1.5px solid #000;
|
border: 1.5px solid #000;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -284,7 +324,7 @@
|
|||||||
|
|
||||||
.card-text {
|
.card-text {
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
font-size: 13px;
|
font-size: var(--text-base);
|
||||||
line-height: 1.55;
|
line-height: 1.55;
|
||||||
color: #1a1208;
|
color: #1a1208;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
@@ -304,21 +344,21 @@
|
|||||||
|
|
||||||
.stat {
|
.stat {
|
||||||
font-family: 'Cinzel', serif;
|
font-family: 'Cinzel', serif;
|
||||||
font-size: 11px;
|
font-size: var(--text-sm);
|
||||||
color: #2a2010;
|
color: #2a2010;
|
||||||
letter-spacing: 0.03em;
|
letter-spacing: 0.03em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat strong {
|
.stat strong {
|
||||||
color: #000;
|
color: #000;
|
||||||
font-size: 15px;
|
font-size: var(--text-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-date {
|
.card-date {
|
||||||
font-size: 10px;
|
font-size: var(--text-sm);
|
||||||
color: rgba(0,0,0,0.5);
|
color: rgba(0,0,0,0.4);
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
font-family: 'Crimson Text', serif;
|
font-family: 'Cinzel', serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cost-bubbles {
|
.cost-bubbles {
|
||||||
@@ -335,15 +375,15 @@
|
|||||||
.cost-bubble {
|
.cost-bubble {
|
||||||
width: 16px;
|
width: 16px;
|
||||||
height: 16px;
|
height: 16px;
|
||||||
border-radius: 50%;
|
border-radius: var(--radius-full);
|
||||||
background: #6ea0ec;
|
background: var(--color-energy);
|
||||||
border: 2.5px solid #000;
|
border: 2.5px solid #000;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
color: #08152c;
|
color: #08152c;
|
||||||
font-size: 12px;
|
font-size: var(--text-sm);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-family: 'Cinzel', serif;
|
font-family: 'Cinzel', serif;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
@@ -351,6 +391,6 @@
|
|||||||
|
|
||||||
.card.no-hover:hover {
|
.card.no-hover:hover {
|
||||||
transform: none;
|
transform: none;
|
||||||
box-shadow: 0 4px 24px rgba(0,0,0,0.5);
|
box-shadow: var(--shadow-card);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -1,26 +1,24 @@
|
|||||||
<script>
|
<script lang="ts">
|
||||||
import Card from '$lib/Card.svelte';
|
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 RARITIES = ['common', 'uncommon', 'rare', 'super_rare', 'epic', 'legendary'];
|
||||||
const TYPES = ['person', 'location', 'artwork', 'life_form', 'event', 'group', 'science_thing', 'vehicle', 'organization', 'other'];
|
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 {
|
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()),
|
selectedIds = $bindable(new Set()),
|
||||||
|
selectedCards = $bindable([]),
|
||||||
|
selectedCost = $bindable(0),
|
||||||
|
costMap = $bindable(new Map()),
|
||||||
inDeckIds = new Set(),
|
inDeckIds = new Set(),
|
||||||
onclose = null,
|
onclose = null,
|
||||||
costLimit = null, // if set, prevents selecting cards that would exceed it
|
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)
|
showFooter = true, // set false to hide the Done button (e.g. inline deck builder)
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
const selectedCost = $derived(
|
function label(str: string) {
|
||||||
costLimit !== null
|
|
||||||
? allCards.filter(c => selectedIds.has(c.id)).reduce((sum, c) => sum + c.cost, 0)
|
|
||||||
: 0
|
|
||||||
);
|
|
||||||
|
|
||||||
function label(str) {
|
|
||||||
return str.charAt(0).toUpperCase() + str.slice(1).replace(/_/g, ' ');
|
return str.charAt(0).toUpperCase() + str.slice(1).replace(/_/g, ' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,40 +30,159 @@
|
|||||||
let costMax = $state(10);
|
let costMax = $state(10);
|
||||||
let filtersOpen = $state(false);
|
let filtersOpen = $state(false);
|
||||||
let searchQuery = $state('');
|
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();
|
const q = searchQuery.trim().toLowerCase();
|
||||||
let result = allCards.filter(c =>
|
let result = staticCards.filter((c: any) =>
|
||||||
selectedRarities.has(c.card_rarity) &&
|
selectedRarities.has(c.card_rarity) &&
|
||||||
selectedTypes.has(c.card_type) &&
|
selectedTypes.has(c.card_type) &&
|
||||||
c.cost >= costMin &&
|
c.cost >= costMin &&
|
||||||
c.cost <= costMax &&
|
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;
|
let cmp = 0;
|
||||||
if (sortBy === 'name') cmp = a.name.localeCompare(b.name);
|
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 === '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 === '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 === '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 === '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 sortAsc ? cmp : -cmp;
|
||||||
});
|
});
|
||||||
return result;
|
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;
|
if (sortBy === val) sortAsc = !sortAsc;
|
||||||
else { sortBy = val; sortAsc = true; }
|
else { sortBy = val; sortAsc = true; }
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleRarity(r) {
|
function toggleRarity(r: string) {
|
||||||
const s = new Set(selectedRarities);
|
const s = new Set(selectedRarities);
|
||||||
s.has(r) ? s.delete(r) : s.add(r);
|
s.has(r) ? s.delete(r) : s.add(r);
|
||||||
selectedRarities = s;
|
selectedRarities = s;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleType(t) {
|
function toggleType(t: string) {
|
||||||
const s = new Set(selectedTypes);
|
const s = new Set(selectedTypes);
|
||||||
s.has(t) ? s.delete(t) : s.add(t);
|
s.has(t) ? s.delete(t) : s.add(t);
|
||||||
selectedTypes = s;
|
selectedTypes = s;
|
||||||
@@ -76,14 +193,17 @@
|
|||||||
function toggleAllRarities() { selectedRarities = allRaritiesSelected() ? new Set() : new Set(RARITIES); }
|
function toggleAllRarities() { selectedRarities = allRaritiesSelected() ? new Set() : new Set(RARITIES); }
|
||||||
function toggleAllTypes() { selectedTypes = allTypesSelected() ? new Set() : new Set(TYPES); }
|
function toggleAllTypes() { selectedTypes = allTypesSelected() ? new Set() : new Set(TYPES); }
|
||||||
|
|
||||||
function toggleCard(id) {
|
function toggleCard(id: string | number) {
|
||||||
const s = new Set(selectedIds);
|
const s = new Set(selectedIds);
|
||||||
if (s.has(id)) {
|
if (s.has(id)) {
|
||||||
s.delete(id);
|
s.delete(id);
|
||||||
} else {
|
} else {
|
||||||
if (costLimit !== null) {
|
const card = cards.find((c: any) => c.id === id);
|
||||||
const card = allCards.find(c => c.id === id);
|
if (costLimit !== null && card && selectedCost + card.cost > costLimit) return;
|
||||||
if (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);
|
s.add(id);
|
||||||
}
|
}
|
||||||
@@ -101,7 +221,7 @@
|
|||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<div class="sort-row">
|
<div class="sort-row">
|
||||||
<span class="toolbar-label">Sort by</span>
|
<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)}>
|
<button class="sort-btn" class:active={sortBy === val} onclick={() => toggleSort(val)}>
|
||||||
{lbl}
|
{lbl}
|
||||||
{#if sortBy === val}<span class="sort-arrow">{sortAsc ? '↑' : '↓'}</span>{/if}
|
{#if sortBy === val}<span class="sort-arrow">{sortAsc ? '↑' : '↓'}</span>{/if}
|
||||||
@@ -115,12 +235,21 @@
|
|||||||
bind:value={searchQuery}
|
bind:value={searchQuery}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<button class="filter-toggle" onclick={() => filtersOpen = !filtersOpen}>
|
<div class="filter-actions">
|
||||||
{filtersOpen ? 'Hide filters' : 'Filter'}
|
<button
|
||||||
{#if selectedRarities.size < RARITIES.length || selectedTypes.size < TYPES.length || costMin > 1 || costMax < 10}
|
class="filter-toggle"
|
||||||
<span class="filter-dot"></span>
|
class:active={willingToTradeOnly}
|
||||||
{/if}
|
onclick={() => willingToTradeOnly = !willingToTradeOnly}
|
||||||
</button>
|
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>
|
</div>
|
||||||
|
|
||||||
{#if filtersOpen}
|
{#if filtersOpen}
|
||||||
@@ -171,36 +300,42 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if filtered.length === 0}
|
{#if !loadingMore && cards.length === 0}
|
||||||
<p class="status">No cards match your filters.</p>
|
<p class="status">No cards match your filters.</p>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="grid">
|
<div class="grid" bind:this={gridEl}>
|
||||||
{#each filtered as card (card.id)}
|
{#each cards as card (card.id)}
|
||||||
<button
|
<div class="card-item">
|
||||||
class="card-wrap"
|
<button
|
||||||
class:selected={selectedIds.has(card.id)}
|
class="card-wrap"
|
||||||
class:disabled={costLimit !== null && !selectedIds.has(card.id) && selectedCost + card.cost > costLimit}
|
class:selected={selectedIds.has(card.id)}
|
||||||
onclick={() => toggleCard(card.id)}
|
class:disabled={costLimit !== null && !selectedIds.has(card.id) && selectedCost + card.cost > costLimit}
|
||||||
>
|
onclick={() => toggleCard(card.id)}
|
||||||
<Card {card} noHover={true} />
|
>
|
||||||
{#if selectedIds.has(card.id)}
|
<Card {card} noHover={true} />
|
||||||
<div class="selected-badge">✓</div>
|
{#if selectedIds.has(card.id)}
|
||||||
|
<div class="selected-badge">✓</div>
|
||||||
|
{/if}
|
||||||
|
{#if inDeckIds.has(card.id)}
|
||||||
|
<div class="in-deck-badge">⊞</div>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{#if sortBy === 'date_received' && card.received_at}
|
||||||
|
<span class="received-label">Received {new Date(card.received_at).toLocaleDateString('en-GB', { year: 'numeric', month: '2-digit', day: '2-digit' })}</span>
|
||||||
{/if}
|
{/if}
|
||||||
{#if inDeckIds.has(card.id)}
|
</div>
|
||||||
<div class="in-deck-badge" title="In a deck">⊞</div>
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
{/each}
|
{/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>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<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 {
|
.selector {
|
||||||
background: #0d0a04;
|
background: var(--color-bg);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -211,7 +346,7 @@
|
|||||||
.toolbar {
|
.toolbar {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
padding: 1.5rem 2rem 1rem;
|
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;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
@@ -226,23 +361,23 @@
|
|||||||
|
|
||||||
.search-input {
|
.search-input {
|
||||||
font-family: 'Crimson Text', serif;
|
font-family: 'Crimson Text', serif;
|
||||||
font-size: 15px;
|
font-size: var(--text-md);
|
||||||
background: rgba(255,255,255,0.04);
|
background: var(--color-surface);
|
||||||
border: 1px solid rgba(107, 76, 30, 0.4);
|
border: 1.5px solid var(--color-bronze);
|
||||||
border-radius: 4px;
|
border-radius: var(--radius-md);
|
||||||
color: #f0d080;
|
color: var(--color-gold);
|
||||||
padding: 5px 10px;
|
padding: 5px 10px;
|
||||||
outline: none;
|
outline: none;
|
||||||
width: 220px;
|
width: 220px;
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
transition: border-color 0.15s;
|
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); }
|
.search-input::placeholder { color: var(--color-gold-faint); }
|
||||||
|
|
||||||
.toolbar-label {
|
.toolbar-label {
|
||||||
font-family: 'Cinzel', serif;
|
font-family: 'Cinzel', serif;
|
||||||
font-size: 11px;
|
font-size: var(--text-sm);
|
||||||
color: rgba(240, 180, 80, 0.5);
|
color: rgba(240, 180, 80, 0.5);
|
||||||
letter-spacing: 0.08em;
|
letter-spacing: 0.08em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -251,41 +386,47 @@
|
|||||||
|
|
||||||
.sort-btn {
|
.sort-btn {
|
||||||
font-family: 'Cinzel', serif;
|
font-family: 'Cinzel', serif;
|
||||||
font-size: 11px;
|
font-size: var(--btn-font-sm);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.06em;
|
letter-spacing: 0.06em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
background: none;
|
background: none;
|
||||||
border: 1px solid rgba(107, 76, 30, 0.4);
|
border: 1px solid var(--color-border-subtle);
|
||||||
border-radius: 4px;
|
border-radius: var(--radius-sm);
|
||||||
color: rgba(240, 180, 80, 0.6);
|
color: var(--color-gold-dim);
|
||||||
padding: 4px 10px;
|
padding: var(--btn-padding-sm);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.15s;
|
transition: all 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sort-btn:hover { border-color: #c8861a; color: #f0d080; }
|
.sort-btn:hover { border-color: var(--color-bronze); color: var(--color-gold); }
|
||||||
.sort-btn.active { background: #3d2507; border-color: #c8861a; color: #f0d080; }
|
.sort-btn.active { background: var(--color-surface-raised); border-color: var(--color-bronze); color: var(--color-gold); }
|
||||||
.sort-arrow { font-size: 10px; margin-left: 3px; }
|
.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 {
|
.filter-toggle {
|
||||||
font-family: 'Cinzel', serif;
|
font-family: 'Cinzel', serif;
|
||||||
font-size: 11px;
|
font-size: var(--btn-font-sm);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.06em;
|
letter-spacing: 0.06em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
background: none;
|
background: none;
|
||||||
border: 1px solid rgba(107, 76, 30, 0.4);
|
border: 1px solid var(--color-border-subtle);
|
||||||
border-radius: 4px;
|
border-radius: var(--radius-sm);
|
||||||
color: rgba(240, 180, 80, 0.6);
|
color: var(--color-gold-dim);
|
||||||
padding: 4px 10px;
|
padding: var(--btn-padding-sm);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
margin-left: 0.5rem;
|
|
||||||
position: relative;
|
position: relative;
|
||||||
transition: all 0.15s;
|
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 {
|
.filter-dot {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -293,8 +434,8 @@
|
|||||||
right: -3px;
|
right: -3px;
|
||||||
width: 7px;
|
width: 7px;
|
||||||
height: 7px;
|
height: 7px;
|
||||||
border-radius: 50%;
|
border-radius: var(--radius-full);
|
||||||
background: #c8861a;
|
background: var(--color-bronze);
|
||||||
}
|
}
|
||||||
|
|
||||||
.filters {
|
.filters {
|
||||||
@@ -309,7 +450,7 @@
|
|||||||
|
|
||||||
.filter-group-label {
|
.filter-group-label {
|
||||||
font-family: 'Cinzel', serif;
|
font-family: 'Cinzel', serif;
|
||||||
font-size: 11px;
|
font-size: var(--text-sm);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.08em;
|
letter-spacing: 0.08em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -318,7 +459,7 @@
|
|||||||
|
|
||||||
.select-all {
|
.select-all {
|
||||||
font-family: 'Crimson Text', serif;
|
font-family: 'Crimson Text', serif;
|
||||||
font-size: 12px;
|
font-size: var(--text-sm);
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
@@ -329,7 +470,7 @@
|
|||||||
transition: color 0.15s;
|
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; }
|
.checkboxes { display: flex; flex-wrap: wrap; gap: 0.4rem 1rem; }
|
||||||
|
|
||||||
@@ -338,13 +479,13 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
font-family: 'Crimson Text', serif;
|
font-family: 'Crimson Text', serif;
|
||||||
font-size: 14px;
|
font-size: var(--text-base);
|
||||||
color: rgba(240, 180, 80, 0.8);
|
color: var(--color-gold-muted);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.checkbox-label input {
|
.checkbox-label input {
|
||||||
accent-color: #c8861a;
|
accent-color: var(--color-bronze);
|
||||||
width: 14px;
|
width: 14px;
|
||||||
height: 14px;
|
height: 14px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -354,13 +495,13 @@
|
|||||||
|
|
||||||
.range-label {
|
.range-label {
|
||||||
font-family: 'Cinzel', serif;
|
font-family: 'Cinzel', serif;
|
||||||
font-size: 11px;
|
font-size: var(--text-sm);
|
||||||
color: rgba(240, 180, 80, 0.7);
|
color: rgba(240, 180, 80, 0.7);
|
||||||
min-width: 60px;
|
min-width: 60px;
|
||||||
}
|
}
|
||||||
|
|
||||||
input[type=range] {
|
input[type=range] {
|
||||||
accent-color: #c8861a;
|
accent-color: var(--color-bronze);
|
||||||
width: 160px;
|
width: 160px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -374,22 +515,46 @@
|
|||||||
padding: 2rem 2rem 0;
|
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 {
|
.card-wrap {
|
||||||
all: unset;
|
all: unset;
|
||||||
position: relative;
|
position: relative;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
display: block;
|
display: block;
|
||||||
border-radius: 12px;
|
border-radius: var(--radius-xl);
|
||||||
transition: transform 0.15s, box-shadow 0.15s;
|
transition: transform 0.15s, box-shadow 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-wrap:hover {
|
.card-wrap:hover {
|
||||||
transform: translateY(-4px) scale(1.02);
|
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 {
|
.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 {
|
.card-wrap.disabled {
|
||||||
@@ -402,69 +567,104 @@
|
|||||||
top: 80px;
|
top: 80px;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
background: #c8861a;
|
background: var(--color-bronze);
|
||||||
color: #fff8e0;
|
color: var(--color-btn-text);
|
||||||
font-family: 'Cinzel', serif;
|
font-family: 'Cinzel', serif;
|
||||||
font-size: 23.875px;
|
font-size: var(--text-xl);
|
||||||
font-weight: 1000;
|
font-weight: 900;
|
||||||
padding: 4px 10px;
|
padding: 4px 10px;
|
||||||
border-radius: 23px;
|
border-radius: 23px;
|
||||||
border: black 3px solid;
|
border: black 3px solid;
|
||||||
pointer-events: none;
|
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 {
|
.in-deck-badge {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 6px;
|
top: 6px;
|
||||||
right: 6px;
|
right: 6px;
|
||||||
background: rgba(13, 8, 2, 0.75);
|
background: #000;
|
||||||
color: #7ecfcf;
|
color: #7ecfcf;
|
||||||
font-size: 16px;
|
font-size: var(--text-lg);
|
||||||
line-height: 1;
|
font-weight: 700;
|
||||||
padding: 3px 5px;
|
width: 34px;
|
||||||
border-radius: 6px;
|
height: 34px;
|
||||||
border: 1px solid rgba(126, 207, 207, 0.5);
|
padding: 0;
|
||||||
pointer-events: none;
|
border-radius: var(--radius-md);
|
||||||
z-index: 10;
|
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 {
|
.status {
|
||||||
font-family: 'Crimson Text', serif;
|
font-family: 'Crimson Text', serif;
|
||||||
font-size: 16px;
|
font-size: var(--text-md);
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
color: rgba(240, 180, 80, 0.5);
|
color: rgba(240, 180, 80, 0.5);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin-top: 4rem;
|
margin-top: 4rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.loading-more {
|
||||||
|
flex-basis: 100%;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
.top-bar {
|
.top-bar {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 1rem 2rem;
|
padding: 1rem 2rem;
|
||||||
border-bottom: 1px solid rgba(107, 76, 30, 0.3);
|
border-bottom: 1px solid var(--color-border-dim);
|
||||||
background: #0d0a04;
|
background: var(--color-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.counter {
|
.counter {
|
||||||
font-family: 'Cinzel', serif;
|
font-family: 'Cinzel', serif;
|
||||||
font-size: 13px;
|
font-size: var(--text-base);
|
||||||
color: rgba(240, 180, 80, 0.6);
|
color: var(--color-gold-dim);
|
||||||
}
|
}
|
||||||
|
|
||||||
.done-btn {
|
.done-btn {
|
||||||
font-family: 'Cinzel', serif;
|
font-family: 'Cinzel', serif;
|
||||||
font-size: 11px;
|
font-size: var(--btn-font-md);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.06em;
|
letter-spacing: 0.06em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
background: #3d2507;
|
background: var(--color-surface-raised);
|
||||||
border: 1px solid #c8861a;
|
border: 1px solid var(--color-bronze);
|
||||||
border-radius: 4px;
|
border-radius: var(--radius-md);
|
||||||
color: #f0d080;
|
color: var(--color-gold);
|
||||||
padding: 8px 24px;
|
padding: var(--btn-padding-md);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.15s;
|
transition: background 0.15s;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,11 +11,12 @@
|
|||||||
<style>
|
<style>
|
||||||
.type-badge {
|
.type-badge {
|
||||||
font-family: 'Cinzel', serif;
|
font-family: 'Cinzel', serif;
|
||||||
font-size: 10px;
|
font-size: var(--text-xs);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
padding: 3px 8px;
|
padding: 3px 8px;
|
||||||
border-radius: 3px;
|
white-space: nowrap;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
cursor: default;
|
cursor: default;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
let isRefreshing = false;
|
let isRefreshing = false;
|
||||||
|
/** @type {Promise<string> | null} */
|
||||||
let refreshPromise = null;
|
let refreshPromise = null;
|
||||||
import { PUBLIC_API_URL } from '$env/static/public';
|
import { PUBLIC_API_URL } from '$env/static/public';
|
||||||
export const API_URL = PUBLIC_API_URL;
|
export const API_URL = PUBLIC_API_URL;
|
||||||
@@ -27,6 +28,10 @@ async function refreshTokens() {
|
|||||||
return data.access_token;
|
return data.access_token;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} url
|
||||||
|
* @param {RequestInit} [options]
|
||||||
|
*/
|
||||||
export async function apiFetch(url, options = {}) {
|
export async function apiFetch(url, options = {}) {
|
||||||
const token = localStorage.getItem('token');
|
const token = localStorage.getItem('token');
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
const cache = {};
|
||||||
|
|
||||||
|
const FILES = {
|
||||||
|
cardFlip: '/sounds/card-flip.mp3',
|
||||||
|
packOpen: '/sounds/pack-open.mp3',
|
||||||
|
packRip: '/sounds/pack-rip.mp3',
|
||||||
|
cardPlay: '/sounds/card-play.mp3',
|
||||||
|
attack: '/sounds/attack.mp3',
|
||||||
|
defend: '/sounds/defend.mp3',
|
||||||
|
cardDestroy: '/sounds/card-destroy.mp3',
|
||||||
|
cardShatter: '/sounds/card-shatter.mp3',
|
||||||
|
win: '/sounds/win.mp3',
|
||||||
|
loss: '/sounds/loss.mp3',
|
||||||
|
buttonClick: '/sounds/button-click.mp3',
|
||||||
|
};
|
||||||
|
|
||||||
|
const VOLUMES = {
|
||||||
|
cardShatter: 0.1,
|
||||||
|
attack: 0.1,
|
||||||
|
defend: 0.1,
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_VOLUME = 0.3;
|
||||||
|
|
||||||
|
export function play(name) {
|
||||||
|
if (!FILES[name]) return;
|
||||||
|
if (!cache[name]) cache[name] = new Audio(FILES[name]);
|
||||||
|
// cloneNode allows the same sound to overlap itself
|
||||||
|
const audio = cache[name].cloneNode();
|
||||||
|
audio.volume = VOLUMES[name] ?? DEFAULT_VOLUME;
|
||||||
|
audio.play().catch(() => {});
|
||||||
|
}
|
||||||
+803
-35
@@ -1,21 +1,224 @@
|
|||||||
<script>
|
<script>
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import { goto } from '$app/navigation';
|
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 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 = [
|
const links = [
|
||||||
{ href: '/', label: 'Booster Packs' },
|
{ href: '/', label: 'Booster Packs' },
|
||||||
{ href: '/cards', label: 'Cards' },
|
{ href: '/cards', label: 'Cards' },
|
||||||
{ href: '/decks', label: 'Decks' },
|
{ href: '/decks', label: 'Decks' },
|
||||||
{ href: '/play', label: 'Play' },
|
{ href: '/play', label: 'Play' },
|
||||||
{ href: '/trade', label: 'Trade' },
|
];
|
||||||
{ href: '/store', label: 'Store' },
|
|
||||||
|
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 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>
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onclick={handleWindowClick} />
|
||||||
|
|
||||||
<header>
|
<header>
|
||||||
<a href="/" class="logo" onclick={close}>WikiTCG</a>
|
<a href="/" class="logo" onclick={close}>WikiTCG</a>
|
||||||
|
|
||||||
@@ -23,7 +226,168 @@
|
|||||||
{#each links as link}
|
{#each links as link}
|
||||||
<a href={link.href} class:active={$page.url.pathname === link.href}>{link.label}</a>
|
<a href={link.href} class:active={$page.url.pathname === link.href}>{link.label}</a>
|
||||||
{/each}
|
{/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>
|
</nav>
|
||||||
|
|
||||||
<button class="hamburger" onclick={() => menuOpen = !menuOpen} aria-label="Toggle menu">
|
<button class="hamburger" onclick={() => menuOpen = !menuOpen} aria-label="Toggle menu">
|
||||||
@@ -39,56 +403,460 @@
|
|||||||
{#each links as link}
|
{#each links as link}
|
||||||
<a href={link.href} class:active={$page.url.pathname === link.href} onclick={close}>{link.label}</a>
|
<a href={link.href} class:active={$page.url.pathname === link.href} onclick={close}>{link.label}</a>
|
||||||
{/each}
|
{/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>
|
</nav>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<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 {
|
header {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
z-index: 100;
|
z-index: var(--z-header);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 0 2rem;
|
padding: 0 var(--space-xl);
|
||||||
height: 56px;
|
height: 56px;
|
||||||
background: #1a1008;
|
background: var(--color-surface);
|
||||||
border-bottom: 2px solid #6b4c1e;
|
border-bottom: 2px solid var(--color-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo {
|
.logo {
|
||||||
font-family: 'Cinzel', serif;
|
font-family: 'Cinzel', serif;
|
||||||
font-size: 18px;
|
font-size: var(--text-lg);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #f0d080;
|
color: var(--color-gold);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
}
|
}
|
||||||
|
|
||||||
nav.desktop {
|
nav.desktop {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 2rem;
|
align-items: center;
|
||||||
|
gap: var(--space-lg);
|
||||||
}
|
}
|
||||||
|
|
||||||
nav.desktop a {
|
nav.desktop > a {
|
||||||
font-family: 'Cinzel', serif;
|
font-family: 'Cinzel', serif;
|
||||||
font-size: 13px;
|
font-size: var(--text-base);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.08em;
|
letter-spacing: 0.08em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: rgba(240, 180, 80, 0.8);
|
color: var(--color-gold-muted);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
transition: color 0.15s;
|
transition: color 0.15s;
|
||||||
padding: 4px 0;
|
padding: 4px 0;
|
||||||
border-bottom: 1.5px solid transparent;
|
border-bottom: 1.5px solid transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
nav.desktop a:hover,
|
nav.desktop > a:hover,
|
||||||
nav.desktop a.active {
|
nav.desktop > a.active {
|
||||||
color: #f0d080;
|
color: var(--color-gold);
|
||||||
border-bottom-color: #f0d080;
|
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 {
|
.hamburger {
|
||||||
@@ -106,7 +874,7 @@
|
|||||||
display: block;
|
display: block;
|
||||||
width: 22px;
|
width: 22px;
|
||||||
height: 2px;
|
height: 2px;
|
||||||
background: #f0d080;
|
background: var(--color-gold);
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
transition: transform 0.2s, opacity 0.2s;
|
transition: transform 0.2s, opacity 0.2s;
|
||||||
}
|
}
|
||||||
@@ -118,8 +886,8 @@
|
|||||||
.mobile-backdrop {
|
.mobile-backdrop {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 56px 0 0 0;
|
inset: 56px 0 0 0;
|
||||||
z-index: 99;
|
z-index: var(--z-header);
|
||||||
background: rgba(0,0,0,0.5);
|
background: var(--color-overlay);
|
||||||
}
|
}
|
||||||
|
|
||||||
nav.mobile {
|
nav.mobile {
|
||||||
@@ -128,34 +896,34 @@
|
|||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
width: 240px;
|
width: 240px;
|
||||||
z-index: 100;
|
z-index: var(--z-header);
|
||||||
background: #1a1008;
|
background: var(--color-surface);
|
||||||
border-left: 2px solid #6b4c1e;
|
border-left: 2px solid var(--color-border);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
padding: 1.5rem;
|
padding: var(--space-lg);
|
||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
nav.mobile a {
|
nav.mobile a {
|
||||||
font-family: 'Cinzel', serif;
|
font-family: 'Cinzel', serif;
|
||||||
font-size: 13px;
|
font-size: var(--text-base);
|
||||||
letter-spacing: 0.08em;
|
letter-spacing: 0.08em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: rgba(240, 180, 80, 0.6);
|
color: var(--color-gold-dim);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
padding: 0.75rem 0;
|
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;
|
transition: color 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
nav.mobile a:hover,
|
nav.mobile a:hover,
|
||||||
nav.mobile a.active {
|
nav.mobile a.active {
|
||||||
color: #f0d080;
|
color: var(--color-gold);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
nav.desktop { display: none; }
|
nav.desktop { display: none; }
|
||||||
.hamburger { display: flex; }
|
.hamburger { display: flex; }
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
<script>
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
|
||||||
|
$: is404 = $page.status === 404;
|
||||||
|
$: title = is404 ? 'Page Not Found' : 'Something Went Wrong';
|
||||||
|
$: message = is404 ? null : ($page.error?.message || 'An unexpected error occurred. Please try again.');
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="error-page">
|
||||||
|
<div class="card-ghost" aria-hidden="true"></div>
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
<div class="rune-divider" aria-hidden="true">
|
||||||
|
<span class="rune">⬡</span>
|
||||||
|
<span class="line"></span>
|
||||||
|
<span class="rune">⬡</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="status-code">{$page.status}</p>
|
||||||
|
<h1 class="title">{title}</h1>
|
||||||
|
{#if message}<p class="message">{message}</p>{/if}
|
||||||
|
|
||||||
|
<a href="/" class="btn-home">Return Home</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.error-page {
|
||||||
|
min-height: calc(100vh - 56px);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 40px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ghost card decorative background element */
|
||||||
|
.card-ghost {
|
||||||
|
position: absolute;
|
||||||
|
right: 12%;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%) rotate(8deg);
|
||||||
|
width: 180px;
|
||||||
|
height: 260px;
|
||||||
|
border: 2px solid #f0d080;
|
||||||
|
border-radius: 10px;
|
||||||
|
opacity: 0.04;
|
||||||
|
pointer-events: none;
|
||||||
|
animation: ghost-float 6s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes ghost-float {
|
||||||
|
0%, 100% { transform: translateY(-50%) rotate(8deg); }
|
||||||
|
50% { transform: translateY(calc(-50% - 12px)) rotate(8deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Second ghost card, mirror left */
|
||||||
|
.card-ghost::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: calc(-100% - 60vw + 180px);
|
||||||
|
top: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border: 2px solid #f0d080;
|
||||||
|
border-radius: 10px;
|
||||||
|
transform: rotate(-12deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
max-width: 480px;
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rune-divider {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rune {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: rgba(240, 180, 80, 0.3);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line {
|
||||||
|
flex: 1;
|
||||||
|
height: 1px;
|
||||||
|
background: rgba(107, 76, 30, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-code {
|
||||||
|
font-family: 'Cinzel', serif;
|
||||||
|
font-size: clamp(6rem, 18vw, 9rem);
|
||||||
|
font-weight: 900;
|
||||||
|
color: #f0d080;
|
||||||
|
line-height: 0.9;
|
||||||
|
margin: 0 0 12px;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
/* Faint inner glow */
|
||||||
|
text-shadow:
|
||||||
|
0 0 60px rgba(200, 134, 26, 0.2),
|
||||||
|
0 0 120px rgba(200, 134, 26, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-family: 'Cinzel', serif;
|
||||||
|
font-size: clamp(1rem, 3.5vw, 1.4rem);
|
||||||
|
font-weight: 700;
|
||||||
|
color: rgba(240, 180, 80, 0.7);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
margin: 0 0 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message {
|
||||||
|
font-family: 'Crimson Text', serif;
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
font-style: italic;
|
||||||
|
color: rgba(240, 180, 80, 0.5);
|
||||||
|
line-height: 1.6;
|
||||||
|
margin: 0 0 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-home {
|
||||||
|
display: inline-block;
|
||||||
|
background: #c8861a;
|
||||||
|
color: #fff8e0;
|
||||||
|
font-family: 'Cinzel', serif;
|
||||||
|
font-size: var(--btn-font-lg);
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-decoration: none;
|
||||||
|
padding: var(--btn-padding-lg);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border: none;
|
||||||
|
transition: background 0.15s ease, transform 0.15s ease, box-shadow 0.15s ease;
|
||||||
|
box-shadow: 0 2px 12px rgba(200, 134, 26, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-home:hover {
|
||||||
|
background: #e09820;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 20px rgba(200, 134, 26, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-home:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,17 +1,20 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import '../app.css';
|
import '../app.css';
|
||||||
import Header from "$lib/header.svelte";
|
import Header from "$lib/header.svelte";
|
||||||
import favicon from '$lib/assets/favicon.svg';
|
import favicon from '$lib/assets/favicon.svg';
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
|
|
||||||
const showHeader = $derived(!['auth', 'forgot-password'].some(p => page.url.pathname.startsWith(`/${p}`)));
|
const showHeader = $derived(
|
||||||
|
!['auth', 'forgot-password'].some(p => page.url.pathname.startsWith(`/${p}`)) &&
|
||||||
|
!/^\/decks\/.+/.test(page.url.pathname)
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>WikiTCG</title>
|
<title>WikiTCG</title>
|
||||||
<link rel="icon" href={favicon} />
|
<link rel="icon" href={favicon} />
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div class="layout">
|
<div class="layout">
|
||||||
|
|||||||
@@ -1,27 +1,28 @@
|
|||||||
<script>
|
<script lang="ts">
|
||||||
import { API_URL, WS_URL } from '$lib/api.js';
|
import { API_URL, WS_URL } from '$lib/api.js';
|
||||||
import { apiFetch } from '$lib/api.js';
|
import { apiFetch } from '$lib/api.js';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import Card from '$lib/Card.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 loading = $state(false);
|
||||||
let boosters = $state(null);
|
let boosters: number | null = $state(null);
|
||||||
let countdown = $state(null);
|
let countdown: Date | null = $state(null);
|
||||||
let emailVerified = $state(true);
|
let emailVerified = $state(true);
|
||||||
let countdownDisplay = $state('');
|
let countdownDisplay = $state('');
|
||||||
let countdownInterval = null;
|
let countdownInterval: number | undefined = undefined;
|
||||||
|
|
||||||
let phase = $state('idle');
|
let phase = $state('idle');
|
||||||
let flippedCards = $state([]);
|
let flippedCards: boolean[] = $state([]);
|
||||||
let fanVisible = $state(false);
|
let fanVisible = $state(false);
|
||||||
let packRef = $state(null);
|
let packRef: HTMLDivElement | null = $state(null);
|
||||||
let overlayPackRef = $state(null);
|
let overlayPackRef: HTMLElement | null = $state(null);
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(() => {
|
||||||
if (!localStorage.getItem('token')) { goto('/auth'); return; }
|
if (!localStorage.getItem('token')) { goto('/auth'); return; }
|
||||||
await fetchBoosters();
|
fetchBoosters();
|
||||||
return () => clearInterval(countdownInterval);
|
return () => clearInterval(countdownInterval);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -37,10 +38,11 @@
|
|||||||
|
|
||||||
function startCountdown() {
|
function startCountdown() {
|
||||||
clearInterval(countdownInterval);
|
clearInterval(countdownInterval);
|
||||||
if (!countdown || boosters >= 5) return;
|
const cd = countdown;
|
||||||
|
if (!cd || boosters === null || boosters >= 5) return;
|
||||||
countdownInterval = setInterval(() => {
|
countdownInterval = setInterval(() => {
|
||||||
const nextTick = new Date(countdown.getTime() + 5 * 60 * 60 * 1000);
|
const nextTick = new Date(cd.getTime() + 5 * 60 * 60 * 1000);
|
||||||
const diff = nextTick - Date.now();
|
const diff = nextTick.getTime() - Date.now();
|
||||||
if (diff <= 0) { clearInterval(countdownInterval); fetchBoosters(); return; }
|
if (diff <= 0) { clearInterval(countdownInterval); fetchBoosters(); return; }
|
||||||
const h = Math.floor(diff / 3600000);
|
const h = Math.floor(diff / 3600000);
|
||||||
const m = Math.floor((diff % 3600000) / 60000);
|
const m = Math.floor((diff % 3600000) / 60000);
|
||||||
@@ -49,7 +51,7 @@
|
|||||||
}, 1000);
|
}, 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
|
// Get the screen position of the idle pack so the overlay pack starts there
|
||||||
function getPackRect() {
|
function getPackRect() {
|
||||||
@@ -71,6 +73,7 @@
|
|||||||
await delay(600);
|
await delay(600);
|
||||||
|
|
||||||
phase = 'ripping';
|
phase = 'ripping';
|
||||||
|
play('packRip');
|
||||||
await delay(900);
|
await delay(900);
|
||||||
|
|
||||||
phase = 'dropping';
|
phase = 'dropping';
|
||||||
@@ -86,12 +89,14 @@
|
|||||||
if (!res.ok) { phase = 'idle'; loading = false; return; }
|
if (!res.ok) { phase = 'idle'; loading = false; return; }
|
||||||
cards = await res.json();
|
cards = await res.json();
|
||||||
flippedCards = new Array(cards.length).fill(false);
|
flippedCards = new Array(cards.length).fill(false);
|
||||||
boosters -= 1;
|
cardActions = cards.map(() => ({ favorited: false, tradeListed: false, shattered: false, shardGain: 0 }));
|
||||||
if (boosters < 5 && !countdown) { countdown = new Date(); startCountdown(); }
|
if (boosters !== null) boosters -= 1;
|
||||||
|
if (boosters !== null && boosters < 5 && !countdown) { countdown = new Date(); startCountdown(); }
|
||||||
|
|
||||||
phase = 'fanning';
|
phase = 'fanning';
|
||||||
await delay(50);
|
await delay(50);
|
||||||
fanVisible = true;
|
fanVisible = true;
|
||||||
|
play('packOpen');
|
||||||
await delay(800);
|
await delay(800);
|
||||||
|
|
||||||
phase = 'flipping';
|
phase = 'flipping';
|
||||||
@@ -102,6 +107,7 @@
|
|||||||
|
|
||||||
for (let i of indices) {
|
for (let i of indices) {
|
||||||
await delay(350);
|
await delay(350);
|
||||||
|
play('cardFlip');
|
||||||
flippedCards = flippedCards.map((v, idx) => idx === i ? true : v);
|
flippedCards = flippedCards.map((v, idx) => idx === i ? true : v);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,13 +119,51 @@
|
|||||||
phase = 'idle';
|
phase = 'idle';
|
||||||
cards = [];
|
cards = [];
|
||||||
flippedCards = [];
|
flippedCards = [];
|
||||||
|
cardActions = [];
|
||||||
fanVisible = false;
|
fanVisible = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const FOIL_RARITIES = new Set(['super_rare', 'epic', 'legendary']);
|
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
|
// Compute fan positions for each card
|
||||||
function fanStyle(i, total) {
|
function fanStyle(i: number, total: number) {
|
||||||
const isMobile = window.innerWidth <= 640;
|
const isMobile = window.innerWidth <= 640;
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
return `--tx: 0px; --ty: 0px;`;
|
return `--tx: 0px; --ty: 0px;`;
|
||||||
@@ -136,9 +180,9 @@
|
|||||||
<h1 class="pack-count">
|
<h1 class="pack-count">
|
||||||
{#if boosters !== null}{boosters}/5 BOOSTER PACKS REMAINING{/if}
|
{#if boosters !== null}{boosters}/5 BOOSTER PACKS REMAINING{/if}
|
||||||
</h1>
|
</h1>
|
||||||
{#if boosters !== null && boosters < 5 && countdownDisplay}
|
<p class="countdown" class:invisible={!(boosters !== null && boosters < 5 && countdownDisplay)}>
|
||||||
<p class="countdown">{countdownDisplay} until next pack</p>
|
{countdownDisplay || ''} until next pack
|
||||||
{/if}
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Idle pack -->
|
<!-- Idle pack -->
|
||||||
@@ -157,6 +201,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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}
|
||||||
|
|
||||||
{#if phase !== 'idle'}
|
{#if phase !== 'idle'}
|
||||||
@@ -180,16 +229,44 @@
|
|||||||
{#each cards as card, i}
|
{#each cards as card, i}
|
||||||
{@const flipped = flippedCards[i]}
|
{@const flipped = flippedCards[i]}
|
||||||
{@const foil = FOIL_RARITIES.has(card.card_rarity)}
|
{@const foil = FOIL_RARITIES.has(card.card_rarity)}
|
||||||
|
{@const action = cardActions[i] ?? {}}
|
||||||
<div
|
<div
|
||||||
class="fan-card"
|
class="fan-card"
|
||||||
class:fan-visible={fanVisible}
|
class:fan-visible={fanVisible}
|
||||||
class:foil-reveal={flipped && foil}
|
class:foil-reveal={flipped && foil}
|
||||||
style="--i: {i};"
|
style="--i: {i};"
|
||||||
>
|
>
|
||||||
<div class="card-flipper" class:flipped>
|
<div class="card-shatter-wrap" class:shattered={action.shattered}>
|
||||||
<div class="card-face back"><div class="card-back-face"></div></div>
|
<div class="card-flipper" class:flipped>
|
||||||
<div class="card-face front"><Card {card} /></div>
|
{#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>
|
||||||
|
<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>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
@@ -204,12 +281,10 @@
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
<style>
|
<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 {
|
main {
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: #0d0a04;
|
background: var(--color-bg);
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -225,28 +300,67 @@
|
|||||||
font-family: 'Cinzel', serif;
|
font-family: 'Cinzel', serif;
|
||||||
font-size: clamp(16px, 3vw, 26px);
|
font-size: clamp(16px, 3vw, 26px);
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
color: #f0d080;
|
color: var(--color-gold);
|
||||||
letter-spacing: 0.1em;
|
letter-spacing: 0.1em;
|
||||||
margin: 0 0 0.4rem;
|
margin: 0 0 0.4rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.countdown {
|
.countdown {
|
||||||
font-family: 'Crimson Text', serif;
|
font-family: 'Crimson Text', serif;
|
||||||
font-size: 15px;
|
font-size: var(--text-md);
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
color: rgba(240, 180, 80, 0.6);
|
color: var(--color-gold-dim);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.invisible {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
.verify-notice {
|
.verify-notice {
|
||||||
font-family: 'Crimson Text', serif;
|
font-family: 'Crimson Text', serif;
|
||||||
font-size: 18px;
|
font-size: var(--text-lg);
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
color: rgba(240, 180, 80, 0.55);
|
color: var(--color-gold-dim);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin-top: 4rem;
|
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 {
|
.pack-wrap {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -340,7 +454,7 @@
|
|||||||
position: fixed;
|
position: fixed;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
z-index: 200;
|
z-index: var(--z-dropdown);
|
||||||
background: rgba(0,0,0,0);
|
background: rgba(0,0,0,0);
|
||||||
transition: background 0.6s ease;
|
transition: background 0.6s ease;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -432,6 +546,9 @@
|
|||||||
transform: translateY(80vh);
|
transform: translateY(80vh);
|
||||||
transition: opacity 0.5s ease, transform 0.7s cubic-bezier(0.2, 0.8, 0.3, 1);
|
transition: opacity 0.5s ease, transform 0.7s cubic-bezier(0.2, 0.8, 0.3, 1);
|
||||||
transition-delay: calc(var(--i) * 0.1s);
|
transition-delay: calc(var(--i) * 0.1s);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fan-card.fan-visible {
|
.fan-card.fan-visible {
|
||||||
@@ -463,6 +580,18 @@
|
|||||||
transform: rotateY(180deg);
|
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 {
|
.card-face {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
@@ -501,12 +630,13 @@
|
|||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
padding: 10px 32px;
|
padding: 10px 32px;
|
||||||
background: rgba(60,30,5,0.85);
|
background: rgba(60,30,5,0.85);
|
||||||
color: #f0d080;
|
color: var(--color-gold);
|
||||||
border: 1.5px solid #c8861a;
|
border: 1.5px solid var(--color-bronze);
|
||||||
border-radius: 6px;
|
border-radius: var(--radius-md);
|
||||||
font-family: 'Cinzel', serif;
|
font-family: 'Cinzel', serif;
|
||||||
font-size: 13px;
|
font-size: var(--text-base);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
letter-spacing: 0.08em;
|
letter-spacing: 0.08em;
|
||||||
transition: background 0.15s;
|
transition: background 0.15s;
|
||||||
@@ -517,6 +647,81 @@
|
|||||||
background: rgba(100,60,10,0.9);
|
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 ── */
|
/* ── Mobile ── */
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.fan-wrap {
|
.fan-wrap {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<script>
|
<script lang="ts">
|
||||||
import { API_URL, WS_URL } from '$lib/api.js';
|
import { API_URL, WS_URL } from '$lib/api.js';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
|
|
||||||
@@ -12,6 +12,7 @@
|
|||||||
|
|
||||||
function validate() {
|
function validate() {
|
||||||
if (!username.trim()) return 'Username is required';
|
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 (username.length > 16) return 'Username must be 16 characters or fewer';
|
||||||
if (mode === 'register') {
|
if (mode === 'register') {
|
||||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return 'Please enter a valid email';
|
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return 'Please enter a valid email';
|
||||||
@@ -40,20 +41,18 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const form = new FormData();
|
|
||||||
form.append('username', username);
|
|
||||||
form.append('password', password);
|
|
||||||
const res = await fetch(`${API_URL}/login`, {
|
const res = await fetch(`${API_URL}/login`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: form,
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: new URLSearchParams({ username, password }),
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (!res.ok) throw new Error(data.detail);
|
if (!res.ok) throw new Error(data.detail);
|
||||||
localStorage.setItem('token', data.access_token);
|
localStorage.setItem('token', data.access_token);
|
||||||
localStorage.setItem('refresh_token', data.refresh_token);
|
localStorage.setItem('refresh_token', data.refresh_token);
|
||||||
goto('/');
|
goto('/');
|
||||||
} catch (e) {
|
} catch (e: any) {
|
||||||
error = e.message;
|
error = e.message || 'Connection failed — check your network and try again';
|
||||||
} finally {
|
} finally {
|
||||||
loading = false;
|
loading = false;
|
||||||
}
|
}
|
||||||
@@ -61,7 +60,15 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
|
<div class="card-ghost" aria-hidden="true"></div>
|
||||||
|
|
||||||
<div class="card">
|
<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>
|
<h1>{mode === 'login' ? 'Sign In' : 'Register'}</h1>
|
||||||
|
|
||||||
<div class="fields">
|
<div class="fields">
|
||||||
@@ -103,33 +110,99 @@
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
<style>
|
<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 {
|
main {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background: #0d0a04;
|
background: var(--color-bg);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: 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 {
|
.card {
|
||||||
|
position: relative;
|
||||||
|
z-index: var(--z-base);
|
||||||
width: 340px;
|
width: 340px;
|
||||||
background: #2e1c05;
|
background: var(--color-surface);
|
||||||
border: 2px solid #6b4c1e;
|
border: 2px solid var(--color-bronze);
|
||||||
border-radius: 12px;
|
border-radius: var(--radius-xl);
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 1.2rem;
|
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 {
|
h1 {
|
||||||
font-family: 'Cinzel', serif;
|
font-family: 'Cinzel', serif;
|
||||||
font-size: 20px;
|
font-size: var(--text-md);
|
||||||
color: #f0d080;
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
color: var(--color-gold-dim);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin: 0;
|
margin: -0.4rem 0 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fields {
|
.fields {
|
||||||
@@ -141,39 +214,42 @@
|
|||||||
input {
|
input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 9px 12px;
|
padding: 9px 12px;
|
||||||
background: #1a1008;
|
background: var(--color-surface);
|
||||||
border: 1.5px solid #8b6420;
|
border: 1.5px solid var(--color-border);
|
||||||
border-radius: 6px;
|
border-radius: var(--radius-md);
|
||||||
color: #f0d080;
|
color: var(--color-gold);
|
||||||
font-family: 'Crimson Text', serif;
|
font-family: 'Crimson Text', serif;
|
||||||
font-size: 15px;
|
font-size: var(--text-md);
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
input::placeholder {
|
input::placeholder {
|
||||||
color: rgba(240, 180, 80, 0.4);
|
color: var(--color-gold-faint);
|
||||||
}
|
}
|
||||||
|
|
||||||
input:focus {
|
input:focus {
|
||||||
border-color: #f0d080;
|
border-color: var(--color-bronze);
|
||||||
}
|
}
|
||||||
|
|
||||||
button {
|
button {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 10px;
|
padding: var(--btn-padding-lg);
|
||||||
background: #6b4c1e;
|
background: var(--color-bronze);
|
||||||
color: #f0d080;
|
color: var(--color-btn-text);
|
||||||
border: 1.5px solid #8b6420;
|
border: none;
|
||||||
border-radius: 6px;
|
border-radius: var(--radius-md);
|
||||||
font-family: 'Cinzel', serif;
|
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;
|
cursor: pointer;
|
||||||
transition: background 0.15s;
|
transition: background 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
button:hover:not(:disabled) {
|
button:hover:not(:disabled) {
|
||||||
background: #8b6420;
|
background: var(--color-bronze-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
button:disabled {
|
button:disabled {
|
||||||
@@ -182,44 +258,44 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.error {
|
.error {
|
||||||
color: #c84040;
|
color: var(--color-error);
|
||||||
font-family: 'Crimson Text', serif;
|
font-family: 'Crimson Text', serif;
|
||||||
font-size: 13px;
|
font-size: var(--text-base);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toggle {
|
.toggle {
|
||||||
font-family: 'Crimson Text', serif;
|
font-family: 'Crimson Text', serif;
|
||||||
font-size: 13px;
|
font-size: var(--text-base);
|
||||||
color: rgba(240, 180, 80, 0.7);
|
color: var(--color-gold-dim);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.link {
|
.link {
|
||||||
all: unset;
|
all: unset;
|
||||||
color: #f0d080;
|
color: var(--color-gold);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
width: auto;
|
width: auto;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
font-size: 13px;
|
font-size: var(--text-base);
|
||||||
font-family: 'Crimson Text', serif;
|
font-family: 'Crimson Text', serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
.link:hover {
|
.link:hover {
|
||||||
color: #f0d080;
|
color: var(--color-gold);
|
||||||
}
|
}
|
||||||
|
|
||||||
.forgot-link {
|
.forgot-link {
|
||||||
font-family: 'Crimson Text', serif;
|
font-family: 'Crimson Text', serif;
|
||||||
font-size: 13px;
|
font-size: var(--text-base);
|
||||||
color: rgba(245, 208, 96, 0.45);
|
color: var(--color-gold-faint);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
transition: color 0.15s;
|
transition: color 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.forgot-link:hover { color: rgba(245, 208, 96, 0.8); }
|
.forgot-link:hover { color: var(--color-gold-muted); }
|
||||||
</style>
|
</style>
|
||||||
@@ -1,11 +1,10 @@
|
|||||||
<script>
|
<script lang="ts">
|
||||||
import { API_URL, WS_URL } from '$lib/api.js';
|
import { API_URL, WS_URL } from '$lib/api.js';
|
||||||
import { apiFetch } from '$lib/api.js';
|
import { apiFetch } from '$lib/api.js';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import Card from '$lib/Card.svelte';
|
import Card from '$lib/Card.svelte';
|
||||||
|
|
||||||
let allCards = $state([]);
|
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
const token = () => localStorage.getItem('token');
|
const token = () => localStorage.getItem('token');
|
||||||
|
|
||||||
@@ -21,53 +20,106 @@
|
|||||||
|
|
||||||
let filtersOpen = $state(false);
|
let filtersOpen = $state(false);
|
||||||
|
|
||||||
const RARITY_ORDER = Object.fromEntries(RARITIES.map((r, i) => [r, i]));
|
function label(str: string) {
|
||||||
|
|
||||||
function label(str) {
|
|
||||||
return str.charAt(0).toUpperCase() + str.slice(1).replace(/_/g, ' ');
|
return str.charAt(0).toUpperCase() + str.slice(1).replace(/_/g, ' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
let sortAsc = $state(true);
|
let sortAsc = $state(true);
|
||||||
let costMin = $state(1);
|
let costMin = $state(1);
|
||||||
let costMax = $state(10);
|
let costMax = $state(10);
|
||||||
let searchQuery = $state('');
|
let searchQuery = $state('');
|
||||||
|
let favoritesOnly = $state(false);
|
||||||
|
let willingToTradeOnly = $state(false);
|
||||||
|
|
||||||
let filtered = $derived.by(() => {
|
// Selection mode for bulk actions
|
||||||
const q = searchQuery.trim().toLowerCase();
|
let selectionMode = $state(false);
|
||||||
let result = allCards.filter(c =>
|
let selectedIds = $state(new Set<string>());
|
||||||
selectedRarities.has(c.card_rarity) &&
|
let bulkLoading = $state(false);
|
||||||
selectedTypes.has(c.card_type) &&
|
|
||||||
c.cost >= costMin &&
|
|
||||||
c.cost <= costMax &&
|
|
||||||
(!q || c.name.toLowerCase().includes(q))
|
|
||||||
);
|
|
||||||
|
|
||||||
result = result.slice().sort((a, b) => {
|
// Server-side fetch state
|
||||||
let cmp = 0;
|
const PAGE_SIZE = 40;
|
||||||
if (sortBy === 'name') cmp = a.name.localeCompare(b.name);
|
let cards: any[] = $state([]);
|
||||||
else if (sortBy === 'cost') cmp = b.cost - a.cost || a.name.localeCompare(b.name);
|
let total = $state(0);
|
||||||
else if (sortBy === 'attack') cmp = b.attack - a.attack || a.name.localeCompare(b.name);
|
let loadingMore = $state(false);
|
||||||
else if (sortBy === 'defense') cmp = b.defense - a.defense || a.name.localeCompare(b.name);
|
let hasMore = $derived(cards.length < total);
|
||||||
else if (sortBy === 'rarity') cmp = RARITY_ORDER[b.card_rarity] - RARITY_ORDER[a.card_rarity] || a.name.localeCompare(b.name);
|
// Must be $state so the IntersectionObserver $effect re-runs when the element is bound
|
||||||
return sortAsc ? cmp : -cmp;
|
let sentinel: HTMLElement | undefined = $state();
|
||||||
|
// <main> has overflow-y: auto — it is the scroll container, not the viewport.
|
||||||
|
// The IntersectionObserver root must be the actual scroll container.
|
||||||
|
let scrollContainer: HTMLElement | undefined = $state();
|
||||||
|
|
||||||
|
// For non-name sorts the "natural" first click should show the highest/newest/rarest values,
|
||||||
|
// which is descending. This matches the old client-side behaviour (b.cost - a.cost etc.).
|
||||||
|
function sortDir() {
|
||||||
|
return sortBy === 'name'
|
||||||
|
? (sortAsc ? 'asc' : 'desc')
|
||||||
|
: (sortAsc ? 'desc' : 'asc');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchCards(reset = false) {
|
||||||
|
if (reset) { cards = []; total = 0; }
|
||||||
|
if (loadingMore) return;
|
||||||
|
loadingMore = true;
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
skip: String(reset ? 0 : cards.length),
|
||||||
|
limit: String(PAGE_SIZE),
|
||||||
|
search: searchQuery.trim(),
|
||||||
|
cost_min: String(costMin),
|
||||||
|
cost_max: String(costMax),
|
||||||
|
favorites_only: String(favoritesOnly),
|
||||||
|
wtt_only: String(willingToTradeOnly),
|
||||||
|
sort_by: sortBy,
|
||||||
|
sort_dir: sortDir(),
|
||||||
});
|
});
|
||||||
|
for (const r of selectedRarities) params.append('rarities', r);
|
||||||
|
for (const t of selectedTypes) params.append('types', t);
|
||||||
|
const res = await apiFetch(`${API_URL}/cards?${params}`);
|
||||||
|
if (res.status === 401) { goto('/auth'); loadingMore = false; return; }
|
||||||
|
const data = await res.json();
|
||||||
|
cards = reset ? data.cards : [...cards, ...data.cards];
|
||||||
|
total = data.total;
|
||||||
|
loadingMore = false;
|
||||||
|
}
|
||||||
|
|
||||||
return result;
|
// Debounced refetch when any filter/sort state changes.
|
||||||
|
// Skip the initial run on mount — onMount handles the first fetch directly so
|
||||||
|
// there's no double-fetch (which would cause a visible flash as cards reset).
|
||||||
|
let mounted = false;
|
||||||
|
let fetchTimer: number;
|
||||||
|
$effect(() => {
|
||||||
|
searchQuery; sortBy; sortAsc; selectedRarities; selectedTypes;
|
||||||
|
costMin; costMax; favoritesOnly; willingToTradeOnly;
|
||||||
|
if (!mounted) return;
|
||||||
|
clearTimeout(fetchTimer);
|
||||||
|
fetchTimer = setTimeout(() => fetchCards(true), 300);
|
||||||
});
|
});
|
||||||
|
|
||||||
function toggleSort(val) {
|
// IntersectionObserver: load next page when sentinel scrolls into view.
|
||||||
|
// Both sentinel and scrollContainer are $state so this effect re-runs once bound.
|
||||||
|
$effect(() => {
|
||||||
|
if (!sentinel || !scrollContainer) return;
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
if (entries[0].isIntersecting && hasMore && !loadingMore) fetchCards(false);
|
||||||
|
},
|
||||||
|
{ root: scrollContainer, rootMargin: '200px' }
|
||||||
|
);
|
||||||
|
observer.observe(sentinel);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
function toggleSort(val: string) {
|
||||||
if (sortBy === val) sortAsc = !sortAsc;
|
if (sortBy === val) sortAsc = !sortAsc;
|
||||||
else { sortBy = val; sortAsc = true; }
|
else { sortBy = val; sortAsc = true; }
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleRarity(r) {
|
function toggleRarity(r: string) {
|
||||||
const s = new Set(selectedRarities);
|
const s = new Set(selectedRarities);
|
||||||
s.has(r) ? s.delete(r) : s.add(r);
|
s.has(r) ? s.delete(r) : s.add(r);
|
||||||
selectedRarities = s;
|
selectedRarities = s;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleType(t) {
|
function toggleType(t: string) {
|
||||||
const s = new Set(selectedTypes);
|
const s = new Set(selectedTypes);
|
||||||
s.has(t) ? s.delete(t) : s.add(t);
|
s.has(t) ? s.delete(t) : s.add(t);
|
||||||
selectedTypes = s;
|
selectedTypes = s;
|
||||||
@@ -86,35 +138,65 @@
|
|||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
if (!token()) { goto('/auth'); return; }
|
if (!token()) { goto('/auth'); return; }
|
||||||
const res = await apiFetch(`${API_URL}/cards`);
|
await fetchCards(true);
|
||||||
if (res.status === 401) { goto('/auth'); return; }
|
|
||||||
allCards = await res.json();
|
|
||||||
loading = false;
|
loading = false;
|
||||||
|
mounted = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
let selectedCard = $state(null);
|
let selectedCard: any = $state(null);
|
||||||
let refreshStatus = $state(null);
|
let refreshStatus: { can_refresh: boolean; next_refresh_at: string | null } | null = $state(null);
|
||||||
let countdownDisplay = $state('');
|
let countdownDisplay = $state('');
|
||||||
let countdownInterval = null;
|
let countdownInterval: number | undefined = undefined;
|
||||||
let reportLoading = $state(false);
|
let reportLoading = $state(false);
|
||||||
let refreshLoading = $state(false);
|
let refreshLoading = $state(false);
|
||||||
let actionMessage = $state('');
|
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() {
|
async function fetchRefreshStatus() {
|
||||||
const res = await apiFetch(`${API_URL}/profile/refresh-status`);
|
const res = await apiFetch(`${API_URL}/profile/refresh-status`);
|
||||||
refreshStatus = await res.json();
|
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));
|
startRefreshCountdown(new Date(refreshStatus.next_refresh_at));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function startRefreshCountdown(nextRefreshAt) {
|
function startRefreshCountdown(nextRefreshAt: Date) {
|
||||||
clearInterval(countdownInterval);
|
clearInterval(countdownInterval);
|
||||||
countdownInterval = setInterval(() => {
|
countdownInterval = setInterval(() => {
|
||||||
const diff = nextRefreshAt - Date.now();
|
const diff = nextRefreshAt.getTime() - Date.now();
|
||||||
if (diff <= 0) {
|
if (diff <= 0) {
|
||||||
clearInterval(countdownInterval);
|
clearInterval(countdownInterval);
|
||||||
refreshStatus = { can_refresh: true, next_refresh_at: null };
|
refreshStatus = { can_refresh: true, next_refresh_at: null } as typeof refreshStatus;
|
||||||
countdownDisplay = '';
|
countdownDisplay = '';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -125,7 +207,54 @@
|
|||||||
}, 1000);
|
}, 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;
|
selectedCard = card;
|
||||||
actionMessage = '';
|
actionMessage = '';
|
||||||
fetchRefreshStatus();
|
fetchRefreshStatus();
|
||||||
@@ -136,6 +265,8 @@
|
|||||||
clearInterval(countdownInterval);
|
clearInterval(countdownInterval);
|
||||||
countdownDisplay = '';
|
countdownDisplay = '';
|
||||||
actionMessage = '';
|
actionMessage = '';
|
||||||
|
(previousFocus as HTMLElement)?.focus();
|
||||||
|
previousFocus = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function reportCard() {
|
async function reportCard() {
|
||||||
@@ -146,13 +277,31 @@
|
|||||||
reportLoading = false;
|
reportLoading = false;
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
selectedCard = { ...selectedCard, reported: true };
|
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!';
|
actionMessage = 'Card reported. Thank you!';
|
||||||
} else {
|
} else {
|
||||||
actionMessage = 'Failed to report card.';
|
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() {
|
async function refreshCard() {
|
||||||
refreshLoading = true;
|
refreshLoading = true;
|
||||||
actionMessage = '';
|
actionMessage = '';
|
||||||
@@ -162,10 +311,9 @@
|
|||||||
refreshLoading = false;
|
refreshLoading = false;
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const updated = await res.json();
|
const updated = await res.json();
|
||||||
// Update card in allCards list
|
cards = cards.map(c => c.id === updated.id ? updated : c);
|
||||||
allCards = allCards.map(c => c.id === updated.id ? updated : c);
|
|
||||||
selectedCard = updated;
|
selectedCard = updated;
|
||||||
refreshStatus = { can_refresh: false, next_refresh_at: null };
|
refreshStatus = { can_refresh: false, next_refresh_at: null } as typeof refreshStatus;
|
||||||
await fetchRefreshStatus();
|
await fetchRefreshStatus();
|
||||||
actionMessage = 'Card refreshed!';
|
actionMessage = 'Card refreshed!';
|
||||||
} else {
|
} else {
|
||||||
@@ -175,11 +323,13 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<main>
|
<svelte:window onkeydown={handleKeydown} />
|
||||||
|
|
||||||
|
<main bind:this={scrollContainer}>
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<div class="sort-row">
|
<div class="sort-row">
|
||||||
<span class="toolbar-label">Sort by</span>
|
<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
|
<button
|
||||||
class="sort-btn"
|
class="sort-btn"
|
||||||
class:active={sortBy === val}
|
class:active={sortBy === val}
|
||||||
@@ -199,12 +349,32 @@
|
|||||||
bind:value={searchQuery}
|
bind:value={searchQuery}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<button class="filter-toggle" onclick={() => filtersOpen = !filtersOpen}>
|
<div class="filter-actions">
|
||||||
{filtersOpen ? 'Hide filters' : 'Filter'}
|
<button
|
||||||
{#if selectedRarities.size < RARITIES.length || selectedTypes.size < TYPES.length || costMin > 1 || costMax < 10}
|
class="filter-toggle"
|
||||||
<span class="filter-dot"></span>
|
class:active={favoritesOnly}
|
||||||
{/if}
|
onclick={() => favoritesOnly = !favoritesOnly}
|
||||||
</button>
|
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>
|
</div>
|
||||||
|
|
||||||
{#if filtersOpen}
|
{#if filtersOpen}
|
||||||
@@ -263,21 +433,53 @@
|
|||||||
|
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<p class="status">Loading your cards...</p>
|
<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>
|
<p class="status">No cards match your filters.</p>
|
||||||
{:else}
|
{:else}
|
||||||
<p class="card-count">{filtered.length} card{filtered.length === 1 ? '' : 's'}</p>
|
<p class="card-count">{total} card{total === 1 ? '' : 's'}</p>
|
||||||
<div class="grid">
|
<div class="grid" style={selectionMode ? 'padding-bottom: 100px' : ''}>
|
||||||
{#each filtered as card (card.id)}
|
{#each cards as card (card.id)}
|
||||||
<button class="card-btn" onclick={() => openCard(card)}>
|
<div class="card-item">
|
||||||
<Card {card} />
|
<button
|
||||||
</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}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
<div bind:this={sentinel} class="scroll-sentinel"></div>
|
||||||
|
{#if loadingMore}<p class="status">Loading more...</p>{/if}
|
||||||
{/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}
|
{#if selectedCard}
|
||||||
<div class="backdrop" onclick={closeCard}>
|
<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} />
|
<Card card={selectedCard} />
|
||||||
<div class="popup-actions">
|
<div class="popup-actions">
|
||||||
<div class="action-col">
|
<div class="action-col">
|
||||||
@@ -289,6 +491,24 @@
|
|||||||
{selectedCard.reported ? 'Already Reported' : reportLoading ? 'Reporting...' : 'Report Error'}
|
{selectedCard.reported ? 'Already Reported' : reportLoading ? 'Reporting...' : 'Report Error'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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">
|
<div class="action-col">
|
||||||
<button
|
<button
|
||||||
class="refresh-btn"
|
class="refresh-btn"
|
||||||
@@ -310,12 +530,10 @@
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
<style>
|
<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 {
|
main {
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
background: #0d0a04;
|
background: var(--color-bg);
|
||||||
padding: 0 2rem 2rem 2rem;
|
padding: 0 2rem 2rem 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -323,9 +541,9 @@
|
|||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0px;
|
top: 0px;
|
||||||
z-index: 50;
|
z-index: 50;
|
||||||
background: #0d0a04;
|
background: var(--color-bg);
|
||||||
padding-bottom: 1rem;
|
padding-bottom: 1rem;
|
||||||
border-bottom: 1px solid rgba(107, 76, 30, 0.3);
|
border-bottom: 1px solid var(--color-border-dim);
|
||||||
margin-bottom: 2rem;
|
margin-bottom: 2rem;
|
||||||
padding-top: 32px;
|
padding-top: 32px;
|
||||||
}
|
}
|
||||||
@@ -339,23 +557,23 @@
|
|||||||
|
|
||||||
.search-input {
|
.search-input {
|
||||||
font-family: 'Crimson Text', serif;
|
font-family: 'Crimson Text', serif;
|
||||||
font-size: 15px;
|
font-size: var(--text-md);
|
||||||
background: rgba(255,255,255,0.04);
|
background: var(--color-surface);
|
||||||
border: 1px solid rgba(107, 76, 30, 0.4);
|
border: 1.5px solid var(--color-bronze);
|
||||||
border-radius: 4px;
|
border-radius: var(--radius-md);
|
||||||
color: #f0d080;
|
color: var(--color-gold);
|
||||||
padding: 5px 10px;
|
padding: 5px 10px;
|
||||||
outline: none;
|
outline: none;
|
||||||
width: 220px;
|
width: 220px;
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
transition: border-color 0.15s;
|
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); }
|
.search-input::placeholder { color: rgba(240, 180, 80, 0.3); }
|
||||||
|
|
||||||
.toolbar-label {
|
.toolbar-label {
|
||||||
font-family: 'Cinzel', serif;
|
font-family: 'Cinzel', serif;
|
||||||
font-size: 11px;
|
font-size: var(--text-sm);
|
||||||
color: rgba(240, 180, 80, 0.5);
|
color: rgba(240, 180, 80, 0.5);
|
||||||
letter-spacing: 0.08em;
|
letter-spacing: 0.08em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -364,50 +582,61 @@
|
|||||||
|
|
||||||
.sort-btn {
|
.sort-btn {
|
||||||
font-family: 'Cinzel', serif;
|
font-family: 'Cinzel', serif;
|
||||||
font-size: 11px;
|
font-size: var(--btn-font-sm);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.06em;
|
letter-spacing: 0.06em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
background: none;
|
background: none;
|
||||||
border: 1px solid rgba(107, 76, 30, 0.4);
|
border: 1px solid var(--color-border-subtle);
|
||||||
border-radius: 4px;
|
border-radius: var(--radius-sm);
|
||||||
color: rgba(240, 180, 80, 0.6);
|
color: var(--color-gold-dim);
|
||||||
padding: 4px 10px;
|
padding: var(--btn-padding-sm);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.15s;
|
transition: all 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sort-btn:hover {
|
.sort-btn:hover {
|
||||||
border-color: #c8861a;
|
border-color: var(--color-bronze);
|
||||||
color: #f0d080;
|
color: var(--color-gold);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sort-btn.active {
|
.sort-btn.active {
|
||||||
background: #3d2507;
|
background: var(--color-surface-raised);
|
||||||
border-color: #c8861a;
|
border-color: var(--color-bronze);
|
||||||
color: #f0d080;
|
color: var(--color-gold);
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-toggle {
|
.filter-toggle {
|
||||||
font-family: 'Cinzel', serif;
|
font-family: 'Cinzel', serif;
|
||||||
font-size: 11px;
|
font-size: var(--btn-font-sm);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.06em;
|
letter-spacing: 0.06em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
background: none;
|
background: none;
|
||||||
border: 1px solid rgba(107, 76, 30, 0.4);
|
border: 1px solid var(--color-border-subtle);
|
||||||
border-radius: 4px;
|
border-radius: var(--radius-sm);
|
||||||
color: rgba(240, 180, 80, 0.6);
|
color: var(--color-gold-dim);
|
||||||
padding: 4px 10px;
|
padding: var(--btn-padding-sm);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
margin-left: 0.5rem;
|
|
||||||
position: relative;
|
position: relative;
|
||||||
transition: all 0.15s;
|
transition: all 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.filter-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-left: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
.filter-toggle:hover {
|
.filter-toggle:hover {
|
||||||
border-color: #c8861a;
|
border-color: var(--color-bronze);
|
||||||
color: #f0d080;
|
color: var(--color-gold);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-toggle.active {
|
||||||
|
background: var(--color-surface-raised);
|
||||||
|
border-color: var(--color-bronze);
|
||||||
|
color: var(--color-gold);
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-dot {
|
.filter-dot {
|
||||||
@@ -416,8 +645,8 @@
|
|||||||
right: -3px;
|
right: -3px;
|
||||||
width: 7px;
|
width: 7px;
|
||||||
height: 7px;
|
height: 7px;
|
||||||
border-radius: 50%;
|
border-radius: var(--radius-full);
|
||||||
background: #c8861a;
|
background: var(--color-bronze);
|
||||||
}
|
}
|
||||||
|
|
||||||
.filters {
|
.filters {
|
||||||
@@ -441,7 +670,7 @@
|
|||||||
|
|
||||||
.filter-group-label {
|
.filter-group-label {
|
||||||
font-family: 'Cinzel', serif;
|
font-family: 'Cinzel', serif;
|
||||||
font-size: 11px;
|
font-size: var(--text-sm);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.08em;
|
letter-spacing: 0.08em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -450,7 +679,7 @@
|
|||||||
|
|
||||||
.select-all {
|
.select-all {
|
||||||
font-family: 'Crimson Text', serif;
|
font-family: 'Crimson Text', serif;
|
||||||
font-size: 12px;
|
font-size: var(--text-sm);
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
@@ -462,7 +691,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.select-all:hover {
|
.select-all:hover {
|
||||||
color: #f0d080;
|
color: var(--color-gold);
|
||||||
}
|
}
|
||||||
|
|
||||||
.checkboxes {
|
.checkboxes {
|
||||||
@@ -476,23 +705,25 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
font-family: 'Crimson Text', serif;
|
font-family: 'Crimson Text', serif;
|
||||||
font-size: 14px;
|
font-size: var(--text-base);
|
||||||
color: rgba(240, 180, 80, 0.8);
|
color: var(--color-gold-muted);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.checkbox-label input {
|
.checkbox-label input {
|
||||||
accent-color: #c8861a;
|
accent-color: var(--color-bronze);
|
||||||
width: 14px;
|
width: 14px;
|
||||||
height: 14px;
|
height: 14px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.scroll-sentinel { height: 1px; }
|
||||||
|
|
||||||
.card-count {
|
.card-count {
|
||||||
font-family: 'Crimson Text', serif;
|
font-family: 'Crimson Text', serif;
|
||||||
font-size: 16px;
|
font-size: var(--text-md);
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
color: rgba(240, 180, 80, 0.4);
|
color: var(--color-gold-faint);
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -506,7 +737,7 @@
|
|||||||
|
|
||||||
.status {
|
.status {
|
||||||
font-family: 'Crimson Text', serif;
|
font-family: 'Crimson Text', serif;
|
||||||
font-size: 16px;
|
font-size: var(--text-md);
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
color: rgba(240, 180, 80, 0.5);
|
color: rgba(240, 180, 80, 0.5);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -514,7 +745,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sort-arrow {
|
.sort-arrow {
|
||||||
font-size: 10px;
|
font-size: var(--text-xs);
|
||||||
margin-left: 3px;
|
margin-left: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -526,21 +757,25 @@
|
|||||||
|
|
||||||
.range-label {
|
.range-label {
|
||||||
font-family: 'Cinzel', serif;
|
font-family: 'Cinzel', serif;
|
||||||
font-size: 11px;
|
font-size: var(--text-sm);
|
||||||
color: rgba(240, 180, 80, 0.7);
|
color: var(--color-gold-dim);
|
||||||
min-width: 60px;
|
min-width: 60px;
|
||||||
}
|
}
|
||||||
|
|
||||||
input[type=range] {
|
input[type=range] {
|
||||||
accent-color: #c8861a;
|
accent-color: var(--color-bronze);
|
||||||
width: 160px;
|
width: 160px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.card-item {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
.card-btn {
|
.card-btn {
|
||||||
all: unset;
|
all: unset;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
display: block;
|
display: block;
|
||||||
border-radius: 12px;
|
border-radius: var(--radius-xl);
|
||||||
transition: transform 0.15s;
|
transition: transform 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -548,6 +783,26 @@
|
|||||||
transform: translateY(-4px);
|
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 {
|
.backdrop {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
@@ -555,7 +810,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
z-index: 100;
|
z-index: var(--z-header);
|
||||||
backdrop-filter: blur(6px);
|
backdrop-filter: blur(6px);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -565,12 +820,17 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
|
padding-top: 5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.popup-actions {
|
.popup-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 1rem;
|
flex-direction: column;
|
||||||
justify-content: center;
|
gap: 0.5rem;
|
||||||
|
align-items: stretch;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 260px;
|
||||||
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-col {
|
.action-col {
|
||||||
@@ -578,37 +838,70 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
min-height: 60px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.report-btn, .refresh-btn {
|
.fav-btn, .wtt-btn, .report-btn, .refresh-btn {
|
||||||
font-family: 'Cinzel', serif;
|
font-family: 'Cinzel', serif;
|
||||||
font-size: 11px;
|
font-size: var(--btn-font-md);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.06em;
|
letter-spacing: 0.06em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
border-radius: 4px;
|
border-radius: var(--radius-sm);
|
||||||
padding: 8px 18px;
|
padding: var(--btn-padding-md);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.15s;
|
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 {
|
.report-btn {
|
||||||
background: rgba(180, 60, 60, 0.5);
|
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;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.report-btn:hover:not(:disabled) {
|
.report-btn:hover:not(:disabled) {
|
||||||
/* border-color: #c84040; */
|
|
||||||
color: #E0E0E0;
|
color: #E0E0E0;
|
||||||
background: rgba(180, 40, 40, 0.9);
|
background: rgba(180, 40, 40, 0.9);
|
||||||
}
|
}
|
||||||
|
|
||||||
.refresh-btn {
|
.refresh-btn {
|
||||||
background: #3d2507;
|
background: var(--color-surface-raised);
|
||||||
border: 1px solid #c8861a;
|
border: 1px solid var(--color-bronze);
|
||||||
color: #f0d080;
|
color: var(--color-gold);
|
||||||
}
|
}
|
||||||
|
|
||||||
.refresh-btn:hover:not(:disabled) {
|
.refresh-btn:hover:not(:disabled) {
|
||||||
@@ -622,16 +915,16 @@
|
|||||||
|
|
||||||
.refresh-countdown {
|
.refresh-countdown {
|
||||||
font-family: 'Crimson Text', serif;
|
font-family: 'Crimson Text', serif;
|
||||||
font-size: 12px;
|
font-size: var(--text-sm);
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
color: rgba(240, 180, 80, 0.5);
|
color: rgba(240, 180, 80, 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-message {
|
.action-message {
|
||||||
font-family: 'Crimson Text', serif;
|
font-family: 'Crimson Text', serif;
|
||||||
font-size: 14px;
|
font-size: var(--text-base);
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
color: rgba(240, 180, 80, 0.7);
|
color: var(--color-gold-dim);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
min-height: 1.4em;
|
min-height: 1.4em;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -639,15 +932,15 @@
|
|||||||
|
|
||||||
.close-btn {
|
.close-btn {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: -12px;
|
top: calc(5rem - 14px);
|
||||||
right: -12px;
|
right: -12px;
|
||||||
width: 28px;
|
width: 28px;
|
||||||
height: 28px;
|
height: 28px;
|
||||||
border-radius: 50%;
|
border-radius: var(--radius-full);
|
||||||
background: #1a1008;
|
background: var(--color-surface);
|
||||||
border: 1px solid #6b4c1e;
|
border: 1px solid var(--color-border);
|
||||||
color: rgba(240, 180, 80, 0.7);
|
color: var(--color-gold-dim);
|
||||||
font-size: 12px;
|
font-size: var(--text-sm);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -656,7 +949,110 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.close-btn:hover {
|
.close-btn:hover {
|
||||||
border-color: #c8861a;
|
border-color: var(--color-bronze);
|
||||||
color: #f0d080;
|
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>
|
</style>
|
||||||
@@ -1,15 +1,15 @@
|
|||||||
<script>
|
<script lang="ts">
|
||||||
import { API_URL, WS_URL } from '$lib/api.js';
|
import { API_URL, WS_URL } from '$lib/api.js';
|
||||||
import { apiFetch } from '$lib/api.js';
|
import { apiFetch } from '$lib/api.js';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import DeckTypeBadge from '$lib/DeckTypeBadge.svelte';
|
import DeckTypeBadge from '$lib/DeckTypeBadge.svelte';
|
||||||
|
|
||||||
let decks = $state([]);
|
let decks: any[] = $state([]);
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
|
|
||||||
let editConfirm = $state(null); // deck object pending edit confirmation
|
let editConfirm: any = $state(null); // deck object pending edit confirmation
|
||||||
let deleteConfirm = $state(null); // deck object pending delete confirmation
|
let deleteConfirm: any = $state(null); // deck object pending delete confirmation
|
||||||
|
|
||||||
const token = () => localStorage.getItem('token');
|
const token = () => localStorage.getItem('token');
|
||||||
|
|
||||||
@@ -33,7 +33,7 @@
|
|||||||
goto(`/decks/${deck.id}`);
|
goto(`/decks/${deck.id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function clickEdit(deck) {
|
function clickEdit(deck: any) {
|
||||||
if (deck.times_played > 0) {
|
if (deck.times_played > 0) {
|
||||||
editConfirm = deck;
|
editConfirm = deck;
|
||||||
} else {
|
} else {
|
||||||
@@ -41,7 +41,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function clickDelete(deck) {
|
function clickDelete(deck: any) {
|
||||||
deleteConfirm = deck;
|
deleteConfirm = deck;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,7 +53,7 @@
|
|||||||
deleteConfirm = null;
|
deleteConfirm = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function winRate(deck) {
|
function winRate(deck: any) {
|
||||||
if (deck.times_played === 0) return null;
|
if (deck.times_played === 0) return null;
|
||||||
return Math.round((deck.wins / deck.times_played) * 100);
|
return Math.round((deck.wins / deck.times_played) * 100);
|
||||||
}
|
}
|
||||||
@@ -160,12 +160,10 @@
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
<style>
|
<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 {
|
main {
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
background: #0d0a04;
|
background: var(--color-bg);
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,29 +172,31 @@
|
|||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
margin-bottom: 2rem;
|
margin-bottom: 2rem;
|
||||||
border-bottom: 1px solid rgba(107, 76, 30, 0.3);
|
border-bottom: 1px solid var(--color-border-dim);
|
||||||
padding-bottom: 1rem;
|
padding-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.title {
|
.title {
|
||||||
font-family: 'Cinzel', serif;
|
font-family: 'Cinzel', serif;
|
||||||
font-size: 22px;
|
font-size: var(--text-xl);
|
||||||
font-weight: 700;
|
font-weight: 900;
|
||||||
color: #f0d080;
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--color-gold);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.new-btn {
|
.new-btn {
|
||||||
font-family: 'Cinzel', serif;
|
font-family: 'Cinzel', serif;
|
||||||
font-size: 11px;
|
font-size: var(--btn-font-md);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.06em;
|
letter-spacing: 0.06em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
background: #3d2507;
|
background: var(--color-surface-raised);
|
||||||
border: 1px solid #c8861a;
|
border: 1px solid var(--color-bronze);
|
||||||
border-radius: 4px;
|
border-radius: var(--radius-md);
|
||||||
color: #f0d080;
|
color: var(--color-gold);
|
||||||
padding: 6px 14px;
|
padding: var(--btn-padding-md);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.15s;
|
transition: background 0.15s;
|
||||||
}
|
}
|
||||||
@@ -210,16 +210,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
thead tr {
|
thead tr {
|
||||||
border-bottom: 1px solid rgba(107, 76, 30, 0.5);
|
border-bottom: 1px solid var(--color-border-subtle);
|
||||||
}
|
}
|
||||||
|
|
||||||
th {
|
th {
|
||||||
font-family: 'Cinzel', serif;
|
font-family: 'Cinzel', serif;
|
||||||
font-size: 10px;
|
font-size: var(--text-xs);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.1em;
|
letter-spacing: 0.1em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: rgba(240, 180, 80, 0.4);
|
color: var(--color-gold-faint);
|
||||||
padding: 0 1rem 0.75rem 0;
|
padding: 0 1rem 0.75rem 0;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
@@ -237,62 +237,43 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.deck-name {
|
.deck-name {
|
||||||
font-size: 17px;
|
font-size: var(--text-lg);
|
||||||
color: #e8d090;
|
color: var(--color-gold);
|
||||||
}
|
}
|
||||||
|
|
||||||
.deck-count {
|
.deck-count {
|
||||||
font-family: 'Cinzel', serif;
|
font-family: 'Cinzel', serif;
|
||||||
font-size: 13px;
|
font-size: var(--text-base);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: rgba(240, 180, 80, 0.6);
|
color: var(--color-gold-dim);
|
||||||
width: 60px;
|
width: 60px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.deck-cost {
|
.deck-cost {
|
||||||
font-family: 'Cinzel', serif;
|
font-family: 'Cinzel', serif;
|
||||||
font-size: 13px;
|
font-size: var(--text-base);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #6aaa6a;
|
color: var(--color-success);
|
||||||
width: 60px;
|
width: 60px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.deck-cost.over-budget { color: #c85050; }
|
.deck-cost.over-budget { color: var(--color-error); }
|
||||||
|
|
||||||
.deck-type { width: 90px; }
|
.deck-type { white-space: nowrap; }
|
||||||
|
|
||||||
.type-badge {
|
|
||||||
font-family: 'Cinzel', serif;
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
padding: 3px 8px;
|
|
||||||
border-radius: 3px;
|
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
|
|
||||||
.type-wall { background: rgba(58, 104, 120, 0.3); color: #a0d4e8; border: 1px solid rgba(58, 104, 120, 0.5); }
|
|
||||||
.type-aggro { background: rgba(139, 32, 32, 0.3); color: #e89090; border: 1px solid rgba(139, 32, 32, 0.5); }
|
|
||||||
.type-god-card { background: rgba(184, 120, 32, 0.3); color: #f5d880; border: 1px solid rgba(184, 120, 32, 0.5); }
|
|
||||||
.type-rush { background: rgba(74, 122, 80, 0.3); color: #a8dca8; border: 1px solid rgba(74, 122, 80, 0.5); }
|
|
||||||
.type-control { background: rgba(122, 80, 144, 0.3); color: #d0a0e8; border: 1px solid rgba(122, 80, 144, 0.5); }
|
|
||||||
.type-unplayable { background: rgba(60, 60, 60, 0.3); color: #909090; border: 1px solid rgba(60, 60, 60, 0.5); }
|
|
||||||
.type-pantheon { background: rgba(184, 150, 60, 0.3); color: #fce8a0; border: 1px solid rgba(184, 150, 60, 0.5); }
|
|
||||||
.type-balanced { background: rgba(106, 104, 96, 0.3); color: #c8c6c0; border: 1px solid rgba(106, 104, 96, 0.5); }
|
|
||||||
|
|
||||||
.deck-stat {
|
.deck-stat {
|
||||||
font-family: 'Cinzel', serif;
|
font-family: 'Cinzel', serif;
|
||||||
font-size: 13px;
|
font-size: var(--text-base);
|
||||||
color: rgba(240, 180, 80, 0.6);
|
color: var(--color-gold-dim);
|
||||||
width: 60px;
|
width: 60px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wins { color: #6aaa6a; }
|
.wins { color: var(--color-success); }
|
||||||
.losses { color: #c85050; }
|
.losses { color: var(--color-error); }
|
||||||
.separator { color: rgba(240, 180, 80, 0.3); }
|
.separator { color: rgba(240, 180, 80, 0.3); }
|
||||||
|
|
||||||
.good-wr { color: #6aaa6a; }
|
.good-wr { color: var(--color-success); }
|
||||||
.bad-wr { color: #c85050; }
|
.bad-wr { color: var(--color-error); }
|
||||||
|
|
||||||
.no-data {
|
.no-data {
|
||||||
color: rgba(240, 180, 80, 0.2);
|
color: rgba(240, 180, 80, 0.2);
|
||||||
@@ -306,25 +287,25 @@
|
|||||||
|
|
||||||
.edit-btn, .delete-btn {
|
.edit-btn, .delete-btn {
|
||||||
font-family: 'Cinzel', serif;
|
font-family: 'Cinzel', serif;
|
||||||
font-size: 10px;
|
font-size: var(--btn-font-sm);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.06em;
|
letter-spacing: 0.06em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
border-radius: 4px;
|
border-radius: var(--radius-sm);
|
||||||
padding: 4px 10px;
|
padding: var(--btn-padding-sm);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.15s;
|
transition: background 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.edit-btn {
|
.edit-btn {
|
||||||
background: none;
|
background: none;
|
||||||
border: 1px solid rgba(107, 76, 30, 0.5);
|
border: 1px solid var(--color-border-subtle);
|
||||||
color: rgba(240, 180, 80, 0.7);
|
color: var(--color-gold-dim);
|
||||||
}
|
}
|
||||||
|
|
||||||
.edit-btn:hover {
|
.edit-btn:hover {
|
||||||
border-color: #c8861a;
|
border-color: var(--color-bronze);
|
||||||
color: #f0d080;
|
color: var(--color-gold);
|
||||||
}
|
}
|
||||||
|
|
||||||
.delete-btn {
|
.delete-btn {
|
||||||
@@ -334,13 +315,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.delete-btn:hover {
|
.delete-btn:hover {
|
||||||
border-color: #c84040;
|
border-color: var(--color-error);
|
||||||
color: #e05050;
|
color: var(--color-error);
|
||||||
}
|
}
|
||||||
|
|
||||||
.status {
|
.status {
|
||||||
font-family: 'Crimson Text', serif;
|
font-family: 'Crimson Text', serif;
|
||||||
font-size: 16px;
|
font-size: var(--text-md);
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
color: rgba(240, 180, 80, 0.5);
|
color: rgba(240, 180, 80, 0.5);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -354,13 +335,13 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
z-index: 100;
|
z-index: var(--z-header);
|
||||||
}
|
}
|
||||||
|
|
||||||
.popup {
|
.popup {
|
||||||
background: #1a1008;
|
background: var(--color-surface);
|
||||||
border: 1px solid #6b4c1e;
|
border: 1px solid var(--color-border);
|
||||||
border-radius: 10px;
|
border-radius: var(--radius-lg);
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
max-width: 400px;
|
max-width: 400px;
|
||||||
width: calc(100% - 2rem);
|
width: calc(100% - 2rem);
|
||||||
@@ -371,22 +352,22 @@
|
|||||||
|
|
||||||
.popup-title {
|
.popup-title {
|
||||||
font-family: 'Cinzel', serif;
|
font-family: 'Cinzel', serif;
|
||||||
font-size: 18px;
|
font-size: var(--text-lg);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #f0d080;
|
color: var(--color-gold);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.popup-body {
|
.popup-body {
|
||||||
font-family: 'Crimson Text', serif;
|
font-family: 'Crimson Text', serif;
|
||||||
font-size: 15px;
|
font-size: var(--text-md);
|
||||||
color: rgba(240, 180, 80, 0.7);
|
color: var(--color-gold-dim);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.popup-body strong {
|
.popup-body strong {
|
||||||
color: #f0d080;
|
color: var(--color-gold);
|
||||||
}
|
}
|
||||||
|
|
||||||
.popup-actions {
|
.popup-actions {
|
||||||
@@ -397,52 +378,52 @@
|
|||||||
|
|
||||||
.popup-cancel {
|
.popup-cancel {
|
||||||
font-family: 'Cinzel', serif;
|
font-family: 'Cinzel', serif;
|
||||||
font-size: 11px;
|
font-size: var(--btn-font-md);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.06em;
|
letter-spacing: 0.06em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
background: none;
|
background: none;
|
||||||
border: 1px solid rgba(107, 76, 30, 0.4);
|
border: 1px solid var(--color-border-subtle);
|
||||||
border-radius: 4px;
|
border-radius: var(--radius-sm);
|
||||||
color: rgba(240, 180, 80, 0.6);
|
color: var(--color-gold-dim);
|
||||||
padding: 7px 16px;
|
padding: var(--btn-padding-md);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.15s;
|
transition: all 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.popup-cancel:hover {
|
.popup-cancel:hover {
|
||||||
border-color: #c8861a;
|
border-color: var(--color-bronze);
|
||||||
color: #f0d080;
|
color: var(--color-gold);
|
||||||
}
|
}
|
||||||
|
|
||||||
.popup-confirm {
|
.popup-confirm {
|
||||||
font-family: 'Cinzel', serif;
|
font-family: 'Cinzel', serif;
|
||||||
font-size: 11px;
|
font-size: var(--btn-font-md);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.06em;
|
letter-spacing: 0.06em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
background: #c8861a;
|
background: var(--color-bronze);
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 4px;
|
border-radius: var(--radius-sm);
|
||||||
color: #fff8e0;
|
color: var(--color-btn-text);
|
||||||
padding: 7px 16px;
|
padding: var(--btn-padding-md);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.15s;
|
transition: background 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.popup-confirm:hover { background: #e09820; }
|
.popup-confirm:hover { background: var(--color-bronze-hover); }
|
||||||
|
|
||||||
.popup-delete {
|
.popup-delete {
|
||||||
font-family: 'Cinzel', serif;
|
font-family: 'Cinzel', serif;
|
||||||
font-size: 11px;
|
font-size: var(--btn-font-md);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.06em;
|
letter-spacing: 0.06em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
background: rgba(180, 40, 40, 0.8);
|
background: rgba(180, 40, 40, 0.8);
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 4px;
|
border-radius: var(--radius-sm);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
padding: 7px 16px;
|
padding: var(--btn-padding-md);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.15s;
|
transition: background 0.15s;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<script>
|
<script lang="ts">
|
||||||
import { API_URL } from '$lib/api.js';
|
import { API_URL } from '$lib/api.js';
|
||||||
import { apiFetch } from '$lib/api.js';
|
import { apiFetch } from '$lib/api.js';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
@@ -9,17 +9,18 @@
|
|||||||
const deckId = $derived($page.params.id);
|
const deckId = $derived($page.params.id);
|
||||||
const token = () => localStorage.getItem('token');
|
const token = () => localStorage.getItem('token');
|
||||||
|
|
||||||
let allCards = $state([]);
|
|
||||||
let selectedIds = $state(new Set());
|
let selectedIds = $state(new Set());
|
||||||
|
let selectedCost = $state(0);
|
||||||
|
let costMap: Map<string, number> = $state(new Map());
|
||||||
let deckName = $state('');
|
let deckName = $state('');
|
||||||
let editingName = $state(false);
|
let editingName = $state(false);
|
||||||
let nameInput = $state('');
|
let nameInput = $state('');
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let saving = $state(false);
|
let saving = $state(false);
|
||||||
|
let nameError = $state('');
|
||||||
|
let saveError = $state('');
|
||||||
|
|
||||||
const selectedCost = $derived(
|
const MAX_NAME = 64;
|
||||||
allCards.filter(c => selectedIds.has(c.id)).reduce((sum, c) => sum + c.cost, 0)
|
|
||||||
);
|
|
||||||
|
|
||||||
function startEditName() {
|
function startEditName() {
|
||||||
nameInput = deckName;
|
nameInput = deckName;
|
||||||
@@ -27,13 +28,20 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function commitName() {
|
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;
|
editingName = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
saving = true;
|
saving = true;
|
||||||
await apiFetch(`${API_URL}/decks/${deckId}`, {
|
saveError = '';
|
||||||
|
const res = await apiFetch(`${API_URL}/decks/${deckId}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -42,26 +50,29 @@
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
saving = false;
|
saving = false;
|
||||||
|
if (!res.ok) {
|
||||||
|
saveError = 'Failed to save deck. Please try again.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
goto('/decks');
|
goto('/decks');
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
if (!token()) { goto('/auth'); return; }
|
if (!token()) { goto('/auth'); return; }
|
||||||
|
|
||||||
const [cardsRes, deckCardsRes] = await Promise.all([
|
const [deckCardsRes, decksRes] = await Promise.all([
|
||||||
apiFetch(`${API_URL}/cards`),
|
|
||||||
apiFetch(`${API_URL}/decks/${deckId}/cards`),
|
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 deckCards = await deckCardsRes.json();
|
||||||
const currentCardIds = await deckCardsRes.json();
|
selectedIds = new Set(deckCards.map((c: any) => c.id));
|
||||||
selectedIds = new Set(currentCardIds);
|
costMap = new Map(deckCards.map((c: any) => [c.id, c.cost]));
|
||||||
|
|
||||||
const decksRes = await apiFetch(`${API_URL}/decks`);
|
|
||||||
const decks = await decksRes.json();
|
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';
|
deckName = deck?.name ?? 'Untitled Deck';
|
||||||
|
|
||||||
loading = false;
|
loading = false;
|
||||||
@@ -71,25 +82,31 @@
|
|||||||
<main>
|
<main>
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<div class="deck-header">
|
<div class="deck-header">
|
||||||
{#if editingName}
|
<div class="name-area">
|
||||||
<input
|
{#if editingName}
|
||||||
class="name-input"
|
<input
|
||||||
bind:value={nameInput}
|
class="name-input"
|
||||||
onblur={commitName}
|
bind:value={nameInput}
|
||||||
onkeydown={e => e.key === 'Enter' && commitName()}
|
onblur={commitName}
|
||||||
autofocus
|
onkeydown={e => e.key === 'Enter' && commitName()}
|
||||||
/>
|
autofocus
|
||||||
{:else}
|
/>
|
||||||
<button class="name-btn" onclick={startEditName}>{deckName} ✎</button>
|
{:else}
|
||||||
{/if}
|
<button class="name-btn" onclick={startEditName}>{deckName} ✎</button>
|
||||||
|
{/if}
|
||||||
|
<p class="field-error">{nameError}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
<span class="card-counter" class:full={selectedCost === 50} class:over={selectedCost > 50} class:empty={selectedIds.size === 0}>
|
<span class="card-counter" class:full={selectedCost === 50} class:over={selectedCost > 50} class:empty={selectedIds.size === 0}>
|
||||||
{selectedIds.size} cards · {selectedCost}/50
|
{selectedIds.size} cards · {selectedCost}/50
|
||||||
</span>
|
</span>
|
||||||
<button class="done-btn" onclick={save} disabled={saving}>
|
<div class="save-area">
|
||||||
{saving ? 'Saving...' : 'Done'}
|
<button class="done-btn" onclick={save} disabled={saving}>
|
||||||
</button>
|
{saving ? 'Saving...' : 'Done'}
|
||||||
|
</button>
|
||||||
|
<p class="field-error">{saveError}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -98,8 +115,9 @@
|
|||||||
<p class="status">Loading...</p>
|
<p class="status">Loading...</p>
|
||||||
{:else}
|
{:else}
|
||||||
<CardSelector
|
<CardSelector
|
||||||
allCards={allCards}
|
|
||||||
bind:selectedIds={selectedIds}
|
bind:selectedIds={selectedIds}
|
||||||
|
bind:selectedCost={selectedCost}
|
||||||
|
bind:costMap={costMap}
|
||||||
costLimit={50}
|
costLimit={50}
|
||||||
showFooter={false}
|
showFooter={false}
|
||||||
/>
|
/>
|
||||||
@@ -107,21 +125,19 @@
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
<style>
|
<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 {
|
main {
|
||||||
height: 100vh;
|
height: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: #0d0a04;
|
background: var(--color-bg);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toolbar {
|
.toolbar {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
background: #0d0a04;
|
background: var(--color-bg);
|
||||||
padding: 1.5rem 2rem 1rem;
|
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 {
|
.deck-header {
|
||||||
@@ -133,9 +149,9 @@
|
|||||||
|
|
||||||
.name-btn {
|
.name-btn {
|
||||||
font-family: 'Cinzel', serif;
|
font-family: 'Cinzel', serif;
|
||||||
font-size: 18px;
|
font-size: var(--text-lg);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #f0d080;
|
color: var(--color-gold);
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -148,12 +164,12 @@
|
|||||||
|
|
||||||
.name-input {
|
.name-input {
|
||||||
font-family: 'Cinzel', serif;
|
font-family: 'Cinzel', serif;
|
||||||
font-size: 18px;
|
font-size: var(--text-lg);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #f0d080;
|
color: var(--color-gold);
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: none;
|
border: none;
|
||||||
border-bottom: 1.5px solid #c8861a;
|
border-bottom: 1.5px solid var(--color-bronze);
|
||||||
outline: none;
|
outline: none;
|
||||||
padding: 0 0 2px 0;
|
padding: 0 0 2px 0;
|
||||||
min-width: 200px;
|
min-width: 200px;
|
||||||
@@ -167,27 +183,27 @@
|
|||||||
|
|
||||||
.card-counter {
|
.card-counter {
|
||||||
font-family: 'Cinzel', serif;
|
font-family: 'Cinzel', serif;
|
||||||
font-size: 14px;
|
font-size: var(--text-base);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: rgba(240, 180, 80, 0.7);
|
color: rgba(240, 180, 80, 0.7);
|
||||||
transition: color 0.2s;
|
transition: color 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-counter.full { color: #6aaa6a; }
|
.card-counter.full { color: var(--color-success); }
|
||||||
.card-counter.over { color: #c85050; }
|
.card-counter.over { color: var(--color-error); }
|
||||||
.card-counter.empty { color: rgba(240, 180, 80, 0.3); }
|
.card-counter.empty { color: rgba(240, 180, 80, 0.3); }
|
||||||
|
|
||||||
.done-btn {
|
.done-btn {
|
||||||
font-family: 'Cinzel', serif;
|
font-family: 'Cinzel', serif;
|
||||||
font-size: 11px;
|
font-size: var(--btn-font-md);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.06em;
|
letter-spacing: 0.06em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
background: #3d2507;
|
background: var(--color-surface-raised);
|
||||||
border: 1px solid #c8861a;
|
border: 1px solid var(--color-bronze);
|
||||||
border-radius: 4px;
|
border-radius: var(--radius-md);
|
||||||
color: #f0d080;
|
color: var(--color-gold);
|
||||||
padding: 6px 16px;
|
padding: var(--btn-padding-md);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.15s;
|
transition: background 0.15s;
|
||||||
}
|
}
|
||||||
@@ -195,9 +211,30 @@
|
|||||||
.done-btn:hover:not(:disabled) { background: #5a3510; }
|
.done-btn:hover:not(:disabled) { background: #5a3510; }
|
||||||
.done-btn:disabled { opacity: 0.5; cursor: default; }
|
.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 {
|
.status {
|
||||||
font-family: 'Crimson Text', serif;
|
font-family: 'Crimson Text', serif;
|
||||||
font-size: 16px;
|
font-size: var(--text-md);
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
color: rgba(240, 180, 80, 0.5);
|
color: rgba(240, 180, 80, 0.5);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|||||||
@@ -48,11 +48,9 @@
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
<style>
|
<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 {
|
main {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background: #0d0a04;
|
background: var(--color-bg);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -61,9 +59,9 @@
|
|||||||
|
|
||||||
.card {
|
.card {
|
||||||
width: 340px;
|
width: 340px;
|
||||||
background: #2e1c05;
|
background: var(--color-surface);
|
||||||
border: 2px solid #6b4c1e;
|
border: 2px solid var(--color-bronze);
|
||||||
border-radius: 12px;
|
border-radius: var(--radius-xl);
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -72,16 +70,16 @@
|
|||||||
|
|
||||||
.title {
|
.title {
|
||||||
font-family: 'Cinzel', serif;
|
font-family: 'Cinzel', serif;
|
||||||
font-size: 20px;
|
font-size: var(--text-xl);
|
||||||
color: #f0d080;
|
color: var(--color-gold);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hint {
|
.hint {
|
||||||
font-family: 'Crimson Text', serif;
|
font-family: 'Crimson Text', serif;
|
||||||
font-size: 15px;
|
font-size: var(--text-md);
|
||||||
color: rgba(245, 208, 96, 0.7);
|
color: var(--color-gold-dim);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
@@ -90,38 +88,41 @@
|
|||||||
input {
|
input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 9px 12px;
|
padding: 9px 12px;
|
||||||
background: #1a1008;
|
background: var(--color-surface);
|
||||||
border: 1.5px solid #8b6420;
|
border: 1.5px solid var(--color-border);
|
||||||
border-radius: 6px;
|
border-radius: var(--radius-md);
|
||||||
color: #f0d080;
|
color: var(--color-gold);
|
||||||
font-family: 'Crimson Text', serif;
|
font-family: 'Crimson Text', serif;
|
||||||
font-size: 15px;
|
font-size: var(--text-md);
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
input::placeholder {
|
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 {
|
button {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 10px;
|
padding: var(--btn-padding-lg);
|
||||||
background: #6b4c1e;
|
background: var(--color-bronze);
|
||||||
color: #f0d080;
|
color: var(--color-btn-text);
|
||||||
border: 1.5px solid #8b6420;
|
border: none;
|
||||||
border-radius: 6px;
|
border-radius: var(--radius-md);
|
||||||
font-family: 'Cinzel', serif;
|
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;
|
cursor: pointer;
|
||||||
transition: background 0.15s;
|
transition: background 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
button:hover:not(:disabled) {
|
button:hover:not(:disabled) {
|
||||||
background: #8b6420;
|
background: var(--color-bronze-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
button:disabled {
|
button:disabled {
|
||||||
@@ -131,19 +132,19 @@
|
|||||||
|
|
||||||
.back-link {
|
.back-link {
|
||||||
font-family: 'Crimson Text', serif;
|
font-family: 'Crimson Text', serif;
|
||||||
font-size: 14px;
|
font-size: var(--text-base);
|
||||||
color: rgba(245, 208, 96, 0.5);
|
color: var(--color-gold-faint);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
transition: color 0.15s;
|
transition: color 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.back-link:hover { color: #f5d060; }
|
.back-link:hover { color: var(--color-gold); }
|
||||||
|
|
||||||
.error {
|
.error {
|
||||||
font-family: 'Crimson Text', serif;
|
font-family: 'Crimson Text', serif;
|
||||||
font-size: 14px;
|
font-size: var(--text-base);
|
||||||
color: #f06060;
|
color: var(--color-error);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
min-height: 1.4em;
|
min-height: 1.4em;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|||||||
@@ -71,11 +71,9 @@
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
<style>
|
<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 {
|
main {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background: #0d0a04;
|
background: var(--color-bg);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -84,9 +82,9 @@
|
|||||||
|
|
||||||
.card {
|
.card {
|
||||||
width: 380px;
|
width: 380px;
|
||||||
background: #3d2507;
|
background: var(--color-surface);
|
||||||
border: 2px solid #c8861a;
|
border: 2px solid var(--color-bronze);
|
||||||
border-radius: 12px;
|
border-radius: var(--radius-xl);
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -95,17 +93,17 @@
|
|||||||
|
|
||||||
.title {
|
.title {
|
||||||
font-family: 'Cinzel', serif;
|
font-family: 'Cinzel', serif;
|
||||||
font-size: 20px;
|
font-size: var(--text-xl);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #f5d060;
|
color: var(--color-gold);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hint {
|
.hint {
|
||||||
font-family: 'Crimson Text', serif;
|
font-family: 'Crimson Text', serif;
|
||||||
font-size: 15px;
|
font-size: var(--text-md);
|
||||||
color: rgba(245, 208, 96, 0.7);
|
color: var(--color-gold-dim);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
@@ -119,52 +117,54 @@
|
|||||||
|
|
||||||
.field-label {
|
.field-label {
|
||||||
font-family: 'Cinzel', serif;
|
font-family: 'Cinzel', serif;
|
||||||
font-size: 10px;
|
font-size: var(--text-xs);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.08em;
|
letter-spacing: 0.08em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: rgba(245, 208, 96, 0.5);
|
color: var(--color-gold-faint);
|
||||||
}
|
}
|
||||||
|
|
||||||
input {
|
input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 9px 12px;
|
padding: 9px 12px;
|
||||||
background: #221508;
|
background: var(--color-surface);
|
||||||
border: 1.5px solid #c8861a;
|
border: 1.5px solid var(--color-border);
|
||||||
border-radius: 6px;
|
border-radius: var(--radius-md);
|
||||||
color: #f5d060;
|
color: var(--color-gold);
|
||||||
font-family: 'Crimson Text', serif;
|
font-family: 'Crimson Text', serif;
|
||||||
font-size: 15px;
|
font-size: var(--text-md);
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
outline: none;
|
outline: none;
|
||||||
margin-bottom: 0.4rem;
|
margin-bottom: 0.4rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
input:focus { border-color: #f5d060; }
|
input:focus { border-color: var(--color-bronze); }
|
||||||
input::placeholder { color: rgba(245, 208, 96, 0.35); }
|
input::placeholder { color: var(--color-gold-faint); }
|
||||||
|
|
||||||
.btn {
|
.btn {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 10px;
|
padding: var(--btn-padding-lg);
|
||||||
background: #c8861a;
|
background: var(--color-bronze);
|
||||||
color: #fff8e0;
|
color: var(--color-btn-text);
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 6px;
|
border-radius: var(--radius-md);
|
||||||
font-family: 'Cinzel', serif;
|
font-family: 'Cinzel', serif;
|
||||||
font-size: 13px;
|
font-size: var(--btn-font-lg);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.15s;
|
transition: background 0.15s;
|
||||||
display: block;
|
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; }
|
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
|
||||||
.error {
|
.error {
|
||||||
font-family: 'Crimson Text', serif;
|
font-family: 'Crimson Text', serif;
|
||||||
font-size: 14px;
|
font-size: var(--text-base);
|
||||||
color: #f06060;
|
color: var(--color-error);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
min-height: 1.4em;
|
min-height: 1.4em;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script>
|
<script>
|
||||||
import { onMount } from 'svelte';
|
import Card from '$lib/Card.svelte';
|
||||||
|
|
||||||
// A fake card for display purposes
|
// A fake card for display purposes
|
||||||
const exampleCard = {
|
const exampleCard = {
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
attack: 351,
|
attack: 351,
|
||||||
defense: 222,
|
defense: 222,
|
||||||
cost: 5,
|
cost: 5,
|
||||||
created_at: new Date().toISOString(),
|
generated_at: new Date().toISOString(),
|
||||||
reported: false,
|
reported: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -48,34 +48,7 @@
|
|||||||
<div class="card-explainer">
|
<div class="card-explainer">
|
||||||
<div class="card-annotated">
|
<div class="card-annotated">
|
||||||
<div class="card-display">
|
<div class="card-display">
|
||||||
<!-- Inline card rendering matching Card.svelte visuals -->
|
<Card card={exampleCard} noHover={true} />
|
||||||
<div class="demo-card">
|
|
||||||
<div class="demo-inner" style="--bg: #f0e0c8; --header: #b87830">
|
|
||||||
<div class="demo-header">
|
|
||||||
<span class="demo-name">{exampleCard.name}</span>
|
|
||||||
<span class="demo-type-badge">Person</span>
|
|
||||||
</div>
|
|
||||||
<div class="demo-image-wrap">
|
|
||||||
<img src={exampleCard.image_link} alt={exampleCard.name} class="demo-image" />
|
|
||||||
<div class="demo-rarity" style="background: #2a5a9b; color: #fff">R</div>
|
|
||||||
<a href="https://en.wikipedia.org/wiki/Harald_Bluetooth" target="_blank" rel="noopener" class="demo-wiki">
|
|
||||||
<svg viewBox="0 0 50 50" width="14" height="14"><circle cx="25" cy="25" r="24" fill="white" stroke="#888" stroke-width="1"/><text x="25" y="33" text-anchor="middle" font-family="serif" font-size="28" font-weight="bold" fill="#000">W</text></svg>
|
|
||||||
</a>
|
|
||||||
<div class="demo-cost-bubbles">
|
|
||||||
{#each { length: exampleCard.cost } as _}
|
|
||||||
<div class="demo-cost-bubble">✦</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="demo-divider"></div>
|
|
||||||
<div class="demo-text">{exampleCard.text}</div>
|
|
||||||
<div class="demo-footer" style="background: #e8d8b8">
|
|
||||||
<span class="demo-stat">ATK <strong>{exampleCard.attack}</strong></span>
|
|
||||||
<span class="demo-date">{new Date(exampleCard.created_at).toLocaleDateString()}</span>
|
|
||||||
<span class="demo-stat">DEF <strong>{exampleCard.defense}</strong></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Annotation markers -->
|
<!-- Annotation markers -->
|
||||||
@@ -195,12 +168,10 @@
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
<style>
|
<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 {
|
main {
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
background: #0d0a04;
|
background: var(--color-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.content {
|
.content {
|
||||||
@@ -211,11 +182,12 @@
|
|||||||
|
|
||||||
.page-title {
|
.page-title {
|
||||||
font-family: 'Cinzel', serif;
|
font-family: 'Cinzel', serif;
|
||||||
font-size: 28px;
|
font-size: clamp(22px, 4vw, 32px);
|
||||||
font-weight: 700;
|
font-weight: 900;
|
||||||
color: #f0d080;
|
color: var(--color-gold);
|
||||||
margin: 0 0 2rem;
|
margin: 0 0 2rem;
|
||||||
letter-spacing: 0.08em;
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
.section {
|
.section {
|
||||||
@@ -224,14 +196,14 @@
|
|||||||
|
|
||||||
.section-title {
|
.section-title {
|
||||||
font-family: 'Cinzel', serif;
|
font-family: 'Cinzel', serif;
|
||||||
font-size: 14px;
|
font-size: var(--text-base);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.1em;
|
letter-spacing: 0.1em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: #f0d080AA;
|
color: var(--color-gold-dim);
|
||||||
margin: 0 0 1.25rem;
|
margin: 0 0 1.25rem;
|
||||||
padding-bottom: 0.5rem;
|
padding-bottom: 0.5rem;
|
||||||
border-bottom: 1px solid #f0d08055;
|
border-bottom: 1px solid var(--color-gold-faint);
|
||||||
}
|
}
|
||||||
|
|
||||||
.body-text ul {
|
.body-text ul {
|
||||||
@@ -240,8 +212,8 @@
|
|||||||
|
|
||||||
.body-text {
|
.body-text {
|
||||||
font-family: 'Crimson Text', serif;
|
font-family: 'Crimson Text', serif;
|
||||||
font-size: 17px;
|
font-size: var(--text-lg);
|
||||||
color: rgba(240, 180, 80, 0.75);
|
color: var(--color-gold-muted);
|
||||||
line-height: 1.7;
|
line-height: 1.7;
|
||||||
margin: 0 0 1rem;
|
margin: 0 0 1rem;
|
||||||
}
|
}
|
||||||
@@ -272,23 +244,23 @@
|
|||||||
.marker {
|
.marker {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
transform: translate(-50%, -50%);
|
transform: translate(-50%, -50%);
|
||||||
z-index: 10;
|
z-index: var(--z-card);
|
||||||
}
|
}
|
||||||
|
|
||||||
.marker-bubble {
|
.marker-bubble {
|
||||||
width: 22px;
|
width: 22px;
|
||||||
height: 22px;
|
height: 22px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: #c8861a;
|
background: var(--color-bronze);
|
||||||
border: 2px solid #fff;
|
border: 2px solid var(--color-btn-text);
|
||||||
color: #fff;
|
color: var(--color-btn-text);
|
||||||
font-family: 'Cinzel', serif;
|
font-family: 'Cinzel', serif;
|
||||||
font-size: 11px;
|
font-size: var(--text-sm);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
box-shadow: 0 2px 8px rgba(0,0,0,0.6);
|
box-shadow: var(--shadow-subtle);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Annotation list ── */
|
/* ── Annotation list ── */
|
||||||
@@ -313,10 +285,10 @@
|
|||||||
width: 22px;
|
width: 22px;
|
||||||
height: 22px;
|
height: 22px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: #c8861a;
|
background: var(--color-bronze);
|
||||||
color: #fff;
|
color: var(--color-btn-text);
|
||||||
font-family: 'Cinzel', serif;
|
font-family: 'Cinzel', serif;
|
||||||
font-size: 11px;
|
font-size: var(--text-sm);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -333,16 +305,16 @@
|
|||||||
|
|
||||||
.annotation-label {
|
.annotation-label {
|
||||||
font-family: 'Cinzel', serif;
|
font-family: 'Cinzel', serif;
|
||||||
font-size: 12px;
|
font-size: var(--text-sm);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #f0d080;
|
color: var(--color-gold);
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.04em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.annotation-desc {
|
.annotation-desc {
|
||||||
font-family: 'Crimson Text', serif;
|
font-family: 'Crimson Text', serif;
|
||||||
font-size: 14px;
|
font-size: var(--text-base);
|
||||||
color: rgba(240, 180, 80, 0.6);
|
color: var(--color-gold-dim);
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -350,13 +322,13 @@
|
|||||||
.rules-grid {
|
.rules-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
gap: 12px;
|
gap: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rule-card {
|
.rule-card {
|
||||||
background: #1a1008;
|
background: var(--color-surface);
|
||||||
border: 1px solid rgba(107, 76, 30, 0.3);
|
border: 1px solid var(--color-border-dim);
|
||||||
border-radius: 8px;
|
border-radius: var(--radius-lg);
|
||||||
padding: 1.25rem;
|
padding: 1.25rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -364,198 +336,27 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.rule-icon {
|
.rule-icon {
|
||||||
color: #f0d080AA;
|
color: var(--color-gold-dim);
|
||||||
font-size: 20px;
|
font-size: var(--text-xl);
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rule-title {
|
.rule-title {
|
||||||
font-family: 'Cinzel', serif;
|
font-family: 'Cinzel', serif;
|
||||||
font-size: 12px;
|
font-size: var(--text-sm);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.06em;
|
letter-spacing: 0.06em;
|
||||||
color: #f0d080;
|
color: var(--color-gold);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rule-body {
|
.rule-body {
|
||||||
font-family: 'Crimson Text', serif;
|
font-family: 'Crimson Text', serif;
|
||||||
font-size: 14px;
|
font-size: var(--text-base);
|
||||||
color: rgba(240, 180, 80, 0.6);
|
color: var(--color-gold-dim);
|
||||||
line-height: 1.55;
|
line-height: 1.55;
|
||||||
margin: 0;
|
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>
|
</style>
|
||||||
@@ -1,24 +1,30 @@
|
|||||||
<script>
|
<script lang="ts">
|
||||||
import { API_URL, WS_URL } from '$lib/api.js';
|
import { API_URL, WS_URL } from '$lib/api.js';
|
||||||
import { apiFetch } from '$lib/api.js';
|
import { apiFetch } from '$lib/api.js';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import { get } from 'svelte/store';
|
||||||
import { onMount, onDestroy } from 'svelte';
|
import { onMount, onDestroy } from 'svelte';
|
||||||
import Card from '$lib/Card.svelte';
|
import Card from '$lib/Card.svelte';
|
||||||
import DeckTypeBadge from '$lib/DeckTypeBadge.svelte';
|
import DeckTypeBadge from '$lib/DeckTypeBadge.svelte';
|
||||||
|
import { play } from '$lib/audio.js';
|
||||||
|
|
||||||
const token = () => localStorage.getItem('token');
|
const token = () => localStorage.getItem('token');
|
||||||
|
|
||||||
let queueWs = null;
|
let queueWs: WebSocket | null = null;
|
||||||
let gameWs = null;
|
let gameWs: WebSocket | null = null;
|
||||||
let phase = $state('idle');
|
let phase = $state('idle');
|
||||||
let error = $state('');
|
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 selectedDeckId = $state('');
|
||||||
let selectedDeck = $derived(decks.find(d => d.id === selectedDeckId));
|
let selectedDeck = $derived(decks.find(d => d.id === selectedDeckId));
|
||||||
|
|
||||||
let gameId = $state('');
|
let gameId = $state('');
|
||||||
let gameState = $state(null);
|
let gameState: any = $state(null);
|
||||||
let myId = $state('');
|
let myId = $state('');
|
||||||
|
|
||||||
let viewingBoard = $state(false);
|
let viewingBoard = $state(false);
|
||||||
@@ -34,7 +40,7 @@
|
|||||||
'Expert'
|
'Expert'
|
||||||
);
|
);
|
||||||
|
|
||||||
let selectedHandIndex = $state(null);
|
let selectedHandIndex: number | null = $state(null);
|
||||||
let combatAnimating = $state(false);
|
let combatAnimating = $state(false);
|
||||||
let lunging = $state(new Set());
|
let lunging = $state(new Set());
|
||||||
let lungingDown = $state(new Set());
|
let lungingDown = $state(new Set());
|
||||||
@@ -46,24 +52,25 @@
|
|||||||
let gameOver = $derived(!!gameState?.result);
|
let gameOver = $derived(!!gameState?.result);
|
||||||
let sacrificeMode = $state(false);
|
let sacrificeMode = $state(false);
|
||||||
|
|
||||||
let displayedDefense = $state({});
|
let displayedDefense: Record<string, number> = $state({});
|
||||||
let destroying = $state(new Set());
|
let destroying = $state(new Set());
|
||||||
let destroyed = $state(new Set());
|
let destroyed = $state(new Set());
|
||||||
let displayedLife = $state({});
|
let displayedLife: Record<string, number> = $state({});
|
||||||
|
|
||||||
const TURN_TIME_LIMIT = 120; // seconds
|
const TURN_TIME_LIMIT = 120; // seconds
|
||||||
const TIMER_WARNING = 30; // show timer when this many seconds remain
|
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 secondsRemaining = $state(TURN_TIME_LIMIT);
|
||||||
let timerInterval = null
|
let timerInterval: number | undefined = undefined;
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!gameState?.turn_started_at) return;
|
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);
|
clearInterval(timerInterval);
|
||||||
timerInterval = setInterval(async () => {
|
timerInterval = setInterval(async () => {
|
||||||
const elapsed = (Date.now() - turnStartedAt) / 1000;
|
const elapsed = (Date.now() - ts.getTime()) / 1000;
|
||||||
secondsRemaining = Math.max(0, TURN_TIME_LIMIT - elapsed);
|
secondsRemaining = Math.max(0, TURN_TIME_LIMIT - elapsed);
|
||||||
|
|
||||||
if (secondsRemaining <= 0 && !isMyTurn && gameState && !gameState.result) {
|
if (secondsRemaining <= 0 && !isMyTurn && gameState && !gameState.result) {
|
||||||
@@ -75,6 +82,7 @@
|
|||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
clearInterval(timerInterval);
|
clearInterval(timerInterval);
|
||||||
|
clearTimeout(gameReconnectTimer);
|
||||||
});
|
});
|
||||||
|
|
||||||
async function claimTimeoutWin() {
|
async function claimTimeoutWin() {
|
||||||
@@ -103,7 +111,7 @@
|
|||||||
...(gameState.you.board.filter(Boolean) || []),
|
...(gameState.you.board.filter(Boolean) || []),
|
||||||
...(gameState.opponent.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;
|
for (const card of all) next[card.instance_id] = card.defense;
|
||||||
displayedDefense = next;
|
displayedDefense = next;
|
||||||
});
|
});
|
||||||
@@ -115,6 +123,21 @@
|
|||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
if (!token()) { goto('/auth'); return; }
|
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`);
|
const res = await apiFetch(`${API_URL}/decks`);
|
||||||
decks = await res.json();
|
decks = await res.json();
|
||||||
if (decks.length > 0) selectedDeckId = decks[0].id;
|
if (decks.length > 0) selectedDeckId = decks[0].id;
|
||||||
@@ -130,12 +153,12 @@
|
|||||||
error = '';
|
error = '';
|
||||||
phase = 'queuing';
|
phase = 'queuing';
|
||||||
queueWs = new WebSocket(`${WS_URL}/ws/queue?deck_id=${selectedDeckId}`);
|
queueWs = new WebSocket(`${WS_URL}/ws/queue?deck_id=${selectedDeckId}`);
|
||||||
queueWs.onopen = () => queueWs.send(token());
|
queueWs.onopen = () => queueWs!.send(token()!);
|
||||||
queueWs.onmessage = (e) => {
|
queueWs.onmessage = (e) => {
|
||||||
const msg = JSON.parse(e.data);
|
const msg = JSON.parse(e.data);
|
||||||
if (msg.type === 'game_start') {
|
if (msg.type === 'game_start') {
|
||||||
gameId = msg.game_id;
|
gameId = msg.game_id;
|
||||||
queueWs.close();
|
queueWs!.close();
|
||||||
connectToGame();
|
connectToGame();
|
||||||
} else if (msg.type === 'error') {
|
} else if (msg.type === 'error') {
|
||||||
error = msg.message;
|
error = msg.message;
|
||||||
@@ -147,7 +170,11 @@
|
|||||||
|
|
||||||
function connectToGame() {
|
function connectToGame() {
|
||||||
gameWs = new WebSocket(`${WS_URL}/ws/game/${gameId}`);
|
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) => {
|
gameWs.onmessage = async (e) => {
|
||||||
const msg = JSON.parse(e.data);
|
const msg = JSON.parse(e.data);
|
||||||
if (msg.type === 'state') {
|
if (msg.type === 'state') {
|
||||||
@@ -165,6 +192,7 @@
|
|||||||
phase = newState.result ? 'ended' : 'playing';
|
phase = newState.result ? 'ended' : 'playing';
|
||||||
} else if (msg.type === 'sacrifice_animation') {
|
} else if (msg.type === 'sacrifice_animation') {
|
||||||
const id = msg.instance_id;
|
const id = msg.instance_id;
|
||||||
|
play('cardShatter');
|
||||||
destroying = new Set([...destroying, id]);
|
destroying = new Set([...destroying, id]);
|
||||||
await delay(600);
|
await delay(600);
|
||||||
destroying = new Set([...destroying].filter(i => i !== id));
|
destroying = new Set([...destroying].filter(i => i !== id));
|
||||||
@@ -174,10 +202,26 @@
|
|||||||
setTimeout(() => error = '', 3000);
|
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'; };
|
gameWs.onerror = () => { error = 'Connection lost'; };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function animateCombat(newState) {
|
async function animateCombat(newState: any) {
|
||||||
combatAnimating = true;
|
combatAnimating = true;
|
||||||
|
|
||||||
// The attacker is whoever was active when end_turn was called.
|
// The attacker is whoever was active when end_turn was called.
|
||||||
@@ -187,7 +231,7 @@
|
|||||||
// active_player_id hasn't switched yet.
|
// active_player_id hasn't switched yet.
|
||||||
const attackerId = newState.result
|
const attackerId = newState.result
|
||||||
? newState.active_player_id
|
? 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;
|
const attackerIsMe = attackerId === myId;
|
||||||
|
|
||||||
@@ -206,7 +250,11 @@
|
|||||||
} else {
|
} else {
|
||||||
lungingDown = new Set([...lungingDown, attacker.instance_id]);
|
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);
|
await delay(220);
|
||||||
if (defender) {
|
if (defender) {
|
||||||
const newDefense = Math.max(0, (displayedDefense[defender.instance_id] ?? defender.defense) - attacker.attack);
|
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));
|
lungingDown = new Set([...lungingDown].filter(id => id !== attacker.instance_id));
|
||||||
if (defender) shaking = new Set([...shaking].filter(id => id !== defender.instance_id));
|
if (defender) shaking = new Set([...shaking].filter(id => id !== defender.instance_id));
|
||||||
if (defender && (displayedDefense[defender.instance_id] ?? defender.defense) <= 0) {
|
if (defender && (displayedDefense[defender.instance_id] ?? defender.defense) <= 0) {
|
||||||
|
play('cardShatter');
|
||||||
destroying = new Set([...destroying, defender.instance_id]);
|
destroying = new Set([...destroying, defender.instance_id]);
|
||||||
await delay(600);
|
await delay(600);
|
||||||
destroying = new Set([...destroying].filter(id => id !== defender.instance_id));
|
destroying = new Set([...destroying].filter(id => id !== defender.instance_id));
|
||||||
@@ -234,25 +283,27 @@
|
|||||||
combatAnimating = false;
|
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;
|
if (!isMyTurn || combatAnimating) return;
|
||||||
selectedHandIndex = selectedHandIndex === index ? null : index;
|
selectedHandIndex = selectedHandIndex === index ? null : index;
|
||||||
}
|
}
|
||||||
|
|
||||||
function clickSlot(slot) {
|
function clickSlot(slot: number) {
|
||||||
if (!isMyTurn || combatAnimating || selectedHandIndex === null) return;
|
if (!isMyTurn || combatAnimating || selectedHandIndex === null) return;
|
||||||
|
play('cardPlay');
|
||||||
send({ type: 'play_card', hand_index: selectedHandIndex, slot });
|
send({ type: 'play_card', hand_index: selectedHandIndex, slot });
|
||||||
selectedHandIndex = null;
|
selectedHandIndex = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sacrifice(slot) {
|
async function sacrifice(slot: number) {
|
||||||
if (!isMyTurn || combatAnimating) return;
|
if (!isMyTurn || combatAnimating) return;
|
||||||
const card = me.board[slot];
|
const card = me.board[slot];
|
||||||
if (!card) return;
|
if (!card) return;
|
||||||
|
play('cardShatter');
|
||||||
destroying = new Set([...destroying, card.instance_id]);
|
destroying = new Set([...destroying, card.instance_id]);
|
||||||
await delay(600);
|
await delay(600);
|
||||||
destroying = new Set([...destroying].filter(id => id !== card.instance_id));
|
destroying = new Set([...destroying].filter(id => id !== card.instance_id));
|
||||||
@@ -267,7 +318,7 @@
|
|||||||
send({ type: 'end_turn' });
|
send({ type: 'end_turn' });
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleHandCardMouseMove(e, node) {
|
function handleHandCardMouseMove(e: MouseEvent, node: HTMLElement) {
|
||||||
const rect = node.getBoundingClientRect();
|
const rect = node.getBoundingClientRect();
|
||||||
const cy = rect.top + rect.height / 2;
|
const cy = rect.top + rect.height / 2;
|
||||||
const dy = (e.clientY - cy) / (rect.height / 2);
|
const dy = (e.clientY - cy) / (rect.height / 2);
|
||||||
@@ -275,7 +326,7 @@
|
|||||||
node.style.setProperty('--peek-y', `${ty}px`);
|
node.style.setProperty('--peek-y', `${ty}px`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleHandCardMouseLeave(node) {
|
function handleHandCardMouseLeave(node: HTMLElement) {
|
||||||
node.style.setProperty('--peek-y', '0px');
|
node.style.setProperty('--peek-y', '0px');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -337,10 +388,17 @@
|
|||||||
|
|
||||||
{:else if (phase === 'playing' || (phase === 'ended' && viewingBoard)) && gameState}
|
{:else if (phase === 'playing' || (phase === 'ended' && viewingBoard)) && gameState}
|
||||||
<div class="game">
|
<div class="game">
|
||||||
|
{#if reconnecting}
|
||||||
|
<div class="reconnecting-banner">Reconnecting...</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div class="sidebar left-sidebar">
|
<div class="sidebar left-sidebar">
|
||||||
<div class="sidebar-section top-section">
|
<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} />
|
<DeckTypeBadge deckType={opp.deck_type} />
|
||||||
<div class="sidebar-life">♥ {displayedLife[opp.user_id] ?? opp.life}</div>
|
<div class="sidebar-life">♥ {displayedLife[opp.user_id] ?? opp.life}</div>
|
||||||
<div class="sidebar-deck">Deck: {opp.deck_size}</div>
|
<div class="sidebar-deck">Deck: {opp.deck_size}</div>
|
||||||
@@ -389,7 +447,7 @@
|
|||||||
|
|
||||||
<div class="divider">
|
<div class="divider">
|
||||||
<span class="turn-indicator" class:my-turn={isMyTurn}>
|
<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>
|
</span>
|
||||||
{#if secondsRemaining <= TIMER_WARNING}
|
{#if secondsRemaining <= TIMER_WARNING}
|
||||||
<span class="turn-timer" class:urgent={secondsRemaining <= 10}>
|
<span class="turn-timer" class:urgent={secondsRemaining <= 10}>
|
||||||
@@ -436,7 +494,7 @@
|
|||||||
|
|
||||||
<div class="sidebar right-sidebar">
|
<div class="sidebar right-sidebar">
|
||||||
{#if phase === 'ended'}
|
{#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}
|
{:else if isMyTurn && !combatAnimating}
|
||||||
<button class="end-turn-btn" onclick={endTurn}>End Turn</button>
|
<button class="end-turn-btn" onclick={endTurn}>End Turn</button>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -472,7 +530,7 @@
|
|||||||
<p class="lobby-hint">{gameState.result.reason}</p>
|
<p class="lobby-hint">{gameState.result.reason}</p>
|
||||||
<div class="lobby-buttons">
|
<div class="lobby-buttons">
|
||||||
<button class="play-btn" onclick={() => viewingBoard = true}>View Board</button>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -506,12 +564,10 @@
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
<style>
|
<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 {
|
main {
|
||||||
height: 100vh;
|
height: 100dvh;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: #0d0a04;
|
background: var(--color-bg);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
@@ -529,21 +585,21 @@
|
|||||||
|
|
||||||
.lobby-title {
|
.lobby-title {
|
||||||
font-family: 'Cinzel', serif;
|
font-family: 'Cinzel', serif;
|
||||||
font-size: 32px;
|
font-size: var(--text-3xl);
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
color: #f0d080;
|
color: var(--color-gold);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
letter-spacing: 0.1em;
|
letter-spacing: 0.1em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.lobby-title.win { color: #6aaa6a; }
|
.lobby-title.win { color: var(--color-success); }
|
||||||
.lobby-title.lose { color: #c85050; }
|
.lobby-title.lose { color: var(--color-error); }
|
||||||
|
|
||||||
.how-to-play-link {
|
.how-to-play-link {
|
||||||
font-family: 'Crimson Text', serif;
|
font-family: 'Crimson Text', serif;
|
||||||
font-size: 14px;
|
font-size: var(--text-base);
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
color: rgba(240, 180, 80, 0.4);
|
color: var(--color-gold-faint);
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
transition: color 0.15s;
|
transition: color 0.15s;
|
||||||
margin-top: -1rem;
|
margin-top: -1rem;
|
||||||
@@ -553,24 +609,24 @@
|
|||||||
|
|
||||||
.lobby-hint {
|
.lobby-hint {
|
||||||
font-family: 'Crimson Text', serif;
|
font-family: 'Crimson Text', serif;
|
||||||
font-size: 16px;
|
font-size: var(--text-md);
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
color: rgba(240, 180, 80, 0.6);
|
color: var(--color-gold-dim);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.lobby-hint a { color: #f0d080; }
|
.lobby-hint a { color: var(--color-gold); }
|
||||||
|
|
||||||
.final-life {
|
.final-life {
|
||||||
font-family: 'Cinzel', serif;
|
font-family: 'Cinzel', serif;
|
||||||
font-size: 13px;
|
font-size: var(--text-base);
|
||||||
color: rgba(240, 180, 80, 0.5);
|
color: rgba(240, 180, 80, 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
.deck-label {
|
.deck-label {
|
||||||
font-family: 'Cinzel', serif;
|
font-family: 'Cinzel', serif;
|
||||||
font-size: 11px;
|
font-size: var(--text-sm);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.08em;
|
letter-spacing: 0.08em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -583,11 +639,11 @@
|
|||||||
|
|
||||||
select {
|
select {
|
||||||
font-family: 'Crimson Text', serif;
|
font-family: 'Crimson Text', serif;
|
||||||
font-size: 15px;
|
font-size: var(--text-md);
|
||||||
color: #f0d080;
|
color: var(--color-gold);
|
||||||
background: #1a1008;
|
background: var(--color-surface);
|
||||||
border: 1.5px solid #6b4c1e;
|
border: 1.5px solid var(--color-border);
|
||||||
border-radius: 6px;
|
border-radius: var(--radius-md);
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
outline: none;
|
outline: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -596,27 +652,28 @@
|
|||||||
|
|
||||||
.play-btn, .cancel-btn {
|
.play-btn, .cancel-btn {
|
||||||
font-family: 'Cinzel', serif;
|
font-family: 'Cinzel', serif;
|
||||||
font-size: 13px;
|
font-size: var(--btn-font-lg);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.08em;
|
letter-spacing: 0.08em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
padding: 10px 32px;
|
padding: var(--btn-padding-lg);
|
||||||
border-radius: 6px;
|
border-radius: var(--radius-md);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.15s;
|
transition: background 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.play-btn {
|
.play-btn {
|
||||||
background: #c8861a;
|
background: var(--color-surface-raised);
|
||||||
color: #fff8e0;
|
color: var(--color-gold);
|
||||||
border: none;
|
border: 1.5px solid var(--color-bronze);
|
||||||
}
|
}
|
||||||
|
|
||||||
.play-btn:hover:not(:disabled) { background: #e09820; }
|
.play-btn:hover:not(:disabled) { background: #5a3510; }
|
||||||
|
|
||||||
.play-btn:disabled {
|
.play-btn:disabled {
|
||||||
background: #6b4c1e;
|
background: var(--color-surface-raised);
|
||||||
color: rgba(255, 248, 224, 0.4);
|
color: rgba(240, 180, 80, 0.3);
|
||||||
|
border-color: rgba(200, 134, 26, 0.3);
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -626,34 +683,35 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.solo-btn {
|
.solo-btn {
|
||||||
background: #2a3d20;
|
background: none;
|
||||||
border: 1px solid #5a8a40;
|
border: 1px solid rgba(107, 76, 30, 0.5);
|
||||||
color: #a8d880;
|
color: rgba(240, 180, 80, 0.7);
|
||||||
}
|
}
|
||||||
|
|
||||||
.solo-btn:hover:not(:disabled) {
|
.solo-btn:hover:not(:disabled) {
|
||||||
background: #3a5a2a;
|
border-color: var(--color-bronze);
|
||||||
|
color: var(--color-gold);
|
||||||
}
|
}
|
||||||
|
|
||||||
.solo-btn:disabled {
|
.solo-btn:disabled {
|
||||||
background: #1a2510;
|
background: none;
|
||||||
color: rgba(168, 216, 128, 0.3);
|
color: rgba(240, 180, 80, 0.25);
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
border-color: #3a5a2a;
|
border-color: rgba(107, 76, 30, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.cancel-btn {
|
.cancel-btn {
|
||||||
background: none;
|
background: none;
|
||||||
color: rgba(240, 180, 80, 0.6);
|
color: var(--color-gold-dim);
|
||||||
border: 1px solid rgba(107, 76, 30, 0.4);
|
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 {
|
.error {
|
||||||
font-family: 'Crimson Text', serif;
|
font-family: 'Crimson Text', serif;
|
||||||
font-size: 14px;
|
font-size: var(--text-base);
|
||||||
color: #c85050;
|
color: var(--color-error);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
height: 1.4em;
|
height: 1.4em;
|
||||||
margin-top: -1rem;
|
margin-top: -1rem;
|
||||||
@@ -664,8 +722,8 @@
|
|||||||
width: 40px;
|
width: 40px;
|
||||||
height: 40px;
|
height: 40px;
|
||||||
border: 3px solid rgba(200, 134, 26, 0.2);
|
border: 3px solid rgba(200, 134, 26, 0.2);
|
||||||
border-top-color: #c8861a;
|
border-top-color: var(--color-bronze);
|
||||||
border-radius: 50%;
|
border-radius: var(--radius-full);
|
||||||
animation: spin 0.8s linear infinite;
|
animation: spin 0.8s linear infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -673,6 +731,7 @@
|
|||||||
|
|
||||||
/* ── Game layout ── */
|
/* ── Game layout ── */
|
||||||
.game {
|
.game {
|
||||||
|
position: relative;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
@@ -717,7 +776,7 @@
|
|||||||
|
|
||||||
.sidebar-name {
|
.sidebar-name {
|
||||||
font-family: 'Cinzel', serif;
|
font-family: 'Cinzel', serif;
|
||||||
font-size: 14px;
|
font-size: var(--text-base);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.06em;
|
letter-spacing: 0.06em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -727,18 +786,20 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.opp-name { color: rgba(200, 80, 80, 0.8); }
|
.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 {
|
.sidebar-life {
|
||||||
font-family: 'Cinzel', serif;
|
font-family: 'Cinzel', serif;
|
||||||
font-size: 30px;
|
font-size: var(--text-2xl);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #f0d080;
|
color: var(--color-gold);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-deck, .sidebar-hand {
|
.sidebar-deck, .sidebar-hand {
|
||||||
font-family: 'Crimson Text', serif;
|
font-family: 'Crimson Text', serif;
|
||||||
font-size: 16px;
|
font-size: var(--text-md);
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
color: rgba(240, 180, 80, 0.45);
|
color: rgba(240, 180, 80, 0.45);
|
||||||
}
|
}
|
||||||
@@ -752,14 +813,14 @@
|
|||||||
.cost-bubble-display {
|
.cost-bubble-display {
|
||||||
width: 22px;
|
width: 22px;
|
||||||
height: 22px;
|
height: 22px;
|
||||||
border-radius: 50%;
|
border-radius: var(--radius-full);
|
||||||
background: #6ea0ec;
|
background: var(--color-energy);
|
||||||
border: 2.5px solid #000;
|
border: 2.5px solid #000;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
color: #08152c;
|
color: #08152c;
|
||||||
font-size: 13px;
|
font-size: var(--text-base);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-family: 'Cinzel', serif;
|
font-family: 'Cinzel', serif;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
@@ -767,9 +828,9 @@
|
|||||||
|
|
||||||
.energy-count {
|
.energy-count {
|
||||||
font-family: 'Cinzel', serif;
|
font-family: 'Cinzel', serif;
|
||||||
font-size: 18px;
|
font-size: var(--text-lg);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #f0d080;
|
color: var(--color-gold);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sacrifice-mode-btn {
|
.sacrifice-mode-btn {
|
||||||
@@ -777,7 +838,7 @@
|
|||||||
color: rgba(107, 76, 30, 1);
|
color: rgba(107, 76, 30, 1);
|
||||||
border: 2px solid rgba(107, 76, 30, 1);
|
border: 2px solid rgba(107, 76, 30, 1);
|
||||||
border-radius: 15px;
|
border-radius: 15px;
|
||||||
font-size: 16px;
|
font-size: var(--text-md);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 6px 5.5px;
|
padding: 6px 5.5px;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
@@ -786,32 +847,46 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sacrifice-mode-btn:hover {
|
.sacrifice-mode-btn:hover {
|
||||||
border-color: #c8861a;
|
border-color: var(--color-bronze);
|
||||||
background: rgba(200, 134, 26, 0.1);
|
background: rgba(200, 134, 26, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sacrifice-mode-btn.active {
|
.sacrifice-mode-btn.active {
|
||||||
background: rgba(180, 40, 40, 0.3);
|
background: rgba(180, 40, 40, 0.3);
|
||||||
border-color: #c84040;
|
border-color: var(--color-error);
|
||||||
}
|
}
|
||||||
|
|
||||||
.end-turn-btn {
|
.end-turn-btn {
|
||||||
font-family: 'Cinzel', serif;
|
font-family: 'Cinzel', serif;
|
||||||
font-size: 13px;
|
font-size: var(--btn-font-lg);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.06em;
|
letter-spacing: 0.06em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
background: #c8861a;
|
background: var(--color-bronze);
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 4px;
|
border-radius: var(--radius-md);
|
||||||
color: #fff8e0;
|
color: var(--color-btn-text);
|
||||||
padding: 10px 8px;
|
padding: 10px 8px;
|
||||||
|
width: 100%;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.15s;
|
transition: background 0.15s;
|
||||||
margin-top: auto;
|
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 {
|
.sacrifice-overlay {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -821,9 +896,9 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
font-size: 48px;
|
font-size: 48px;
|
||||||
background: rgba(180, 40, 40, 0.35);
|
background: rgba(180, 40, 40, 0.35);
|
||||||
border-radius: 10px;
|
border-radius: var(--radius-lg);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
z-index: 10;
|
z-index: var(--z-card);
|
||||||
transition: background 0.15s;
|
transition: background 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -937,23 +1012,23 @@
|
|||||||
width: calc(300px * 0.55);
|
width: calc(300px * 0.55);
|
||||||
height: calc(400px * 0.55);
|
height: calc(400px * 0.55);
|
||||||
border: 1.5px dashed rgba(107, 76, 30, 0.25);
|
border: 1.5px dashed rgba(107, 76, 30, 0.25);
|
||||||
border-radius: 10px;
|
border-radius: var(--radius-lg);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
transition: border-color 0.15s, background 0.15s;
|
transition: border-color 0.15s, background 0.15s;
|
||||||
z-index: 1;
|
z-index: var(--z-base);
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-slot.highlight {
|
.empty-slot.highlight {
|
||||||
border-color: #c8861a;
|
border-color: var(--color-bronze);
|
||||||
background: rgba(200, 134, 26, 0.08);
|
background: rgba(200, 134, 26, 0.08);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.slot-hint {
|
.slot-hint {
|
||||||
font-family: 'Cinzel', serif;
|
font-family: 'Cinzel', serif;
|
||||||
font-size: 9px;
|
font-size: var(--text-xs);
|
||||||
color: rgba(200, 134, 26, 0.7);
|
color: rgba(200, 134, 26, 0.7);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
@@ -1010,14 +1085,14 @@
|
|||||||
|
|
||||||
.turn-timer {
|
.turn-timer {
|
||||||
font-family: 'Cinzel', serif;
|
font-family: 'Cinzel', serif;
|
||||||
font-size: 11px;
|
font-size: var(--text-sm);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: rgba(240, 180, 80, 0.6);
|
color: var(--color-gold-dim);
|
||||||
transition: color 0.3s;
|
transition: color 0.3s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.turn-timer.urgent {
|
.turn-timer.urgent {
|
||||||
color: #c85050;
|
color: var(--color-error);
|
||||||
animation: pulse 0.8s ease-in-out infinite;
|
animation: pulse 0.8s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1028,7 +1103,7 @@
|
|||||||
|
|
||||||
.turn-indicator {
|
.turn-indicator {
|
||||||
font-family: 'Cinzel', serif;
|
font-family: 'Cinzel', serif;
|
||||||
font-size: 13px;
|
font-size: var(--text-base);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.1em;
|
letter-spacing: 0.1em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -1036,7 +1111,7 @@
|
|||||||
transition: color 0.3s;
|
transition: color 0.3s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.turn-indicator.my-turn { color: #c8861a; }
|
.turn-indicator.my-turn { color: var(--color-bronze); }
|
||||||
|
|
||||||
/* ── Hand ── */
|
/* ── Hand ── */
|
||||||
.hand {
|
.hand {
|
||||||
@@ -1048,7 +1123,7 @@
|
|||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
overflow-y: hidden;
|
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);
|
background: rgba(0,0,0,0.3);
|
||||||
height: calc(400px * 0.9 + 1rem);
|
height: calc(400px * 0.9 + 1rem);
|
||||||
}
|
}
|
||||||
@@ -1081,19 +1156,19 @@
|
|||||||
.hand-card:hover:not(:disabled) :global(.card) {
|
.hand-card:hover:not(:disabled) :global(.card) {
|
||||||
transform: scale(1.1) translate(-50px, calc(var(--peek-y) - 80px)) !important;
|
transform: scale(1.1) translate(-50px, calc(var(--peek-y) - 80px)) !important;
|
||||||
transform-origin: top left !important;
|
transform-origin: top left !important;
|
||||||
z-index: 50 !important;
|
z-index: 50;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hand-card.selected {
|
.hand-card.selected {
|
||||||
/* transform: translateY(-16px); */
|
/* transform: translateY(-16px); */
|
||||||
filter: drop-shadow(0 0 8px rgba(200, 134, 26, 0.9));
|
filter: drop-shadow(0 0 8px rgba(200, 134, 26, 0.9));
|
||||||
z-index: 25 !important;
|
z-index: 25;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hand-card.selected :global(.card) {
|
.hand-card.selected :global(.card) {
|
||||||
/* transform: scale(1.) translate(-30px, calc(var(--peek-y) - 80px)) !important; */
|
/* transform: scale(1.) translate(-30px, calc(var(--peek-y) - 80px)) !important; */
|
||||||
/* transform-origin: top left !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));
|
filter: drop-shadow(0 0 8px rgba(200, 134, 26, 0.9));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1108,13 +1183,13 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
z-index: 200;
|
z-index: var(--z-modal);
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal {
|
.modal {
|
||||||
background: #110d04;
|
background: #110d04;
|
||||||
border: 1.5px solid #6b4c1e;
|
border: 1.5px solid var(--color-border);
|
||||||
border-radius: 10px;
|
border-radius: var(--radius-lg);
|
||||||
padding: 2rem 2.5rem;
|
padding: 2rem 2.5rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -1125,9 +1200,9 @@
|
|||||||
|
|
||||||
.modal-title {
|
.modal-title {
|
||||||
font-family: 'Cinzel', serif;
|
font-family: 'Cinzel', serif;
|
||||||
font-size: 20px;
|
font-size: var(--text-xl);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #f0d080;
|
color: var(--color-gold);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
letter-spacing: 0.08em;
|
letter-spacing: 0.08em;
|
||||||
}
|
}
|
||||||
@@ -1143,20 +1218,20 @@
|
|||||||
font-family: 'Cinzel', serif;
|
font-family: 'Cinzel', serif;
|
||||||
font-size: 48px;
|
font-size: 48px;
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
color: #f0d080;
|
color: var(--color-gold);
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.difficulty-label {
|
.difficulty-label {
|
||||||
font-family: 'Crimson Text', serif;
|
font-family: 'Crimson Text', serif;
|
||||||
font-size: 16px;
|
font-size: var(--text-md);
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
color: rgba(240, 180, 80, 0.6);
|
color: var(--color-gold-dim);
|
||||||
}
|
}
|
||||||
|
|
||||||
.difficulty-slider {
|
.difficulty-slider {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
accent-color: #c8861a;
|
accent-color: var(--color-bronze);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1165,8 +1240,8 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
font-family: 'Cinzel', serif;
|
font-family: 'Cinzel', serif;
|
||||||
font-size: 10px;
|
font-size: var(--text-xs);
|
||||||
color: rgba(240, 180, 80, 0.4);
|
color: var(--color-gold-faint);
|
||||||
margin-top: -0.75rem;
|
margin-top: -0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1185,10 +1260,34 @@
|
|||||||
background: rgba(180, 40, 40, 0.9);
|
background: rgba(180, 40, 40, 0.9);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-family: 'Cinzel', serif;
|
font-family: 'Cinzel', serif;
|
||||||
font-size: 12px;
|
font-size: var(--text-sm);
|
||||||
padding: 8px 20px;
|
padding: 8px 20px;
|
||||||
border-radius: 6px;
|
border-radius: var(--radius-md);
|
||||||
pointer-events: none;
|
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>
|
</style>
|
||||||
@@ -1,11 +1,25 @@
|
|||||||
<script>
|
<script lang="ts">
|
||||||
import { API_URL, WS_URL } from '$lib/api.js';
|
import { API_URL, WS_URL } from '$lib/api.js';
|
||||||
import { apiFetch } from '$lib/api.js';
|
import { apiFetch } from '$lib/api.js';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
let profile = $state(null);
|
let profile: any = $state(null);
|
||||||
let loading = $state(true);
|
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');
|
const token = () => localStorage.getItem('token');
|
||||||
|
|
||||||
@@ -14,9 +28,81 @@
|
|||||||
const res = await apiFetch(`${API_URL}/profile`);
|
const res = await apiFetch(`${API_URL}/profile`);
|
||||||
if (res.status === 401) { goto('/auth'); return; }
|
if (res.status === 401) { goto('/auth'); return; }
|
||||||
profile = await res.json();
|
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;
|
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() {
|
function logout() {
|
||||||
localStorage.removeItem('token');
|
localStorage.removeItem('token');
|
||||||
localStorage.removeItem('refresh_token');
|
localStorage.removeItem('refresh_token');
|
||||||
@@ -69,7 +155,7 @@
|
|||||||
<span class="shards-icon">◈</span>
|
<span class="shards-icon">◈</span>
|
||||||
<span class="shards-value">{profile.shards}</span>
|
<span class="shards-value">{profile.shards}</span>
|
||||||
<span class="shards-label">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>
|
||||||
|
|
||||||
<div class="section-divider"></div>
|
<div class="section-divider"></div>
|
||||||
@@ -98,6 +184,28 @@
|
|||||||
|
|
||||||
<div class="section-divider"></div>
|
<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>
|
<h2 class="section-title">Highlights</h2>
|
||||||
<div class="highlights">
|
<div class="highlights">
|
||||||
<div class="highlight-card">
|
<div class="highlight-card">
|
||||||
@@ -129,17 +237,161 @@
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<style>
|
<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 {
|
main {
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
background: #0d0a04;
|
background: var(--color-bg);
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,6 +401,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 1.5rem;
|
gap: 1.5rem;
|
||||||
|
padding-bottom: 5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-header {
|
.profile-header {
|
||||||
@@ -160,16 +413,16 @@
|
|||||||
.avatar {
|
.avatar {
|
||||||
width: 64px;
|
width: 64px;
|
||||||
height: 64px;
|
height: 64px;
|
||||||
border-radius: 50%;
|
border-radius: var(--radius-full);
|
||||||
background: #3d2507;
|
background: var(--color-surface-raised);
|
||||||
border: 2px solid #c8861a;
|
border: 2px solid var(--color-bronze);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
font-family: 'Cinzel', serif;
|
font-family: 'Cinzel', serif;
|
||||||
font-size: 28px;
|
font-size: var(--text-2xl);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #f0d080;
|
color: var(--color-gold);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,22 +435,22 @@
|
|||||||
|
|
||||||
.username {
|
.username {
|
||||||
font-family: 'Cinzel', serif;
|
font-family: 'Cinzel', serif;
|
||||||
font-size: 24px;
|
font-size: var(--text-xl);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #f0d080;
|
color: var(--color-gold);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.email {
|
.email {
|
||||||
font-family: 'Crimson Text', serif;
|
font-family: 'Crimson Text', serif;
|
||||||
font-size: 15px;
|
font-size: var(--text-md);
|
||||||
color: rgba(240, 180, 80, 0.5);
|
color: rgba(240, 180, 80, 0.5);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.unverified-badge {
|
.unverified-badge {
|
||||||
font-family: 'Cinzel', serif;
|
font-family: 'Cinzel', serif;
|
||||||
font-size: 9px;
|
font-size: var(--text-xs);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.06em;
|
letter-spacing: 0.06em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -211,7 +464,7 @@
|
|||||||
|
|
||||||
.resend-btn {
|
.resend-btn {
|
||||||
font-family: 'Crimson Text', serif;
|
font-family: 'Crimson Text', serif;
|
||||||
font-size: 13px;
|
font-size: var(--text-base);
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
color: rgba(240, 180, 80, 0.5);
|
color: rgba(240, 180, 80, 0.5);
|
||||||
background: none;
|
background: none;
|
||||||
@@ -228,7 +481,7 @@
|
|||||||
|
|
||||||
.joined {
|
.joined {
|
||||||
font-family: 'Crimson Text', serif;
|
font-family: 'Crimson Text', serif;
|
||||||
font-size: 13px;
|
font-size: var(--text-base);
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
color: rgba(240, 180, 80, 0.35);
|
color: rgba(240, 180, 80, 0.35);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -236,29 +489,29 @@
|
|||||||
|
|
||||||
.logout-btn {
|
.logout-btn {
|
||||||
font-family: 'Cinzel', serif;
|
font-family: 'Cinzel', serif;
|
||||||
font-size: 11px;
|
font-size: var(--btn-font-md);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.06em;
|
letter-spacing: 0.06em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
background: none;
|
background: none;
|
||||||
border: 1px solid rgba(180, 60, 60, 0.4);
|
border: 1px solid rgba(180, 60, 60, 0.4);
|
||||||
border-radius: 4px;
|
border-radius: var(--radius-sm);
|
||||||
color: rgba(200, 80, 80, 0.7);
|
color: rgba(200, 80, 80, 0.7);
|
||||||
padding: 8px 16px;
|
padding: var(--btn-padding-md);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.15s;
|
transition: all 0.15s;
|
||||||
align-self: flex-start;
|
align-self: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logout-btn:hover {
|
.logout-btn:hover {
|
||||||
border-color: #c84040;
|
border-color: var(--color-error);
|
||||||
color: #e05050;
|
color: #e05050;
|
||||||
background: rgba(180, 40, 40, 0.1);
|
background: rgba(180, 40, 40, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.reset-link {
|
.reset-link {
|
||||||
font-family: 'Crimson Text', serif;
|
font-family: 'Crimson Text', serif;
|
||||||
font-size: 14px;
|
font-size: var(--text-base);
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
color: rgba(240, 180, 80, 0.4);
|
color: rgba(240, 180, 80, 0.4);
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
@@ -276,38 +529,39 @@
|
|||||||
|
|
||||||
.shards-link {
|
.shards-link {
|
||||||
font-family: 'Cinzel', serif;
|
font-family: 'Cinzel', serif;
|
||||||
font-size: 10px;
|
font-size: var(--text-xs);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.06em;
|
letter-spacing: 0.06em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: rgba(126, 207, 207, 0.6);
|
color: rgba(126, 207, 207, 0.6);
|
||||||
border: 1px solid rgba(126, 207, 207, 0.3);
|
border: 1px solid rgba(126, 207, 207, 0.3);
|
||||||
border-radius: 4px;
|
border-radius: var(--radius-sm);
|
||||||
padding: 3px 8px;
|
padding: 3px 8px;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
margin-left: 0.5rem;
|
margin-left: 0.5rem;
|
||||||
transition: color 0.15s, border-color 0.15s;
|
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 {
|
.shards-icon {
|
||||||
font-size: 22px;
|
font-size: var(--text-xl);
|
||||||
color: #7ecfcf;
|
color: var(--color-cyan);
|
||||||
position: relative;
|
position: relative;
|
||||||
top: -0.1em;
|
top: -0.1em;
|
||||||
|
animation: shard-pulse 3s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
.shards-value {
|
.shards-value {
|
||||||
font-family: 'Cinzel', serif;
|
font-family: 'Cinzel', serif;
|
||||||
font-size: 28px;
|
font-size: var(--text-2xl);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #7ecfcf;
|
color: var(--color-cyan);
|
||||||
}
|
}
|
||||||
|
|
||||||
.shards-label {
|
.shards-label {
|
||||||
font-family: 'Cinzel', serif;
|
font-family: 'Cinzel', serif;
|
||||||
font-size: 11px;
|
font-size: var(--text-sm);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.1em;
|
letter-spacing: 0.1em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -317,16 +571,16 @@
|
|||||||
|
|
||||||
.section-divider {
|
.section-divider {
|
||||||
height: 1px;
|
height: 1px;
|
||||||
background: rgba(107, 76, 30, 0.3);
|
background: var(--color-border-dim);
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-title {
|
.section-title {
|
||||||
font-family: 'Cinzel', serif;
|
font-family: 'Cinzel', serif;
|
||||||
font-size: 13px;
|
font-size: var(--text-base);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.1em;
|
letter-spacing: 0.1em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: rgba(240, 180, 80, 0.4);
|
color: var(--color-gold-faint);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -337,9 +591,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.stat-card {
|
.stat-card {
|
||||||
background: #1a1008;
|
background: var(--color-surface);
|
||||||
border: 1px solid rgba(107, 76, 30, 0.3);
|
border: 1px solid var(--color-border-dim);
|
||||||
border-radius: 8px;
|
border-radius: var(--radius-lg);
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -348,24 +602,24 @@
|
|||||||
|
|
||||||
.stat-label {
|
.stat-label {
|
||||||
font-family: 'Cinzel', serif;
|
font-family: 'Cinzel', serif;
|
||||||
font-size: 9px;
|
font-size: var(--text-xs);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.08em;
|
letter-spacing: 0.08em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: rgba(240, 180, 80, 0.4);
|
color: var(--color-gold-faint);
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-value {
|
.stat-value {
|
||||||
font-family: 'Cinzel', serif;
|
font-family: 'Cinzel', serif;
|
||||||
font-size: 28px;
|
font-size: var(--text-2xl);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #f0d080;
|
color: var(--color-gold);
|
||||||
}
|
}
|
||||||
|
|
||||||
.wins { color: #6aaa6a; }
|
.wins { color: var(--color-success); }
|
||||||
.losses { color: #c85050; }
|
.losses { color: var(--color-error); }
|
||||||
.good-wr { color: #6aaa6a; }
|
.good-wr { color: var(--color-success); }
|
||||||
.bad-wr { color: #c85050; }
|
.bad-wr { color: var(--color-error); }
|
||||||
|
|
||||||
.highlights {
|
.highlights {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -374,9 +628,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.highlight-card {
|
.highlight-card {
|
||||||
background: #1a1008;
|
background: var(--color-surface);
|
||||||
border: 1px solid rgba(107, 76, 30, 0.3);
|
border: 1px solid var(--color-border-dim);
|
||||||
border-radius: 8px;
|
border-radius: var(--radius-lg);
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -385,23 +639,23 @@
|
|||||||
|
|
||||||
.highlight-label {
|
.highlight-label {
|
||||||
font-family: 'Cinzel', serif;
|
font-family: 'Cinzel', serif;
|
||||||
font-size: 9px;
|
font-size: var(--text-xs);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.08em;
|
letter-spacing: 0.08em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: rgba(240, 180, 80, 0.4);
|
color: var(--color-gold-faint);
|
||||||
}
|
}
|
||||||
|
|
||||||
.highlight-value {
|
.highlight-value {
|
||||||
font-family: 'Cinzel', serif;
|
font-family: 'Cinzel', serif;
|
||||||
font-size: 16px;
|
font-size: var(--text-md);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #f0d080;
|
color: var(--color-gold);
|
||||||
}
|
}
|
||||||
|
|
||||||
.highlight-sub {
|
.highlight-sub {
|
||||||
font-family: 'Crimson Text', serif;
|
font-family: 'Crimson Text', serif;
|
||||||
font-size: 13px;
|
font-size: var(--text-base);
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
color: rgba(240, 180, 80, 0.45);
|
color: rgba(240, 180, 80, 0.45);
|
||||||
}
|
}
|
||||||
@@ -418,8 +672,8 @@
|
|||||||
height: 48px;
|
height: 48px;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
object-position: top;
|
object-position: top;
|
||||||
border-radius: 4px;
|
border-radius: var(--radius-sm);
|
||||||
border: 1px solid rgba(107, 76, 30, 0.4);
|
border: 1px solid var(--color-border-subtle);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -429,22 +683,303 @@
|
|||||||
gap: 0.2rem;
|
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 {
|
.no-data {
|
||||||
font-family: 'Crimson Text', serif;
|
font-family: 'Crimson Text', serif;
|
||||||
font-size: 14px;
|
font-size: var(--text-base);
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
color: rgba(240, 180, 80, 0.25);
|
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 {
|
.status {
|
||||||
font-family: 'Crimson Text', serif;
|
font-family: 'Crimson Text', serif;
|
||||||
font-size: 16px;
|
font-size: var(--text-md);
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
color: rgba(240, 180, 80, 0.5);
|
color: rgba(240, 180, 80, 0.5);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin-top: 4rem;
|
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) {
|
@media (max-width: 640px) {
|
||||||
.stats-grid { grid-template-columns: repeat(2, 1fr); }
|
.stats-grid { grid-template-columns: repeat(2, 1fr); }
|
||||||
.highlights { grid-template-columns: 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>
|
</main>
|
||||||
|
|
||||||
<style>
|
<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 {
|
main {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background: #0d0a04;
|
background: var(--color-bg);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -93,9 +91,9 @@
|
|||||||
|
|
||||||
.card {
|
.card {
|
||||||
width: 380px;
|
width: 380px;
|
||||||
background: #3d2507;
|
background: var(--color-surface);
|
||||||
border: 2px solid #c8861a;
|
border: 2px solid var(--color-bronze);
|
||||||
border-radius: 12px;
|
border-radius: var(--radius-xl);
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -104,17 +102,17 @@
|
|||||||
|
|
||||||
.title {
|
.title {
|
||||||
font-family: 'Cinzel', serif;
|
font-family: 'Cinzel', serif;
|
||||||
font-size: 20px;
|
font-size: var(--text-xl);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #f5d060;
|
color: var(--color-gold);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hint {
|
.hint {
|
||||||
font-family: 'Crimson Text', serif;
|
font-family: 'Crimson Text', serif;
|
||||||
font-size: 15px;
|
font-size: var(--text-md);
|
||||||
color: rgba(245, 208, 96, 0.7);
|
color: var(--color-gold-dim);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
@@ -127,63 +125,65 @@
|
|||||||
|
|
||||||
.field-label {
|
.field-label {
|
||||||
font-family: 'Cinzel', serif;
|
font-family: 'Cinzel', serif;
|
||||||
font-size: 10px;
|
font-size: var(--text-xs);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.08em;
|
letter-spacing: 0.08em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: rgba(245, 208, 96, 0.5);
|
color: var(--color-gold-faint);
|
||||||
}
|
}
|
||||||
|
|
||||||
input {
|
input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 9px 12px;
|
padding: 9px 12px;
|
||||||
background: #221508;
|
background: var(--color-surface);
|
||||||
border: 1.5px solid #c8861a;
|
border: 1.5px solid var(--color-border);
|
||||||
border-radius: 6px;
|
border-radius: var(--radius-md);
|
||||||
color: #f5d060;
|
color: var(--color-gold);
|
||||||
font-family: 'Crimson Text', serif;
|
font-family: 'Crimson Text', serif;
|
||||||
font-size: 15px;
|
font-size: var(--text-md);
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
outline: none;
|
outline: none;
|
||||||
margin-bottom: 0.4rem;
|
margin-bottom: 0.4rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
input:focus { border-color: #f5d060; }
|
input:focus { border-color: var(--color-bronze); }
|
||||||
input::placeholder { color: rgba(245, 208, 96, 0.35); }
|
input::placeholder { color: var(--color-gold-faint); }
|
||||||
|
|
||||||
.btn {
|
.btn {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 10px;
|
padding: var(--btn-padding-lg);
|
||||||
background: #c8861a;
|
background: var(--color-bronze);
|
||||||
color: #fff8e0;
|
color: var(--color-btn-text);
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 6px;
|
border-radius: var(--radius-md);
|
||||||
font-family: 'Cinzel', serif;
|
font-family: 'Cinzel', serif;
|
||||||
font-size: 13px;
|
font-size: var(--btn-font-lg);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.15s;
|
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; }
|
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
|
||||||
.back-link {
|
.back-link {
|
||||||
all: unset;
|
all: unset;
|
||||||
font-family: 'Crimson Text', serif;
|
font-family: 'Crimson Text', serif;
|
||||||
font-size: 14px;
|
font-size: var(--text-base);
|
||||||
color: rgba(245, 208, 96, 0.5);
|
color: var(--color-gold-faint);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
transition: color 0.15s;
|
transition: color 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.back-link:hover { color: #f5d060; }
|
.back-link:hover { color: var(--color-gold); }
|
||||||
|
|
||||||
.error {
|
.error {
|
||||||
font-family: 'Crimson Text', serif;
|
font-family: 'Crimson Text', serif;
|
||||||
font-size: 14px;
|
font-size: var(--text-base);
|
||||||
color: #f06060;
|
color: var(--color-error);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
min-height: 1.4em;
|
min-height: 1.4em;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|||||||
+48
-51
@@ -1,30 +1,27 @@
|
|||||||
<script>
|
<script lang="ts">
|
||||||
import { API_URL } from '$lib/api.js';
|
import { API_URL } from '$lib/api.js';
|
||||||
import { apiFetch } from '$lib/api.js';
|
import { apiFetch } from '$lib/api.js';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import CardSelector from '$lib/CardSelector.svelte';
|
import CardSelector from '$lib/CardSelector.svelte';
|
||||||
|
|
||||||
let allCards = $state([]);
|
|
||||||
let shards = $state(null);
|
let shards = $state(null);
|
||||||
let selectedIds = $state(new Set());
|
let selectedIds = $state(new Set());
|
||||||
|
let selectedCards: any[] = $state([]); // bound from CardSelector
|
||||||
let selectorOpen = $state(false);
|
let selectorOpen = $state(false);
|
||||||
let shattering = $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 inDeckIds = $state(new Set());
|
||||||
|
let selectorRef: any;
|
||||||
|
|
||||||
const selectedCards = $derived(allCards.filter(c => selectedIds.has(c.id)));
|
const totalYield = $derived(selectedCards.reduce((sum: number, c: any) => sum + c.cost, 0));
|
||||||
const totalYield = $derived(selectedCards.reduce((sum, c) => sum + c.cost, 0));
|
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
if (!localStorage.getItem('token')) { goto('/auth'); return; }
|
if (!localStorage.getItem('token')) { goto('/auth'); return; }
|
||||||
const [cardsRes, profileRes, inDecksRes] = await Promise.all([
|
const [profileRes, inDecksRes] = await Promise.all([
|
||||||
apiFetch(`${API_URL}/cards`),
|
|
||||||
apiFetch(`${API_URL}/profile`),
|
apiFetch(`${API_URL}/profile`),
|
||||||
apiFetch(`${API_URL}/cards/in-decks`),
|
apiFetch(`${API_URL}/cards/in-decks`),
|
||||||
]);
|
]);
|
||||||
if (cardsRes.status === 401) { goto('/auth'); return; }
|
|
||||||
allCards = await cardsRes.json();
|
|
||||||
const profile = await profileRes.json();
|
const profile = await profileRes.json();
|
||||||
shards = profile.shards;
|
shards = profile.shards;
|
||||||
if (inDecksRes.ok) inDeckIds = new Set(await inDecksRes.json());
|
if (inDecksRes.ok) inDeckIds = new Set(await inDecksRes.json());
|
||||||
@@ -42,8 +39,8 @@
|
|||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
shards = data.shards;
|
shards = data.shards;
|
||||||
result = { gained: data.gained };
|
result = { gained: data.gained };
|
||||||
allCards = allCards.filter(c => !selectedIds.has(c.id));
|
|
||||||
selectedIds = new Set();
|
selectedIds = new Set();
|
||||||
|
selectorRef?.refresh(); // refetch so shattered cards disappear
|
||||||
}
|
}
|
||||||
shattering = false;
|
shattering = false;
|
||||||
}
|
}
|
||||||
@@ -53,7 +50,7 @@
|
|||||||
|
|
||||||
<main>
|
<main>
|
||||||
<div class="top">
|
<div class="top">
|
||||||
<h1 class="page-title">Shards</h1>
|
<h1 class="page-title">Shatter</h1>
|
||||||
{#if shards !== null}
|
{#if shards !== null}
|
||||||
<div class="shards-display">
|
<div class="shards-display">
|
||||||
<span class="shards-icon">◈</span>
|
<span class="shards-icon">◈</span>
|
||||||
@@ -96,8 +93,9 @@
|
|||||||
{#if selectorOpen}
|
{#if selectorOpen}
|
||||||
<div class="selector-overlay">
|
<div class="selector-overlay">
|
||||||
<CardSelector
|
<CardSelector
|
||||||
allCards={allCards}
|
bind:this={selectorRef}
|
||||||
bind:selectedIds={selectedIds}
|
bind:selectedIds={selectedIds}
|
||||||
|
bind:selectedCards={selectedCards}
|
||||||
{inDeckIds}
|
{inDeckIds}
|
||||||
onclose={() => { selectorOpen = false; }}
|
onclose={() => { selectorOpen = false; }}
|
||||||
/>
|
/>
|
||||||
@@ -105,11 +103,9 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<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 {
|
main {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background: #0d0a04;
|
background: var(--color-bg);
|
||||||
padding: 2.5rem 2rem;
|
padding: 2.5rem 2rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -130,7 +126,7 @@
|
|||||||
font-family: 'Cinzel', serif;
|
font-family: 'Cinzel', serif;
|
||||||
font-size: clamp(22px, 4vw, 32px);
|
font-size: clamp(22px, 4vw, 32px);
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
color: #f0d080;
|
color: var(--color-gold);
|
||||||
letter-spacing: 0.12em;
|
letter-spacing: 0.12em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -143,22 +139,23 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.shards-icon {
|
.shards-icon {
|
||||||
font-size: 20px;
|
font-size: var(--text-xl);
|
||||||
color: #7ecfcf;
|
color: var(--color-cyan);
|
||||||
position: relative;
|
position: relative;
|
||||||
top: -0.1em;
|
top: -0.1em;
|
||||||
|
animation: shard-pulse 3s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
.shards-amount {
|
.shards-amount {
|
||||||
font-family: 'Cinzel', serif;
|
font-family: 'Cinzel', serif;
|
||||||
font-size: 24px;
|
font-size: var(--text-xl);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #7ecfcf;
|
color: var(--color-cyan);
|
||||||
}
|
}
|
||||||
|
|
||||||
.shards-label {
|
.shards-label {
|
||||||
font-family: 'Cinzel', serif;
|
font-family: 'Cinzel', serif;
|
||||||
font-size: 11px;
|
font-size: var(--text-sm);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.1em;
|
letter-spacing: 0.1em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -168,23 +165,23 @@
|
|||||||
|
|
||||||
.explainer {
|
.explainer {
|
||||||
font-family: 'Crimson Text', serif;
|
font-family: 'Crimson Text', serif;
|
||||||
font-size: 16px;
|
font-size: var(--text-md);
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
color: rgba(240, 180, 80, 0.5);
|
color: var(--color-gold-faint);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.store-hint {
|
.store-hint {
|
||||||
font-family: 'Crimson Text', serif;
|
font-family: 'Crimson Text', serif;
|
||||||
font-size: 15px;
|
font-size: var(--text-md);
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
color: rgba(240, 180, 80, 0.35);
|
color: var(--color-gold-faint);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.store-link {
|
.store-link {
|
||||||
color: #7ecfcf;
|
color: var(--color-cyan);
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
text-underline-offset: 2px;
|
text-underline-offset: 2px;
|
||||||
transition: color 0.15s;
|
transition: color 0.15s;
|
||||||
@@ -197,16 +194,16 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
background: #0d2a0d;
|
background: #0d2a0d;
|
||||||
border: 1.5px solid #6aaa6a;
|
border: 1.5px solid var(--color-success);
|
||||||
border-radius: 8px;
|
border-radius: var(--radius-lg);
|
||||||
padding: 0.75rem 1.25rem;
|
padding: 0.75rem 1.25rem;
|
||||||
font-family: 'Cinzel', serif;
|
font-family: 'Cinzel', serif;
|
||||||
font-size: 15px;
|
font-size: var(--text-md);
|
||||||
font-weight: 700;
|
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 {
|
.dismiss {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
@@ -214,11 +211,11 @@
|
|||||||
border: none;
|
border: none;
|
||||||
color: rgba(106, 170, 106, 0.5);
|
color: rgba(106, 170, 106, 0.5);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 13px;
|
font-size: var(--text-base);
|
||||||
padding: 0 0 0 0.75rem;
|
padding: 0 0 0 0.75rem;
|
||||||
transition: color 0.15s;
|
transition: color 0.15s;
|
||||||
}
|
}
|
||||||
.dismiss:hover { color: #6aaa6a; }
|
.dismiss:hover { color: var(--color-success); }
|
||||||
|
|
||||||
/* ── Action area ── */
|
/* ── Action area ── */
|
||||||
.action-area {
|
.action-area {
|
||||||
@@ -232,15 +229,15 @@
|
|||||||
|
|
||||||
.select-btn {
|
.select-btn {
|
||||||
font-family: 'Cinzel', serif;
|
font-family: 'Cinzel', serif;
|
||||||
font-size: 13px;
|
font-size: var(--btn-font-lg);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.06em;
|
letter-spacing: 0.06em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
background: #3d2507;
|
background: var(--color-surface-raised);
|
||||||
border: 1.5px solid #c8861a;
|
border: 1.5px solid var(--color-bronze);
|
||||||
border-radius: 6px;
|
border-radius: var(--radius-md);
|
||||||
color: #f0d080;
|
color: var(--color-gold);
|
||||||
padding: 10px 24px;
|
padding: var(--btn-padding-lg);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.15s;
|
transition: background 0.15s;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -254,26 +251,26 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
font-family: 'Cinzel', serif;
|
font-family: 'Cinzel', serif;
|
||||||
font-size: 15px;
|
font-size: var(--text-md);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
.summary-count { color: #f0d080; }
|
.summary-count { color: var(--color-gold); }
|
||||||
.summary-arrow { color: rgba(240, 180, 80, 0.35); }
|
.summary-arrow { color: var(--color-gold-faint); }
|
||||||
.summary-yield { color: #7ecfcf; display: flex; align-items: center; gap: 0.3rem; }
|
.summary-yield { color: var(--color-cyan); display: flex; align-items: center; gap: 0.3rem; }
|
||||||
.shards-icon-sm { font-size: 14px; color: #7ecfcf; position: relative; top: -0.1em; }
|
.shards-icon-sm { font-size: var(--text-base); color: var(--color-cyan); position: relative; top: -0.1em; }
|
||||||
|
|
||||||
.shatter-btn {
|
.shatter-btn {
|
||||||
font-family: 'Cinzel', serif;
|
font-family: 'Cinzel', serif;
|
||||||
font-size: 13px;
|
font-size: var(--btn-font-lg);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.06em;
|
letter-spacing: 0.06em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
background: #1a1008;
|
background: var(--color-surface);
|
||||||
border: 1.5px solid #7ecfcf;
|
border: 1.5px solid var(--color-cyan);
|
||||||
border-radius: 6px;
|
border-radius: var(--radius-md);
|
||||||
color: #7ecfcf;
|
color: var(--color-cyan);
|
||||||
padding: 10px 24px;
|
padding: var(--btn-padding-lg);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
transition: background 0.15s;
|
transition: background 0.15s;
|
||||||
@@ -286,7 +283,7 @@
|
|||||||
.selector-overlay {
|
.selector-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
z-index: 200;
|
z-index: var(--z-dropdown);
|
||||||
background: rgba(0, 0, 0, 0.85);
|
background: rgba(0, 0, 0, 0.85);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
<script>
|
<script lang="ts">
|
||||||
import { API_URL } from '$lib/api.js';
|
import { API_URL } from '$lib/api.js';
|
||||||
import { apiFetch } from '$lib/api.js';
|
import { apiFetch } from '$lib/api.js';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
@@ -7,11 +7,11 @@
|
|||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import Card from '$lib/Card.svelte';
|
import Card from '$lib/Card.svelte';
|
||||||
|
|
||||||
let shards = $state(null);
|
let shards: number | null = $state(null);
|
||||||
let buying = $state(null); // which quantity is being bought
|
let buying: number | null = $state(null);
|
||||||
let flash = $state(null); // { quantity, ok }
|
let flash: { quantity: number; ok: boolean } | null = $state(null);
|
||||||
let shardPackages = $state([]);
|
let shardPackages: any[] = $state([]);
|
||||||
let buyingShards = $state(null);
|
let buyingShards: string | null = $state(null);
|
||||||
let paymentSuccess = $state(false);
|
let paymentSuccess = $state(false);
|
||||||
|
|
||||||
const packages = [
|
const packages = [
|
||||||
@@ -31,7 +31,7 @@
|
|||||||
const profile = await profileRes.json();
|
const profile = await profileRes.json();
|
||||||
shards = profile.shards;
|
shards = profile.shards;
|
||||||
const config = await configRes.json();
|
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') {
|
if ($page.url.searchParams.get('payment') === 'success') {
|
||||||
paymentSuccess = true;
|
paymentSuccess = true;
|
||||||
@@ -41,7 +41,7 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
async function buyWithStripe(packageId) {
|
async function buyWithStripe(packageId: string) {
|
||||||
if (buyingShards) return;
|
if (buyingShards) return;
|
||||||
buyingShards = packageId;
|
buyingShards = packageId;
|
||||||
const res = await apiFetch(`${API_URL}/store/stripe/checkout`, {
|
const res = await apiFetch(`${API_URL}/store/stripe/checkout`, {
|
||||||
@@ -56,8 +56,8 @@
|
|||||||
buyingShards = null;
|
buyingShards = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function buy(quantity, cost) {
|
async function buy(quantity: number, cost: number) {
|
||||||
if (buying !== null || shards < cost) return;
|
if (buying !== null || shards === null || shards < cost) return;
|
||||||
buying = quantity;
|
buying = quantity;
|
||||||
const res = await apiFetch(`${API_URL}/store/buy`, {
|
const res = await apiFetch(`${API_URL}/store/buy`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -79,9 +79,10 @@
|
|||||||
const SPECIFIC_CARD_COST = 1000;
|
const SPECIFIC_CARD_COST = 1000;
|
||||||
let specificPhase = $state('idle'); // idle | input | generating | revealing | done
|
let specificPhase = $state('idle'); // idle | input | generating | revealing | done
|
||||||
let wikiTitle = $state('');
|
let wikiTitle = $state('');
|
||||||
let specificCard = $state(null);
|
let specificCard: any = $state(null);
|
||||||
let specificFlipped = $state(false);
|
let specificFlipped = $state(false);
|
||||||
let specificError = $state('');
|
let specificError = $state('');
|
||||||
|
let specificAction = $state({ favorited: false, tradeListed: false, shattered: false, shardGain: 0 });
|
||||||
|
|
||||||
function openSpecificModal() {
|
function openSpecificModal() {
|
||||||
wikiTitle = '';
|
wikiTitle = '';
|
||||||
@@ -93,6 +94,36 @@
|
|||||||
specificPhase = 'idle';
|
specificPhase = 'idle';
|
||||||
specificCard = null;
|
specificCard = null;
|
||||||
specificFlipped = false;
|
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() {
|
async function buySpecificCard() {
|
||||||
@@ -122,7 +153,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// How many mini-packs to fan out per package
|
// How many mini-packs to fan out per package
|
||||||
function fanCount(quantity) {
|
function fanCount(quantity: number) {
|
||||||
if (quantity === 1) return 1;
|
if (quantity === 1) return 1;
|
||||||
if (quantity === 5) return 2;
|
if (quantity === 5) return 2;
|
||||||
if (quantity === 10) return 3;
|
if (quantity === 10) return 3;
|
||||||
@@ -130,7 +161,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Rotation and offset for each pack in the fan
|
// 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)';
|
if (total === 1) return 'rotate(0deg) translateY(0px)';
|
||||||
const spread = 10; // degrees between each pack
|
const spread = 10; // degrees between each pack
|
||||||
const mid = (total - 1) / 2;
|
const mid = (total - 1) / 2;
|
||||||
@@ -148,7 +179,7 @@
|
|||||||
<span class="shards-icon">◈</span>
|
<span class="shards-icon">◈</span>
|
||||||
<span class="shards-amount">{shards}</span>
|
<span class="shards-amount">{shards}</span>
|
||||||
<span class="shards-label">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>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -179,12 +210,12 @@
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
class="buy-btn"
|
class="buy-btn"
|
||||||
class:flash-ok={isFlashing && flash.ok}
|
class:flash-ok={isFlashing && flash?.ok}
|
||||||
class:flash-err={isFlashing && !flash.ok}
|
class:flash-err={isFlashing && !flash?.ok}
|
||||||
onclick={() => buy(pkg.quantity, pkg.cost)}
|
onclick={() => buy(pkg.quantity, pkg.cost)}
|
||||||
disabled={!canAfford || buying !== null}
|
disabled={!canAfford || buying !== null}
|
||||||
>
|
>
|
||||||
{#if isFlashing && flash.ok}
|
{#if isFlashing && flash?.ok}
|
||||||
Purchased!
|
Purchased!
|
||||||
{:else if buying === pkg.quantity}
|
{:else if buying === pkg.quantity}
|
||||||
...
|
...
|
||||||
@@ -236,7 +267,7 @@
|
|||||||
{#each shardPackages as pkg}
|
{#each shardPackages as pkg}
|
||||||
<div class="shard-card">
|
<div class="shard-card">
|
||||||
{#if pkg.bonus > 0}
|
{#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}
|
{/if}
|
||||||
<span class="shard-amount"><span class="shard-icon">◈</span> {pkg.shards}</span>
|
<span class="shard-amount"><span class="shard-icon">◈</span> {pkg.shards}</span>
|
||||||
<span class="shard-price">{pkg.price_label}</span>
|
<span class="shard-price">{pkg.price_label}</span>
|
||||||
@@ -250,6 +281,9 @@
|
|||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
@@ -300,6 +334,30 @@
|
|||||||
Your card is being generated…
|
Your card is being generated…
|
||||||
</p>
|
</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
|
<button
|
||||||
class="close-reveal-btn"
|
class="close-reveal-btn"
|
||||||
class:hidden={specificPhase !== 'done'}
|
class:hidden={specificPhase !== 'done'}
|
||||||
@@ -312,12 +370,10 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<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 {
|
main {
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
background: #0d0a04;
|
background: var(--color-bg);
|
||||||
padding: 2.5rem 2rem 5rem;
|
padding: 2.5rem 2rem 5rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -336,7 +392,7 @@
|
|||||||
font-family: 'Cinzel', serif;
|
font-family: 'Cinzel', serif;
|
||||||
font-size: clamp(22px, 4vw, 32px);
|
font-size: clamp(22px, 4vw, 32px);
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
color: #f0d080;
|
color: var(--color-gold);
|
||||||
letter-spacing: 0.12em;
|
letter-spacing: 0.12em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -350,38 +406,39 @@
|
|||||||
|
|
||||||
.shards-link {
|
.shards-link {
|
||||||
font-family: 'Cinzel', serif;
|
font-family: 'Cinzel', serif;
|
||||||
font-size: 10px;
|
font-size: var(--text-xs);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.06em;
|
letter-spacing: 0.06em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: rgba(126, 207, 207, 0.6);
|
color: rgba(126, 207, 207, 0.6);
|
||||||
border: 1px solid rgba(126, 207, 207, 0.3);
|
border: 1px solid rgba(126, 207, 207, 0.3);
|
||||||
border-radius: 4px;
|
border-radius: var(--radius-sm);
|
||||||
padding: 3px 8px;
|
padding: 3px 8px;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
margin-left: 0.5rem;
|
margin-left: 0.5rem;
|
||||||
transition: color 0.15s, border-color 0.15s;
|
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 {
|
.shards-icon {
|
||||||
font-size: 20px;
|
font-size: var(--text-xl);
|
||||||
color: #7ecfcf;
|
color: var(--color-cyan);
|
||||||
position: relative;
|
position: relative;
|
||||||
top: -0.1em;
|
top: -0.1em;
|
||||||
|
animation: shard-pulse 3s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
.shards-amount {
|
.shards-amount {
|
||||||
font-family: 'Cinzel', serif;
|
font-family: 'Cinzel', serif;
|
||||||
font-size: 24px;
|
font-size: var(--text-xl);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #7ecfcf;
|
color: var(--color-cyan);
|
||||||
}
|
}
|
||||||
|
|
||||||
.shards-label {
|
.shards-label {
|
||||||
font-family: 'Cinzel', serif;
|
font-family: 'Cinzel', serif;
|
||||||
font-size: 11px;
|
font-size: var(--text-sm);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.1em;
|
letter-spacing: 0.1em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -399,8 +456,8 @@
|
|||||||
|
|
||||||
.pkg-card {
|
.pkg-card {
|
||||||
position: relative;
|
position: relative;
|
||||||
background: #1a1008;
|
background: var(--color-surface);
|
||||||
border: 1.5px solid rgba(107, 76, 30, 0.5);
|
border: 1.5px solid var(--color-border-subtle);
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
padding: 2rem 1.5rem 1.5rem;
|
padding: 2rem 1.5rem 1.5rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -412,7 +469,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.pkg-card:not(.cannot-afford):hover {
|
.pkg-card:not(.cannot-afford):hover {
|
||||||
border-color: #c8861a;
|
border-color: var(--color-bronze);
|
||||||
background: #211408;
|
background: #211408;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -431,7 +488,7 @@
|
|||||||
background: #f5d800;
|
background: #f5d800;
|
||||||
color: #c0000a;
|
color: #c0000a;
|
||||||
font-family: 'Cinzel', serif;
|
font-family: 'Cinzel', serif;
|
||||||
font-size: 11px;
|
font-size: var(--text-sm);
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -528,9 +585,9 @@
|
|||||||
/* ── Labels ── */
|
/* ── Labels ── */
|
||||||
.qty-label {
|
.qty-label {
|
||||||
font-family: 'Cinzel', serif;
|
font-family: 'Cinzel', serif;
|
||||||
font-size: 15px;
|
font-size: var(--text-md);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #f0d080;
|
color: var(--color-gold);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
}
|
}
|
||||||
@@ -539,12 +596,12 @@
|
|||||||
.buy-btn {
|
.buy-btn {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 8px 0;
|
padding: 8px 0;
|
||||||
background: #3d2507;
|
background: var(--color-surface-raised);
|
||||||
border: 1.5px solid #c8861a;
|
border: 1.5px solid var(--color-bronze);
|
||||||
border-radius: 6px;
|
border-radius: var(--radius-md);
|
||||||
color: #f0d080;
|
color: var(--color-gold);
|
||||||
font-family: 'Cinzel', serif;
|
font-family: 'Cinzel', serif;
|
||||||
font-size: 13px;
|
font-size: var(--btn-font-lg);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -572,13 +629,13 @@
|
|||||||
|
|
||||||
.buy-btn.flash-err {
|
.buy-btn.flash-err {
|
||||||
background: #4a1a1a;
|
background: #4a1a1a;
|
||||||
border-color: #c85050;
|
border-color: #c84040;
|
||||||
color: #c85050;
|
color: #c84040;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cost-icon {
|
.cost-icon {
|
||||||
color: #7ecfcf;
|
color: var(--color-cyan);
|
||||||
font-size: 12px;
|
font-size: var(--text-sm);
|
||||||
position: relative;
|
position: relative;
|
||||||
top: -0.1em;
|
top: -0.1em;
|
||||||
}
|
}
|
||||||
@@ -587,7 +644,7 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 800px;
|
max-width: 800px;
|
||||||
height: 1px;
|
height: 1px;
|
||||||
background: rgba(107, 76, 30, 0.3);
|
background: var(--color-border-dim);
|
||||||
}
|
}
|
||||||
|
|
||||||
.shard-section {
|
.shard-section {
|
||||||
@@ -597,33 +654,44 @@
|
|||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 800px;
|
max-width: 800px;
|
||||||
|
margin-top: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-title {
|
.section-title {
|
||||||
font-family: 'Cinzel', serif;
|
font-family: 'Cinzel', serif;
|
||||||
font-size: 16px;
|
font-size: var(--text-base);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.1em;
|
letter-spacing: 0.1em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: rgba(240, 180, 80, 0.5);
|
color: var(--color-gold-faint);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-hint {
|
.section-hint {
|
||||||
font-family: 'Crimson Text', serif;
|
font-family: 'Crimson Text', serif;
|
||||||
font-size: 15px;
|
font-size: var(--text-md);
|
||||||
font-style: italic;
|
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;
|
margin: 0;
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.payment-success {
|
.payment-success {
|
||||||
font-family: 'Crimson Text', serif;
|
font-family: 'Crimson Text', serif;
|
||||||
font-size: 15px;
|
font-size: var(--text-md);
|
||||||
color: #6aaa6a;
|
color: var(--color-success);
|
||||||
background: #0d2a0d;
|
background: #0d2a0d;
|
||||||
border: 1px solid #6aaa6a;
|
border: 1px solid var(--color-success);
|
||||||
border-radius: 6px;
|
border-radius: var(--radius-md);
|
||||||
padding: 0.6rem 1.2rem;
|
padding: 0.6rem 1.2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -631,12 +699,13 @@
|
|||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, 1fr);
|
grid-template-columns: repeat(3, 1fr);
|
||||||
gap: 1.25rem;
|
gap: 1.25rem;
|
||||||
|
margin-top: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.shard-card {
|
.shard-card {
|
||||||
position: relative;
|
position: relative;
|
||||||
background: #1a1008;
|
background: var(--color-surface);
|
||||||
border: 1.5px solid rgba(107, 76, 30, 0.4);
|
border: 1.5px solid var(--color-border-subtle);
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
padding: 2rem 2rem 1.75rem;
|
padding: 2rem 2rem 1.75rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -651,14 +720,14 @@
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
top: -14px;
|
top: -14px;
|
||||||
right: -14px;
|
right: -14px;
|
||||||
background: #7ecfcf;
|
background: var(--color-cyan);
|
||||||
color: #0d0a04;
|
color: var(--color-bg);
|
||||||
font-family: 'Cinzel', serif;
|
font-family: 'Cinzel', serif;
|
||||||
font-size: 12px;
|
font-size: var(--text-sm);
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
padding: 8px 11px;
|
padding: 8px 11px;
|
||||||
border-radius: 5px;
|
border-radius: var(--radius-md);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
transform: rotate(8deg);
|
transform: rotate(8deg);
|
||||||
box-shadow: 0 2px 10px rgba(0,0,0,0.6);
|
box-shadow: 0 2px 10px rgba(0,0,0,0.6);
|
||||||
@@ -670,13 +739,13 @@
|
|||||||
top: -0.1em;
|
top: -0.1em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.shard-card:hover { border-color: #7ecfcf; }
|
.shard-card:hover { border-color: var(--color-cyan); }
|
||||||
|
|
||||||
.shard-amount {
|
.shard-amount {
|
||||||
font-family: 'Cinzel', serif;
|
font-family: 'Cinzel', serif;
|
||||||
font-size: 26px;
|
font-size: var(--text-2xl);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #7ecfcf;
|
color: var(--color-cyan);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.3rem;
|
gap: 0.3rem;
|
||||||
@@ -685,12 +754,13 @@
|
|||||||
.shard-icon {
|
.shard-icon {
|
||||||
position: relative;
|
position: relative;
|
||||||
top: -0.1em;
|
top: -0.1em;
|
||||||
|
animation: shard-pulse 3s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
.shard-price {
|
.shard-price {
|
||||||
font-family: 'Crimson Text', serif;
|
font-family: 'Crimson Text', serif;
|
||||||
font-size: 19px;
|
font-size: var(--text-lg);
|
||||||
color: rgba(240, 180, 80, 0.6);
|
color: var(--color-gold-dim);
|
||||||
}
|
}
|
||||||
|
|
||||||
.stripe-btn {
|
.stripe-btn {
|
||||||
@@ -698,10 +768,10 @@
|
|||||||
padding: 10px 0;
|
padding: 10px 0;
|
||||||
background: #1a3a4a;
|
background: #1a3a4a;
|
||||||
border: 1.5px solid #4a9aba;
|
border: 1.5px solid #4a9aba;
|
||||||
border-radius: 6px;
|
border-radius: var(--radius-md);
|
||||||
color: #a0d8ef;
|
color: #a0d8ef;
|
||||||
font-family: 'Cinzel', serif;
|
font-family: 'Cinzel', serif;
|
||||||
font-size: 13px;
|
font-size: var(--btn-font-lg);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.06em;
|
letter-spacing: 0.06em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -723,8 +793,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.specific-card-preview {
|
.specific-card-preview {
|
||||||
background: #1a1008;
|
background: var(--color-surface);
|
||||||
border: 1.5px solid rgba(107, 76, 30, 0.4);
|
border: 1.5px solid var(--color-border-subtle);
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
padding: 2rem 2.5rem;
|
padding: 2rem 2.5rem;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -732,11 +802,11 @@
|
|||||||
|
|
||||||
.specific-card-title {
|
.specific-card-title {
|
||||||
font-family: 'Cinzel', serif;
|
font-family: 'Cinzel', serif;
|
||||||
font-size: 14px;
|
font-size: var(--text-base);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.1em;
|
letter-spacing: 0.1em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: rgba(240, 180, 80, 0.6);
|
color: var(--color-gold-dim);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -775,7 +845,7 @@
|
|||||||
|
|
||||||
.specific-desc {
|
.specific-desc {
|
||||||
font-family: 'Crimson Text', serif;
|
font-family: 'Crimson Text', serif;
|
||||||
font-size: 16px;
|
font-size: var(--text-md);
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
color: rgba(240, 180, 80, 0.55);
|
color: rgba(240, 180, 80, 0.55);
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
@@ -784,15 +854,15 @@
|
|||||||
|
|
||||||
.specific-buy-btn {
|
.specific-buy-btn {
|
||||||
font-family: 'Cinzel', serif;
|
font-family: 'Cinzel', serif;
|
||||||
font-size: 13px;
|
font-size: var(--btn-font-lg);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.06em;
|
letter-spacing: 0.06em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
background: #3d2507;
|
background: var(--color-surface-raised);
|
||||||
border: 1.5px solid #c8861a;
|
border: 1.5px solid var(--color-bronze);
|
||||||
border-radius: 6px;
|
border-radius: var(--radius-md);
|
||||||
color: #f0d080;
|
color: var(--color-gold);
|
||||||
padding: 10px 20px;
|
padding: var(--btn-padding-lg);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -805,7 +875,7 @@
|
|||||||
|
|
||||||
.specific-cant-afford {
|
.specific-cant-afford {
|
||||||
font-family: 'Crimson Text', serif;
|
font-family: 'Crimson Text', serif;
|
||||||
font-size: 13px;
|
font-size: var(--text-base);
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
color: rgba(200, 100, 80, 0.7);
|
color: rgba(200, 100, 80, 0.7);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -815,7 +885,7 @@
|
|||||||
.specific-overlay {
|
.specific-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
z-index: 300;
|
z-index: var(--z-modal);
|
||||||
background: rgba(0,0,0,0.92);
|
background: rgba(0,0,0,0.92);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -824,8 +894,8 @@
|
|||||||
|
|
||||||
/* Input modal */
|
/* Input modal */
|
||||||
.specific-modal {
|
.specific-modal {
|
||||||
background: #1a1008;
|
background: var(--color-surface);
|
||||||
border: 1.5px solid rgba(107, 76, 30, 0.6);
|
border: 1.5px solid var(--color-border-subtle);
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
padding: 2.5rem 2.5rem 2rem;
|
padding: 2.5rem 2.5rem 2rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -836,40 +906,40 @@
|
|||||||
|
|
||||||
.modal-title {
|
.modal-title {
|
||||||
font-family: 'Cinzel', serif;
|
font-family: 'Cinzel', serif;
|
||||||
font-size: 18px;
|
font-size: var(--text-lg);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.08em;
|
letter-spacing: 0.08em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: #f0d080;
|
color: var(--color-gold);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-hint {
|
.modal-hint {
|
||||||
font-family: 'Crimson Text', serif;
|
font-family: 'Crimson Text', serif;
|
||||||
font-size: 15px;
|
font-size: var(--text-md);
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
color: rgba(240, 180, 80, 0.5);
|
color: var(--color-gold-faint);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wiki-input {
|
.wiki-input {
|
||||||
font-family: 'Crimson Text', serif;
|
font-family: 'Crimson Text', serif;
|
||||||
font-size: 17px;
|
font-size: var(--text-lg);
|
||||||
background: #0d0a04;
|
background: var(--color-bg);
|
||||||
border: 1.5px solid rgba(107, 76, 30, 0.6);
|
border: 1.5px solid var(--color-border-subtle);
|
||||||
border-radius: 6px;
|
border-radius: var(--radius-md);
|
||||||
color: #f0d080;
|
color: var(--color-gold);
|
||||||
padding: 10px 14px;
|
padding: 10px 14px;
|
||||||
outline: none;
|
outline: none;
|
||||||
transition: border-color 0.15s;
|
transition: border-color 0.15s;
|
||||||
}
|
}
|
||||||
.wiki-input:focus { border-color: #c8861a; }
|
.wiki-input:focus { border-color: var(--color-bronze); }
|
||||||
.wiki-input::placeholder { color: rgba(240, 180, 80, 0.25); }
|
.wiki-input::placeholder { color: var(--color-gold-faint); }
|
||||||
|
|
||||||
.modal-error {
|
.modal-error {
|
||||||
font-family: 'Crimson Text', serif;
|
font-family: 'Crimson Text', serif;
|
||||||
font-size: 14px;
|
font-size: var(--text-base);
|
||||||
color: #c85050;
|
color: var(--color-error);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -882,30 +952,30 @@
|
|||||||
|
|
||||||
.modal-cancel {
|
.modal-cancel {
|
||||||
font-family: 'Cinzel', serif;
|
font-family: 'Cinzel', serif;
|
||||||
font-size: 11px;
|
font-size: var(--text-sm);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.06em;
|
letter-spacing: 0.06em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
background: none;
|
background: none;
|
||||||
border: 1px solid rgba(107, 76, 30, 0.5);
|
border: 1px solid var(--color-border-subtle);
|
||||||
border-radius: 5px;
|
border-radius: var(--radius-md);
|
||||||
color: rgba(240, 180, 80, 0.5);
|
color: var(--color-gold-faint);
|
||||||
padding: 8px 18px;
|
padding: 8px 18px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: border-color 0.15s, color 0.15s;
|
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 {
|
.modal-confirm {
|
||||||
font-family: 'Cinzel', serif;
|
font-family: 'Cinzel', serif;
|
||||||
font-size: 11px;
|
font-size: var(--text-sm);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.06em;
|
letter-spacing: 0.06em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
background: #3d2507;
|
background: var(--color-surface-raised);
|
||||||
border: 1.5px solid #c8861a;
|
border: 1.5px solid var(--color-bronze);
|
||||||
border-radius: 5px;
|
border-radius: var(--radius-md);
|
||||||
color: #f0d080;
|
color: var(--color-gold);
|
||||||
padding: 8px 18px;
|
padding: 8px 18px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -936,7 +1006,7 @@
|
|||||||
inset: 0;
|
inset: 0;
|
||||||
background: white;
|
background: white;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
z-index: 9999;
|
z-index: 300;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
}
|
}
|
||||||
@@ -1000,26 +1070,98 @@
|
|||||||
|
|
||||||
.reveal-label {
|
.reveal-label {
|
||||||
font-family: 'Crimson Text', serif;
|
font-family: 'Crimson Text', serif;
|
||||||
font-size: 17px;
|
font-size: var(--text-lg);
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
color: rgba(240, 180, 80, 0.6);
|
color: var(--color-gold-dim);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
transition: opacity 0.6s ease;
|
transition: opacity 0.6s ease;
|
||||||
}
|
}
|
||||||
.reveal-label.hidden { opacity: 0; }
|
.reveal-label.hidden { opacity: 0; }
|
||||||
.close-reveal-btn.hidden { opacity: 0; pointer-events: none; }
|
.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 {
|
.close-reveal-btn {
|
||||||
font-family: 'Cinzel', serif;
|
font-family: 'Cinzel', serif;
|
||||||
font-size: 13px;
|
font-size: var(--btn-font-lg);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.08em;
|
letter-spacing: 0.08em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
background: rgba(60,30,5,0.85);
|
background: rgba(60,30,5,0.85);
|
||||||
border: 1.5px solid #c8861a;
|
border: 1.5px solid var(--color-bronze);
|
||||||
border-radius: 6px;
|
border-radius: var(--radius-md);
|
||||||
color: #f0d080;
|
color: var(--color-gold);
|
||||||
padding: 10px 32px;
|
padding: var(--btn-padding-lg);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.15s;
|
transition: background 0.15s;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<script>
|
<script lang="ts">
|
||||||
import { API_URL, WS_URL, apiFetch } from '$lib/api.js';
|
import { API_URL, WS_URL, apiFetch } from '$lib/api.js';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { onMount, onDestroy } from 'svelte';
|
import { onMount, onDestroy } from 'svelte';
|
||||||
@@ -9,13 +9,17 @@
|
|||||||
|
|
||||||
let phase = $state('idle'); // idle | queuing | trading | complete
|
let phase = $state('idle'); // idle | queuing | trading | complete
|
||||||
let error = $state('');
|
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 queueWs: WebSocket | null = null;
|
||||||
let tradeWs = null;
|
let tradeWs: WebSocket | null = null;
|
||||||
let tradeId = $state('');
|
let tradeId = $state('');
|
||||||
|
|
||||||
let allCards = $state([]); // user's full card collection (for selector)
|
let tradeState: any = $state(null); // latest trade state from server
|
||||||
let tradeState = $state(null); // latest trade state from server
|
|
||||||
|
|
||||||
let selectorOpen = $state(false);
|
let selectorOpen = $state(false);
|
||||||
let selectorIds = $state(new Set());
|
let selectorIds = $state(new Set());
|
||||||
@@ -35,12 +39,11 @@
|
|||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
if (!token()) { goto('/auth'); return; }
|
if (!token()) { goto('/auth'); return; }
|
||||||
const res = await apiFetch(`${API_URL}/cards`);
|
|
||||||
if (!res.ok) { goto('/auth'); return; }
|
|
||||||
allCards = await res.json();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
|
clearTimeout(queueReconnectTimer);
|
||||||
|
clearTimeout(tradeReconnectTimer);
|
||||||
queueWs?.close();
|
queueWs?.close();
|
||||||
tradeWs?.close();
|
tradeWs?.close();
|
||||||
});
|
});
|
||||||
@@ -49,12 +52,18 @@
|
|||||||
error = '';
|
error = '';
|
||||||
phase = 'queuing';
|
phase = 'queuing';
|
||||||
queueWs = new WebSocket(`${WS_URL}/ws/trade/queue`);
|
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) => {
|
queueWs.onmessage = (e) => {
|
||||||
const msg = JSON.parse(e.data);
|
const msg = JSON.parse(e.data);
|
||||||
if (msg.type === 'trade_start') {
|
if (msg.type === 'trade_start') {
|
||||||
tradeId = msg.trade_id;
|
tradeId = msg.trade_id;
|
||||||
queueWs.close();
|
// Set phase before close so onclose doesn't trigger a reconnect
|
||||||
|
phase = 'trading';
|
||||||
|
queueWs!.close();
|
||||||
connectToTrade();
|
connectToTrade();
|
||||||
} else if (msg.type === 'error') {
|
} else if (msg.type === 'error') {
|
||||||
error = msg.message;
|
error = msg.message;
|
||||||
@@ -62,22 +71,37 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
queueWs.onerror = () => { error = 'Connection failed'; phase = 'idle'; };
|
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() {
|
function cancelQueue() {
|
||||||
queueWs?.close();
|
clearTimeout(queueReconnectTimer);
|
||||||
phase = 'idle';
|
phase = 'idle';
|
||||||
|
reconnecting = false;
|
||||||
|
queueWs?.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
function connectToTrade() {
|
function connectToTrade() {
|
||||||
phase = 'trading';
|
|
||||||
tradeWs = new WebSocket(`${WS_URL}/ws/trade/${tradeId}`);
|
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) => {
|
tradeWs.onmessage = (e) => {
|
||||||
const msg = JSON.parse(e.data);
|
const msg = JSON.parse(e.data);
|
||||||
if (msg.type === 'state') {
|
if (msg.type === 'state') {
|
||||||
tradeState = msg.state;
|
tradeState = msg.state;
|
||||||
} else if (msg.type === 'trade_complete') {
|
} else if (msg.type === 'trade_complete') {
|
||||||
|
// Set phase before close so onclose doesn't trigger a reconnect
|
||||||
phase = 'complete';
|
phase = 'complete';
|
||||||
tradeWs?.close();
|
tradeWs?.close();
|
||||||
} else if (msg.type === 'error') {
|
} else if (msg.type === 'error') {
|
||||||
@@ -90,9 +114,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
tradeWs.onerror = () => { error = 'Connection lost'; phase = 'idle'; };
|
tradeWs.onerror = () => { error = 'Connection lost'; reconnecting = false; phase = 'idle'; };
|
||||||
tradeWs.onclose = (e) => {
|
tradeWs.onclose = () => {
|
||||||
if (phase === 'trading') { error = 'Connection lost'; phase = 'idle'; }
|
if (phase === 'trading') {
|
||||||
|
reconnecting = true;
|
||||||
|
tradeReconnectTimer = setTimeout(() => {
|
||||||
|
tradeReconnectDelay = Math.min(tradeReconnectDelay * 2, 30000);
|
||||||
|
connectToTrade();
|
||||||
|
}, tradeReconnectDelay);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,7 +130,7 @@
|
|||||||
if (myOffer.accepted) {
|
if (myOffer.accepted) {
|
||||||
tradeWs?.send(JSON.stringify({ type: 'unaccept' }));
|
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;
|
selectorOpen = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,7 +148,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function reset() {
|
function reset() {
|
||||||
|
clearTimeout(tradeReconnectTimer);
|
||||||
phase = 'idle';
|
phase = 'idle';
|
||||||
|
reconnecting = false;
|
||||||
tradeState = null;
|
tradeState = null;
|
||||||
tradeId = '';
|
tradeId = '';
|
||||||
error = '';
|
error = '';
|
||||||
@@ -140,7 +172,7 @@
|
|||||||
{:else if phase === 'queuing'}
|
{:else if phase === 'queuing'}
|
||||||
<div class="center-screen">
|
<div class="center-screen">
|
||||||
<div class="spinner"></div>
|
<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>
|
<button class="cancel-btn" onclick={cancelQueue}>Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -149,7 +181,6 @@
|
|||||||
{#if selectorOpen}
|
{#if selectorOpen}
|
||||||
<div class="selector-overlay">
|
<div class="selector-overlay">
|
||||||
<CardSelector
|
<CardSelector
|
||||||
allCards={allCards}
|
|
||||||
bind:selectedIds={selectorIds}
|
bind:selectedIds={selectorIds}
|
||||||
onclose={closeSelector}
|
onclose={closeSelector}
|
||||||
/>
|
/>
|
||||||
@@ -185,7 +216,7 @@
|
|||||||
|
|
||||||
<div class="panel their-panel">
|
<div class="panel their-panel">
|
||||||
<div class="panel-header">
|
<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}
|
{#if theirOffer.accepted}
|
||||||
<span class="accepted-badge">Accepted ✓</span>
|
<span class="accepted-badge">Accepted ✓</span>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -211,7 +242,9 @@
|
|||||||
<div class="action-bar">
|
<div class="action-bar">
|
||||||
<button class="choose-btn" onclick={openSelector}>Choose Cards</button>
|
<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>
|
<span class="error-inline">{error}</span>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -246,11 +279,10 @@
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
<style>
|
<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 {
|
main {
|
||||||
height: calc(100vh - 56px);
|
height: calc(100vh - 56px);
|
||||||
background: #0d0a04;
|
background: var(--color-bg);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -270,40 +302,40 @@
|
|||||||
|
|
||||||
.title {
|
.title {
|
||||||
font-family: 'Cinzel', serif;
|
font-family: 'Cinzel', serif;
|
||||||
font-size: 36px;
|
font-size: var(--text-3xl);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #f0d080;
|
color: var(--color-gold);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.04em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.subtitle {
|
.subtitle {
|
||||||
font-family: 'Crimson Text', serif;
|
font-family: 'Crimson Text', serif;
|
||||||
font-size: 18px;
|
font-size: var(--text-lg);
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
color: rgba(240, 180, 80, 0.6);
|
color: var(--color-gold-dim);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.error {
|
.error {
|
||||||
font-family: 'Crimson Text', serif;
|
font-family: 'Crimson Text', serif;
|
||||||
font-size: 15px;
|
font-size: var(--text-md);
|
||||||
color: #c85050;
|
color: var(--color-error);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.primary-btn {
|
.primary-btn {
|
||||||
font-family: 'Cinzel', serif;
|
font-family: 'Cinzel', serif;
|
||||||
font-size: 13px;
|
font-size: var(--btn-font-lg);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.06em;
|
letter-spacing: 0.06em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
background: #3d2507;
|
background: var(--color-surface-raised);
|
||||||
border: 1px solid #c8861a;
|
border: 1px solid var(--color-bronze);
|
||||||
border-radius: 4px;
|
border-radius: var(--radius-md);
|
||||||
color: #f0d080;
|
color: var(--color-gold);
|
||||||
padding: 10px 28px;
|
padding: var(--btn-padding-lg);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.15s;
|
transition: background 0.15s;
|
||||||
margin-top: 0.5rem;
|
margin-top: 0.5rem;
|
||||||
@@ -313,24 +345,24 @@
|
|||||||
|
|
||||||
.cancel-btn {
|
.cancel-btn {
|
||||||
font-family: 'Cinzel', serif;
|
font-family: 'Cinzel', serif;
|
||||||
font-size: 11px;
|
font-size: var(--btn-font-md);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.06em;
|
letter-spacing: 0.06em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
background: none;
|
background: none;
|
||||||
border: 1px solid rgba(107, 76, 30, 0.5);
|
border: 1px solid var(--color-border-subtle);
|
||||||
border-radius: 4px;
|
border-radius: var(--radius-sm);
|
||||||
color: rgba(240, 180, 80, 0.5);
|
color: rgba(240, 180, 80, 0.5);
|
||||||
padding: 6px 18px;
|
padding: var(--btn-padding-md);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.15s;
|
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 {
|
.searching-text {
|
||||||
font-family: 'Crimson Text', serif;
|
font-family: 'Crimson Text', serif;
|
||||||
font-size: 18px;
|
font-size: var(--text-lg);
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
color: rgba(240, 180, 80, 0.7);
|
color: rgba(240, 180, 80, 0.7);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -340,8 +372,8 @@
|
|||||||
width: 40px;
|
width: 40px;
|
||||||
height: 40px;
|
height: 40px;
|
||||||
border: 3px solid rgba(200, 134, 26, 0.2);
|
border: 3px solid rgba(200, 134, 26, 0.2);
|
||||||
border-top-color: #c8861a;
|
border-top-color: var(--color-bronze);
|
||||||
border-radius: 50%;
|
border-radius: var(--radius-full);
|
||||||
animation: spin 0.9s linear infinite;
|
animation: spin 0.9s linear infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -349,20 +381,20 @@
|
|||||||
|
|
||||||
.complete-icon {
|
.complete-icon {
|
||||||
font-size: 56px;
|
font-size: 56px;
|
||||||
color: #6aaa6a;
|
color: var(--color-success);
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.secondary-link {
|
.secondary-link {
|
||||||
font-family: 'Crimson Text', serif;
|
font-family: 'Crimson Text', serif;
|
||||||
font-size: 15px;
|
font-size: var(--text-md);
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
color: rgba(240, 180, 80, 0.5);
|
color: rgba(240, 180, 80, 0.5);
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
transition: color 0.15s;
|
transition: color 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.secondary-link:hover { color: #f0d080; }
|
.secondary-link:hover { color: var(--color-gold); }
|
||||||
|
|
||||||
/* ── Trade layout ── */
|
/* ── Trade layout ── */
|
||||||
|
|
||||||
@@ -394,27 +426,30 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
padding: 1rem 1.5rem 0.75rem;
|
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 {
|
.panel-title {
|
||||||
font-family: 'Cinzel', serif;
|
font-family: 'Cinzel', serif;
|
||||||
font-size: 14px;
|
font-size: var(--text-base);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.06em;
|
letter-spacing: 0.06em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: rgba(240, 180, 80, 0.7);
|
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 {
|
.accepted-badge {
|
||||||
font-family: 'Cinzel', serif;
|
font-family: 'Cinzel', serif;
|
||||||
font-size: 11px;
|
font-size: var(--text-sm);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.04em;
|
||||||
color: #6aaa6a;
|
color: var(--color-success);
|
||||||
background: rgba(106, 170, 106, 0.12);
|
background: rgba(106, 170, 106, 0.12);
|
||||||
border: 1px solid rgba(106, 170, 106, 0.4);
|
border: 1px solid rgba(106, 170, 106, 0.4);
|
||||||
border-radius: 3px;
|
border-radius: var(--radius-sm);
|
||||||
padding: 2px 7px;
|
padding: 2px 7px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -433,7 +468,7 @@
|
|||||||
|
|
||||||
.empty-offer p {
|
.empty-offer p {
|
||||||
font-family: 'Crimson Text', serif;
|
font-family: 'Crimson Text', serif;
|
||||||
font-size: 15px;
|
font-size: var(--text-md);
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
color: rgba(240, 180, 80, 0.25);
|
color: rgba(240, 180, 80, 0.25);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -456,7 +491,7 @@
|
|||||||
.divider {
|
.divider {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
width: 1px;
|
width: 1px;
|
||||||
background: rgba(107, 76, 30, 0.35);
|
background: var(--color-border-dim);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -468,43 +503,58 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
padding: 1rem 1.5rem;
|
padding: 1rem 1.5rem;
|
||||||
border-top: 1px solid rgba(107, 76, 30, 0.35);
|
border-top: 1px solid var(--color-border-dim);
|
||||||
background: #0d0a04;
|
background: var(--color-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.choose-btn {
|
.choose-btn {
|
||||||
font-family: 'Cinzel', serif;
|
font-family: 'Cinzel', serif;
|
||||||
font-size: 11px;
|
font-size: var(--btn-font-md);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.06em;
|
letter-spacing: 0.06em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
background: #1e1208;
|
background: #1e1208;
|
||||||
border: 1px solid rgba(107, 76, 30, 0.6);
|
border: 1px solid rgba(107, 76, 30, 0.6);
|
||||||
border-radius: 4px;
|
border-radius: var(--radius-sm);
|
||||||
color: rgba(240, 180, 80, 0.8);
|
color: rgba(240, 180, 80, 0.8);
|
||||||
padding: 8px 18px;
|
padding: var(--btn-padding-md);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.15s;
|
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 {
|
.error-inline {
|
||||||
font-family: 'Crimson Text', serif;
|
font-family: 'Crimson Text', serif;
|
||||||
font-size: 14px;
|
font-size: var(--text-base);
|
||||||
color: #c85050;
|
color: var(--color-error);
|
||||||
flex: 1;
|
flex: 1;
|
||||||
text-align: center;
|
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 {
|
.accept-btn {
|
||||||
font-family: 'Cinzel', serif;
|
font-family: 'Cinzel', serif;
|
||||||
font-size: 13px;
|
font-size: var(--btn-font-lg);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.06em;
|
letter-spacing: 0.06em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
border-radius: 4px;
|
border-radius: var(--radius-md);
|
||||||
padding: 10px 28px;
|
padding: var(--btn-padding-lg);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
@@ -520,22 +570,22 @@
|
|||||||
|
|
||||||
/* Ready: gold, inviting click */
|
/* Ready: gold, inviting click */
|
||||||
.accept-btn.accept-ready {
|
.accept-btn.accept-ready {
|
||||||
background: #3d2507;
|
background: var(--color-surface-raised);
|
||||||
border: 2px solid #c8861a;
|
border: 2px solid var(--color-bronze);
|
||||||
color: #f0d080;
|
color: var(--color-gold);
|
||||||
box-shadow: 0 0 12px rgba(200, 134, 26, 0.2);
|
box-shadow: 0 0 12px rgba(200, 134, 26, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.accept-btn.accept-ready:hover {
|
.accept-btn.accept-ready:hover {
|
||||||
background: #5a3510;
|
background: #5a3510;
|
||||||
box-shadow: 0 0 20px rgba(200, 134, 26, 0.4);
|
box-shadow: var(--shadow-glow);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Accepted: bright green, pulsing, waiting */
|
/* Accepted: bright green, pulsing, waiting */
|
||||||
.accept-btn.accept-accepted {
|
.accept-btn.accept-accepted {
|
||||||
background: rgba(40, 90, 40, 0.4);
|
background: rgba(40, 90, 40, 0.4);
|
||||||
border: 2px solid #6aaa6a;
|
border: 2px solid var(--color-success);
|
||||||
color: #6aaa6a;
|
color: var(--color-success);
|
||||||
cursor: default;
|
cursor: default;
|
||||||
animation: pulse-green 1.8s ease-in-out infinite;
|
animation: pulse-green 1.8s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
@@ -548,7 +598,7 @@
|
|||||||
.selector-overlay {
|
.selector-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
z-index: 200;
|
z-index: var(--z-dropdown);
|
||||||
background: rgba(0, 0, 0, 0.9);
|
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>
|
</main>
|
||||||
|
|
||||||
<style>
|
<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 {
|
main {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background: #0d0a04;
|
background: var(--color-bg);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -63,9 +61,9 @@
|
|||||||
|
|
||||||
.card {
|
.card {
|
||||||
width: 380px;
|
width: 380px;
|
||||||
background: #3d2507;
|
background: var(--color-surface-raised);
|
||||||
border: 2px solid #c8861a;
|
border: 2px solid var(--color-bronze);
|
||||||
border-radius: 12px;
|
border-radius: var(--radius-xl);
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -74,17 +72,17 @@
|
|||||||
|
|
||||||
.title {
|
.title {
|
||||||
font-family: 'Cinzel', serif;
|
font-family: 'Cinzel', serif;
|
||||||
font-size: 20px;
|
font-size: var(--text-xl);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #f5d060;
|
color: var(--color-gold);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hint {
|
.hint {
|
||||||
font-family: 'Crimson Text', serif;
|
font-family: 'Crimson Text', serif;
|
||||||
font-size: 15px;
|
font-size: var(--text-md);
|
||||||
color: rgba(245, 208, 96, 0.7);
|
color: var(--color-gold-dim);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
@@ -92,17 +90,17 @@
|
|||||||
|
|
||||||
.btn {
|
.btn {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 10px;
|
padding: var(--btn-padding-lg);
|
||||||
background: #c8861a;
|
background: var(--color-bronze);
|
||||||
color: #fff8e0;
|
color: var(--color-btn-text);
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 6px;
|
border-radius: var(--radius-md);
|
||||||
font-family: 'Cinzel', serif;
|
font-family: 'Cinzel', serif;
|
||||||
font-size: 13px;
|
font-size: var(--btn-font-lg);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.15s;
|
transition: background 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn:hover { background: #e09820; }
|
.btn:hover { background: var(--color-bronze-hover); }
|
||||||
</style>
|
</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