Initial commit

This commit is contained in:
2026-07-28 08:58:44 +02:00
commit c3c1f5e216
46 changed files with 10231 additions and 0 deletions
+97
View File
@@ -0,0 +1,97 @@
<div class="card max-w-sm mx-auto mt-16">
<header>
<h2>Connexion</h2>
<p>Connectez-vous à votre compte PokeShare</p>
</header>
<section>
<form id="login-form" class="grid gap-4">
<div role="group" class="field">
<label for="username">Nom d'utilisateur</label>
<input
class="input"
type="text"
id="username"
name="username"
placeholder="votre@email.com"
required
/>
</div>
<div role="group" class="field">
<label for="password">Mot de passe</label>
<input
class="input"
type="password"
id="password"
name="password"
placeholder="••••••••"
required
/>
</div>
</form>
</section>
<footer class="flex-col gap-2">
<button
class="btn w-full"
type="submit"
form="login-form"
id="login-submit"
>
Se connecter
</button>
</footer>
</div>
<script>
const form = document.getElementById("login-form");
const submitBtn = document.getElementById(
"login-submit",
) as HTMLButtonElement;
const toaster = document.getElementById("toaster") as ToasterElement;
const showToast = (
category: "success" | "error",
title: string,
description: string,
) => {
if (toaster?.toast) {
toaster.toast({ category, title, description });
}
};
form?.addEventListener("submit", async (e) => {
e.preventDefault();
const formData = new FormData(form as HTMLFormElement);
const username = formData.get("username");
const password = formData.get("password");
submitBtn.disabled = true;
const originalText = submitBtn.innerHTML;
submitBtn.innerHTML = `<svg aria-label="Loading" role="status" data-icon="inline-start" class="animate-spin lucide lucide-loader-circle" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-6.219-8.56" /></svg> Connexion...`;
try {
const res = await fetch("/api/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
if (res.ok) {
window.location.href = "/";
} else {
const data = await res.json();
showToast(
"error",
"Erreur",
data.error ?? "Identifiants incorrects",
);
submitBtn.disabled = false;
submitBtn.innerHTML = originalText;
}
} catch {
showToast("error", "Erreur", "Erreur réseau");
submitBtn.disabled = false;
submitBtn.innerHTML = originalText;
}
});
</script>
+304
View File
@@ -0,0 +1,304 @@
---
const { pb } = Astro.locals;
const user = pb.authStore.record;
const initials = user
? (user.name || user.email || "")
.split(/[\s@.]+/)
.filter(Boolean)
.slice(0, 2)
.map((s: string) => s[0].toUpperCase())
.join("")
: "";
---
<header
class="sticky top-0 z-50 border-b border-border bg-background/95 backdrop-blur"
>
<nav
class="mx-auto flex h-16 max-w-7xl items-center justify-between px-4 sm:px-8"
>
<a
href="/"
class="flex items-center gap-2 text-lg font-bold no-underline"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="1.2em"
height="1.2em"
viewBox="0 0 24 24"
>
<path d="M0 0h24v24H0z" fill="none"></path>
<path
fill="currentColor"
d="M12 2a10 10 0 0 1 10 10a10 10 0 0 1-10 10A10 10 0 0 1 2 12A10 10 0 0 1 12 2m0 2c-4.08 0-7.45 3.05-7.94 7h4.07c.44-1.73 2.01-3 3.87-3s3.43 1.27 3.87 3h4.07c-.49-3.95-3.86-7-7.94-7m0 16c4.08 0 7.45-3.05 7.94-7h-4.07c-.44 1.73-2.01 3-3.87 3s-3.43-1.27-3.87-3H4.06c.49 3.95 3.86 7 7.94 7m0-10a2 2 0 0 0-2 2a2 2 0 0 0 2 2a2 2 0 0 0 2-2a2 2 0 0 0-2-2"
></path>
</svg>
PokeShare
</a>
<ul class="flex items-center gap-1 list-none m-0 p-0">
<li>
<a
href="/friends"
class="btn relative"
data-variant="ghost"
data-size="icon-sm"
aria-label="Amis"
title="Amis"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"
></path>
<circle cx="9" cy="7" r="4"></circle>
<path d="M22 21v-2a4 4 0 0 0-3-3.87"></path>
<path d="M16 3.13a4 4 0 0 1 0 7.75"></path>
</svg>
</a>
</li>
<li>
<a
href="/search"
class="btn"
data-variant="ghost"
data-size="sm"
>
<svg
data-icon="inline-start"
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
viewBox="0 0 24 24"
>
<path d="M0 0h24v24H0z" fill="none"></path>
<path
fill="currentColor"
d="M9.5 3A6.5 6.5 0 0 1 16 9.5c0 1.61-.59 3.09-1.56 4.23l.27.27h.79l5 5l-1.5 1.5l-5-5v-.79l-.27-.27A6.52 6.52 0 0 1 9.5 16A6.5 6.5 0 0 1 3 9.5A6.5 6.5 0 0 1 9.5 3m0 2C7 5 5 7 5 9.5S7 14 9.5 14S14 12 14 9.5S12 5 9.5 5"
></path>
</svg>
<span class="hidden sm:inline">Rechercher</span>
</a>
</li>
<li>
<a
href="/mycards"
class="btn"
data-variant="ghost"
data-size="sm"
>
<svg
data-icon="inline-start"
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
viewBox="0 0 24 24"
>
<path d="M0 0h24v24H0z" fill="none"></path>
<path
fill="currentColor"
d="m12.1 18.55l-.1.1l-.11-.1C7.14 14.24 4 11.39 4 8.5C4 6.5 5.5 5 7.5 5c1.54 0 3.04 1 3.57 2.36h1.86C13.46 6 14.96 5 16.5 5c2 0 3.5 1.5 3.5 3.5c0 2.89-3.14 5.74-7.9 10.05M16.5 3c-1.74 0-3.41.81-4.5 2.08C10.91 3.81 9.24 3 7.5 3C4.42 3 2 5.41 2 8.5c0 3.77 3.4 6.86 8.55 11.53L12 21.35l1.45-1.32C18.6 15.36 22 12.27 22 8.5C22 5.41 19.58 3 16.5 3"
></path>
</svg>
<span class="hidden sm:inline">Mes cartes</span>
</a>
</li>
{
user ? (
<li class="ml-2">
<div id="user-menu" class="dropdown-menu">
<button
type="button"
id="user-menu-trigger"
aria-haspopup="menu"
aria-controls="user-menu-dropdown"
aria-expanded="false"
class="btn rounded-full"
data-variant="ghost"
data-size="sm"
>
<span class="avatar" data-size="sm">
<span>{initials}</span>
</span>
<span class="hidden sm:inline ml-1 text-sm">
{user.name || user.email}
</span>
<svg
data-icon="inline-end"
xmlns="http://www.w3.org/2000/svg"
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="m6 9 6 6 6-6" />
</svg>
</button>
<div
id="user-menu-popover"
data-popover
aria-hidden="true"
data-align="end"
class="w-48"
>
<div
role="menu"
id="user-menu-dropdown"
aria-labelledby="user-menu-trigger"
>
<div role="group">
<div
role="heading"
class="px-2 py-1.5 text-xs opacity-60"
>
{user.email}
</div>
<div
role="menuitem"
onclick="window.location.href='/mycards'"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="m12.1 18.55-.1.1-.11-.1C7.14 14.24 4 11.39 4 8.5C4 6.5 5.5 5 7.5 5c1.54 0 3.04 1 3.57 2.36h1.86C13.46 6 14.96 5 16.5 5c2 0 3.5 1.5 3.5 3.5 0 2.89-3.14 5.74-7.9 10.05" />
</svg>
Ma collection
</div>
<div
role="menuitem"
onclick="window.location.href='/profile'"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
Profil
</div>
</div>
<hr role="separator" />
<div role="group">
<div
role="menuitem"
onclick="fetch('/api/logout', { method: 'POST' }).finally(() => window.location.href='/login')"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="m16 17 5-5-5-5" />
<path d="M21 12H9" />
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
</svg>
Déconnexion
</div>
</div>
</div>
</div>
</div>
</li>
) : (
<li>
<a
href="/login"
class="btn"
data-variant="outline"
data-size="sm"
>
Login
</a>
</li>
)
}
<li>
<button
type="button"
aria-label="Toggle dark mode"
onclick="window.basecoat.theme.toggle()"
class="btn"
data-variant="ghost"
data-size="icon-sm"
>
<span class="hidden dark:block">
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="12" cy="12" r="4"></circle>
<path d="M12 2v2"></path>
<path d="M12 20v2"></path>
<path d="m4.93 4.93 1.41 1.41"></path>
<path d="m17.66 17.66 1.41 1.41"></path>
<path d="M2 12h2"></path>
<path d="M20 12h2"></path>
<path d="m6.34 17.66-1.41 1.41"></path>
<path d="m19.07 4.93-1.41 1.41"></path>
</svg>
</span>
<span class="block dark:hidden">
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path
d="M20.985 12.486a9 9 0 1 1-9.473-9.472c.405-.022.617.46.402.803a6 6 0 0 0 8.268 8.268c.344-.215.825-.004.803.401"
></path>
</svg>
</span>
</button>
</li>
</ul>
</nav>
</header>
+16
View File
@@ -0,0 +1,16 @@
/// <reference types="astro/client" />
declare namespace App {
interface Locals {
pb: import("pocketbase").default;
user: import("pocketbase").default["authStore"]["record"];
}
}
interface ToasterElement extends HTMLDivElement {
toast: (config: {
category?: "success" | "error" | "info" | "warning";
title: string;
description?: string;
}) => void;
}
+64
View File
@@ -0,0 +1,64 @@
---
import Nav from "../components/Nav.astro";
import "../styles/global.css";
const { pb } = Astro.locals;
const user = pb.authStore.record;
const themePreference = user?.theme_preference ?? "System";
// Determine if dark mode should be active
let isDark = false;
if (themePreference === "Dark") {
isDark = true;
} else if (themePreference === "Light") {
isDark = false;
} else {
// System — let the inline script handle it
isDark = false;
}
---
<html
lang="en"
class={isDark ? "dark" : undefined}
data-theme={themePreference === "Light" ? "light" : undefined}
>
<head>
<meta charset="utf-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width" />
<meta name="generator" content={Astro.generator} />
<title>PokeShare</title>
<script is:inline>
(() => {
try {
const html = document.documentElement;
// Server told us to be light — don't add dark
if (html.dataset.theme === "light") return;
// Server told us to be dark — don't remove it
if (html.classList.contains("dark")) return;
const stored = localStorage.getItem("themeMode");
if (
stored
? stored === "dark"
: matchMedia("(prefers-color-scheme: dark)").matches
) {
document.documentElement.classList.add("dark");
}
} catch (_) {}
})();
</script>
</head>
<body class="min-h-dvh bg-background text-foreground antialiased">
<Nav />
<slot />
<div id="toaster" class="toaster" data-align="end"></div>
<script>
import "basecoat-css/basecoat";
import "basecoat-css/tabs";
import "basecoat-css/dropdown-menu";
</script>
</body>
</html>
+117
View File
@@ -0,0 +1,117 @@
/**
* Instant client-side filtering and sorting for the "Ma collection" page.
* Works on the data-* attributes rendered on each grid item and table row,
* and keeps both views (grid + list) in sync.
*/
export function initCollectionFilter() {
const nameInput = document.getElementById(
"cf-name",
) as HTMLInputElement | null;
const setSelect = document.getElementById(
"cf-set",
) as HTMLSelectElement | null;
const sortSelect = document.getElementById(
"cf-sort",
) as HTMLSelectElement | null;
const resetBtn = document.getElementById("cf-reset");
const gridView = document.getElementById("grid-view");
const listBody = document.querySelector<HTMLElement>("#list-view tbody");
const emptyState = document.getElementById("cf-empty");
if (!gridView || !listBody) return;
// Remember the server-rendered order ("Ajout récent") so it can be restored
const containers = [gridView, listBody];
for (const container of containers) {
Array.from(container.children).forEach((el, i) => {
(el as HTMLElement).dataset.idx = String(i);
});
}
// Single-select pill groups (variant, condition): click to activate,
// click again to deactivate
const pillState: Record<string, string> = {};
const pillButtons = document.querySelectorAll<HTMLElement>("[data-cf-group]");
pillButtons.forEach((btn) => {
const group = btn.dataset.cfGroup!;
pillState[group] ??= "";
btn.addEventListener("click", () => {
const value = btn.dataset.value ?? "";
pillState[group] = pillState[group] === value ? "" : value;
pillButtons.forEach((b) => {
if (b.dataset.cfGroup === group) {
b.classList.toggle(
"active",
b.dataset.value === pillState[group],
);
}
});
apply();
});
});
const num = (el: HTMLElement, key: string) =>
parseFloat(el.dataset[key] ?? "0") || 0;
function comparator(mode: string) {
switch (mode) {
case "name":
return (a: HTMLElement, b: HTMLElement) =>
(a.dataset.name ?? "").localeCompare(b.dataset.name ?? "");
case "price-asc":
return (a: HTMLElement, b: HTMLElement) =>
num(a, "price") - num(b, "price");
case "price-desc":
return (a: HTMLElement, b: HTMLElement) =>
num(b, "price") - num(a, "price");
case "total-desc":
return (a: HTMLElement, b: HTMLElement) =>
num(b, "total") - num(a, "total");
case "qty-desc":
return (a: HTMLElement, b: HTMLElement) =>
num(b, "qty") - num(a, "qty");
default:
// "Ajout récent" — original server-rendered order
return (a: HTMLElement, b: HTMLElement) =>
num(a, "idx") - num(b, "idx");
}
}
function matches(el: HTMLElement): boolean {
const name = nameInput?.value.trim().toLowerCase() ?? "";
if (name && !(el.dataset.name ?? "").includes(name)) return false;
if (setSelect?.value && el.dataset.set !== setSelect.value) return false;
if (pillState.variant && el.dataset.variant !== pillState.variant)
return false;
if (pillState.condition && el.dataset.condition !== pillState.condition)
return false;
return true;
}
function apply() {
const cmp = comparator(sortSelect?.value ?? "");
let visibleCount = 0;
for (const container of containers) {
const items = Array.from(container.children) as HTMLElement[];
items.sort(cmp).forEach((el) => container.appendChild(el));
for (const el of items) {
const visible = matches(el);
el.style.display = visible ? "" : "none";
if (visible && container === gridView) visibleCount++;
}
}
emptyState?.classList.toggle("hidden", visibleCount > 0);
}
nameInput?.addEventListener("input", apply);
setSelect?.addEventListener("change", apply);
sortSelect?.addEventListener("change", apply);
resetBtn?.addEventListener("click", () => {
if (nameInput) nameInput.value = "";
if (setSelect) setSelect.value = "";
if (sortSelect) sortSelect.value = "";
for (const key of Object.keys(pillState)) pillState[key] = "";
pillButtons.forEach((b) => b.classList.remove("active"));
apply();
});
}
+54
View File
@@ -0,0 +1,54 @@
/**
* Shared helpers for the `mycards` PocketBase collection:
* record typing and Cardmarket price resolution via the cached TCGdex client.
*/
import { getCard } from "./tcgdex-cache";
export interface MyCardRecord {
id: string;
user: string;
card_id: string;
card_name: string;
card_image: string;
variant: string;
quantity: number;
condition: string;
notes: string;
set_name: string;
created: string;
updated: string;
}
export type PricedCard = MyCardRecord & { price: number; totalPrice: number };
/** Collection variant label → Cardmarket avg price field. */
export const variantPriceMap: Record<string, string> = {
Base: "avg",
Holo: "avg-holo",
Reverse: "avg-reverse",
"First Edition": "avg-firstEdition",
Promo: "avg-wPromo",
};
/**
* Resolve the Cardmarket unit price and line total for each collection card.
* Card details come from the 24h in-memory cache, so repeated cards and
* repeated page loads don't hit the TCGdex API again.
*/
export async function priceCollectionCards(
cards: MyCardRecord[],
): Promise<PricedCard[]> {
return Promise.all(
cards.map(async (card) => {
let price = 0;
const detail = await getCard(card.card_id);
const cardmarket = detail?.pricing?.cardmarket;
if (cardmarket) {
const priceKey = variantPriceMap[card.variant] ?? "avg";
price = cardmarket[priceKey] ?? cardmarket.avg ?? 0;
}
return { ...card, price, totalPrice: price * card.quantity };
}),
);
}
+58
View File
@@ -0,0 +1,58 @@
/**
* Pokémon TCG type system.
* French TCGdex type names → colors, labels, and short codes.
*/
export interface TcgType {
/** French TCGdex type name */
name: string;
/** Primary color (hex) */
color: string;
/** Lighter tint for backgrounds (hex) */
tint: string;
/** Short label for cost icons */
short: string;
}
const types: Record<string, TcgType> = {
Feu: { name: "Feu", color: "#ee8130", tint: "#fdd8bb", short: "F" },
Eau: { name: "Eau", color: "#6390f0", tint: "#d3e3fd", short: "E" },
Électrique: { name: "Électrique", color: "#f7d02c", tint: "#fdf2c0", short: "É" },
Plante: { name: "Plante", color: "#7ac74c", tint: "#dcf0c8", short: "P" },
Psy: { name: "Psy", color: "#f95587", tint: "#fdd3e0", short: "Y" },
Combat: { name: "Combat", color: "#c22e28", tint: "#f0c6c4", short: "C" },
Obscurité: { name: "Obscurité", color: "#705746", tint: "#d9cfc9", short: "O" },
Métal: { name: "Métal", color: "#b7b7ce", tint: "#e8e8f2", short: "M" },
Incolore: { name: "Incolore", color: "#a8a77a", tint: "#e6e6d3", short: "I" },
Fée: { name: "Fée", color: "#d685ad", tint: "#f5d8e8", short: "F" },
Dragon: { name: "Dragon", color: "#6f35fc", tint: "#dbc9fd", short: "D" },
};
/** Get the type info for a French TCGdex type name. Falls back to Incolore. */
export function getType(name: string): TcgType {
return types[name] ?? types["Incolore"];
}
/** Get the CSS gradient for a card's types (supports 1 or 2 types). */
export function getTypeGradient(typeNames: string[]): string {
if (typeNames.length === 0) return types["Incolore"].color;
const colors = typeNames.map((t) => getType(t).color);
if (colors.length === 1) return colors[0];
return `linear-gradient(135deg, ${colors[0]}, ${colors[1]})`;
}
/** Get the primary (first) type color for a card. */
export function getPrimaryColor(typeNames: string[]): string {
if (typeNames.length === 0) return types["Incolore"].color;
return getType(typeNames[0]).color;
}
/** Get the tint (light) color for a type name. */
export function getTint(name: string): string {
return getType(name).tint;
}
/** Get the short label for a type name. */
export function getShort(name: string): string {
return getType(name).short;
}
+120
View File
@@ -0,0 +1,120 @@
/**
* Simple in-memory cache for TCGdex data (sets, series).
* The module is evaluated once per server process, so values persist
* across requests without hitting the TCGdex API every time.
* Falls back to stale cache on network failure.
*/
const ONE_WEEK_MS = 7 * 24 * 60 * 60 * 1000;
const ONE_DAY_MS = 24 * 60 * 60 * 1000;
interface CacheEntry<T> {
data: T;
fetchedAt: number;
}
export interface TcgSet {
id: string;
name: string;
logo?: string;
symbol?: string;
cardCount: { total: number; official: number };
serie?: { id: string; name: string };
}
export interface TcgSerie {
id: string;
name: string;
logo?: string;
}
let setsCache: CacheEntry<TcgSet[]> | null = null;
let seriesCache: CacheEntry<TcgSerie[]> | null = null;
const setsBySerieCache = new Map<string, CacheEntry<TcgSet[]>>();
const cardCache = new Map<string, CacheEntry<any>>();
function isFresh<T>(entry: CacheEntry<T> | null | undefined): entry is CacheEntry<T> {
return !!entry && Date.now() - entry.fetchedAt < ONE_WEEK_MS;
}
function isFreshDay<T>(entry: CacheEntry<T> | null | undefined): entry is CacheEntry<T> {
return !!entry && Date.now() - entry.fetchedAt < ONE_DAY_MS;
}
/** All sets (French), cached for one week. */
export async function getSets(): Promise<TcgSet[]> {
if (isFresh(setsCache)) return setsCache.data;
try {
const res = await fetch("https://api.tcgdex.net/v2/fr/sets");
if (res.ok) {
const data = (await res.json()) as TcgSet[];
setsCache = { data, fetchedAt: Date.now() };
return data;
}
} catch {
// fall through to stale cache
}
if (setsCache) return setsCache.data;
return [];
}
/** All series (French), cached for one week. */
export async function getSeries(): Promise<TcgSerie[]> {
if (isFresh(seriesCache)) return seriesCache.data;
try {
const res = await fetch("https://api.tcgdex.net/v2/fr/series");
if (res.ok) {
const data = (await res.json()) as TcgSerie[];
seriesCache = { data, fetchedAt: Date.now() };
return data;
}
} catch {
// fall through to stale cache
}
if (seriesCache) return seriesCache.data;
return [];
}
/** Sets belonging to one serie, cached per serie for one week. */
export async function getSetsBySerie(serieId: string): Promise<TcgSet[]> {
const cached = setsBySerieCache.get(serieId);
if (isFresh(cached)) return cached.data;
try {
const res = await fetch(
`https://api.tcgdex.net/v2/fr/sets?serie=${encodeURIComponent(serieId)}`,
);
if (res.ok) {
const data = (await res.json()) as TcgSet[];
setsBySerieCache.set(serieId, { data, fetchedAt: Date.now() });
return data;
}
} catch {
// fall through to stale cache
}
const stale = setsBySerieCache.get(serieId);
return stale?.data ?? [];
}
/** Full card detail, cached per card for 24h (pricing changes daily). */
export async function getCard(cardId: string): Promise<any | null> {
const cached = cardCache.get(cardId);
if (isFreshDay(cached)) return cached.data;
try {
const res = await fetch(
`https://api.tcgdex.net/v2/fr/cards/${encodeURIComponent(cardId)}`,
);
if (res.ok) {
const data = await res.json();
cardCache.set(cardId, { data, fetchedAt: Date.now() });
return data;
}
} catch {
// fall through to stale cache
}
const stale = cardCache.get(cardId);
return stale?.data ?? null;
}
+37
View File
@@ -0,0 +1,37 @@
/**
* Grid/list view toggle shared by the search and collection pages.
* Expects #view-grid-btn, #view-list-btn, #grid-view, #list-view in the DOM
* and persists the choice in localStorage under the given key.
*/
export function initViewToggle(storageKey: string) {
const gridBtn = document.getElementById("view-grid-btn");
const listBtn = document.getElementById("view-list-btn");
const gridView = document.getElementById("grid-view");
const listView = document.getElementById("list-view");
function setView(mode: "grid" | "list") {
if (mode === "grid") {
gridView?.classList.remove("hidden");
gridView?.classList.add("grid");
listView?.classList.add("hidden");
gridBtn?.setAttribute("data-variant", "outline");
listBtn?.setAttribute("data-variant", "ghost");
localStorage.setItem(storageKey, "grid");
} else {
gridView?.classList.add("hidden");
gridView?.classList.remove("grid");
listView?.classList.remove("hidden");
listBtn?.setAttribute("data-variant", "outline");
gridBtn?.setAttribute("data-variant", "ghost");
localStorage.setItem(storageKey, "list");
}
}
gridBtn?.addEventListener("click", () => setView("grid"));
listBtn?.addEventListener("click", () => setView("list"));
// Restore saved preference
if (localStorage.getItem(storageKey) === "list") {
setView("list");
}
}
+34
View File
@@ -0,0 +1,34 @@
import { defineMiddleware } from "astro:middleware";
import PocketBase from "pocketbase";
// Read at runtime (not build time) so the same Docker image can point to
// different PocketBase instances via the POCKETBASE_URL env variable.
const POCKETBASE_URL =
process.env.POCKETBASE_URL ?? "https://pokeshare.namarusaja.me";
export const onRequest = defineMiddleware(async ({ cookies, locals }, next) => {
const pb = new PocketBase(POCKETBASE_URL);
// Restore auth from the cookie if it exists
const authCookie = cookies.get("pb_auth");
if (authCookie) {
pb.authStore.loadFromCookie(authCookie.value);
// Optionally refresh the token if it's close to expiring
try {
if (pb.authStore.isValid) {
await pb.collection("users").authRefresh();
}
} catch {
// Token is invalid/expired — clear it
pb.authStore.clear();
cookies.delete("pb_auth", { path: "/" });
}
}
// Make the PocketBase instance available to all pages and endpoints via locals
locals.pb = pb;
locals.user = pb.authStore.record as any;
return next();
});
+45
View File
@@ -0,0 +1,45 @@
import type { APIRoute } from "astro";
export const POST: APIRoute = async ({ request, locals }) => {
if (!locals.pb.authStore.isValid) {
return new Response(JSON.stringify({ error: "Unauthorized" }), {
status: 401,
headers: { "Content-Type": "application/json" },
});
}
const { card_id, card_name, card_image, variant, quantity, condition, notes, set_name } =
await request.json();
if (!card_id || !card_name) {
return new Response(JSON.stringify({ error: "Missing card info" }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
try {
const record = await locals.pb.collection("mycards").create({
user: locals.pb.authStore.record!.id,
card_id,
card_name,
card_image: card_image ?? "",
variant: variant ?? "Base",
quantity: quantity ?? 1,
condition: condition ?? "",
notes: notes ?? "",
set_name: set_name ?? "",
});
return new Response(JSON.stringify({ success: true, record }), {
status: 201,
headers: { "Content-Type": "application/json" },
});
} catch (err) {
console.error("Failed to add card to collection:", err);
return new Response(JSON.stringify({ error: "Failed to add card" }), {
status: 500,
headers: { "Content-Type": "application/json" },
});
}
};
+43
View File
@@ -0,0 +1,43 @@
import type { APIRoute } from "astro";
export const DELETE: APIRoute = async ({ params, locals }) => {
if (!locals.pb.authStore.isValid) {
return new Response(JSON.stringify({ error: "Unauthorized" }), {
status: 401,
headers: { "Content-Type": "application/json" },
});
}
const { id } = params;
if (!id) {
return new Response(JSON.stringify({ error: "Missing record id" }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
try {
// Verify the record belongs to the current user before deleting
const record = await locals.pb.collection("mycards").getOne(id);
if (record.user !== locals.pb.authStore.record!.id) {
return new Response(JSON.stringify({ error: "Forbidden" }), {
status: 403,
headers: { "Content-Type": "application/json" },
});
}
await locals.pb.collection("mycards").delete(id);
return new Response(JSON.stringify({ success: true }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
} catch (err) {
console.error("Failed to delete card:", err);
return new Response(JSON.stringify({ error: "Failed to delete card" }), {
status: 500,
headers: { "Content-Type": "application/json" },
});
}
};
+46
View File
@@ -0,0 +1,46 @@
import type { APIRoute } from "astro";
export const POST: APIRoute = async ({ request, locals }) => {
if (!locals.pb.authStore.isValid) {
return new Response(JSON.stringify({ error: "Unauthorized" }), {
status: 401,
headers: { "Content-Type": "application/json" },
});
}
try {
const { friendshipId } = await request.json();
if (!friendshipId) {
return new Response(JSON.stringify({ error: "Missing friendship id" }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
const friendship = await locals.pb.collection("friendships").getOne(friendshipId);
// Only the addressee can accept
if (friendship.addressee !== locals.pb.authStore.record!.id) {
return new Response(JSON.stringify({ error: "Forbidden" }), {
status: 403,
headers: { "Content-Type": "application/json" },
});
}
await locals.pb.collection("friendships").update(friendshipId, {
status: "accepted",
});
return new Response(JSON.stringify({ success: true }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
} catch (err) {
console.error("Accept friend failed:", err);
return new Response(JSON.stringify({ error: "Failed to accept" }), {
status: 500,
headers: { "Content-Type": "application/json" },
});
}
};
+46
View File
@@ -0,0 +1,46 @@
import type { APIRoute } from "astro";
export const POST: APIRoute = async ({ request, locals }) => {
if (!locals.pb.authStore.isValid) {
return new Response(JSON.stringify({ error: "Unauthorized" }), {
status: 401,
headers: { "Content-Type": "application/json" },
});
}
try {
const { friendshipId } = await request.json();
if (!friendshipId) {
return new Response(JSON.stringify({ error: "Missing friendship id" }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
const friendship = await locals.pb.collection("friendships").getOne(friendshipId);
// Only the addressee can decline
if (friendship.addressee !== locals.pb.authStore.record!.id) {
return new Response(JSON.stringify({ error: "Forbidden" }), {
status: 403,
headers: { "Content-Type": "application/json" },
});
}
await locals.pb.collection("friendships").update(friendshipId, {
status: "declined",
});
return new Response(JSON.stringify({ success: true }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
} catch (err) {
console.error("Decline friend failed:", err);
return new Response(JSON.stringify({ error: "Failed to decline" }), {
status: 500,
headers: { "Content-Type": "application/json" },
});
}
};
+91
View File
@@ -0,0 +1,91 @@
import type { APIRoute } from "astro";
export const POST: APIRoute = async ({ request, locals }) => {
if (!locals.pb.authStore.isValid) {
return new Response(JSON.stringify({ error: "Unauthorized" }), {
status: 401,
headers: { "Content-Type": "application/json" },
});
}
try {
const { username } = await request.json();
if (!username || username.length < 3 || username.length > 20) {
return new Response(JSON.stringify({ error: "Invalid username" }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
const requesterId = locals.pb.authStore.record!.id;
// Find the target user by username
const targetUser = await locals.pb.collection("public_users").getFirstListItem(
locals.pb.filter("username = {:username}", { username }),
);
if (!targetUser) {
return new Response(JSON.stringify({ error: "User not found" }), {
status: 404,
headers: { "Content-Type": "application/json" },
});
}
if (targetUser.id === requesterId) {
return new Response(JSON.stringify({ error: "Cannot add yourself" }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
// Check if friendship already exists (in either direction)
const existing = await locals.pb.collection("friendships").getList(1, 1, {
filter: locals.pb.filter(
"(requester = {:requesterId} && addressee = {:targetId}) || (requester = {:targetId} && addressee = {:requesterId})",
{ requesterId, targetId: targetUser.id },
),
});
if (existing.items.length > 0) {
const f = existing.items[0];
if (f.status === "accepted") {
return new Response(JSON.stringify({ error: "Already friends" }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
if (f.status === "pending") {
return new Response(JSON.stringify({ error: "Invite already sent" }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
// Declined — allow re-request
await locals.pb.collection("friendships").delete(f.id);
}
const record = await locals.pb.collection("friendships").create({
requester: requesterId,
addressee: targetUser.id,
status: "pending",
});
return new Response(JSON.stringify({ success: true, record }), {
status: 201,
headers: { "Content-Type": "application/json" },
});
} catch (err: any) {
console.error("Friend request failed:", err);
if (err.message?.includes("not found")) {
return new Response(JSON.stringify({ error: "User not found" }), {
status: 404,
headers: { "Content-Type": "application/json" },
});
}
return new Response(JSON.stringify({ error: "Failed to send invite" }), {
status: 500,
headers: { "Content-Type": "application/json" },
});
}
};
+41
View File
@@ -0,0 +1,41 @@
import type { APIRoute } from "astro";
export const GET: APIRoute = async ({ url, locals }) => {
if (!locals.pb.authStore.isValid) {
return new Response(JSON.stringify({ error: "Unauthorized" }), {
status: 401,
headers: { "Content-Type": "application/json" },
});
}
const query = url.searchParams.get("q")?.trim().replace(/^@/, "");
if (!query || query.length < 3 || query.length > 20) {
return new Response(JSON.stringify({ error: "Query must be 3-20 characters" }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
try {
// Search by username (case-insensitive), exclude self
const users = await locals.pb.collection("public_users").getList(1, 10, {
filter: locals.pb.filter("username ~ {:query} && id != {:selfId}", {
query,
selfId: locals.pb.authStore.record!.id,
}),
fields: "id,username,name,visibility",
});
return new Response(JSON.stringify({ users: users.items }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
} catch (err) {
console.error("Friend search failed:", err);
return new Response(JSON.stringify({ error: "Search failed" }), {
status: 500,
headers: { "Content-Type": "application/json" },
});
}
};
+54
View File
@@ -0,0 +1,54 @@
import type { APIRoute } from "astro";
export const POST: APIRoute = async ({ request, cookies, redirect, locals }) => {
let username = "";
let password = "";
const contentType = request.headers.get("content-type") ?? "";
try {
if (contentType.includes("application/json")) {
const body = await request.json();
username = body.username ?? "";
password = body.password ?? "";
} else {
const formData = await request.formData();
username = formData.get("username")?.toString() ?? "";
password = formData.get("password")?.toString() ?? "";
}
} catch (err) {
console.error("Failed to parse login request body:", err);
return new Response(JSON.stringify({ error: "Invalid request body" }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
if (!username || !password) {
return new Response(JSON.stringify({ error: "Username and password are required" }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
try {
await locals.pb.collection("users").authWithPassword(username, password);
cookies.set("pb_auth", locals.pb.authStore.exportToCookie(), {
path: "/",
httpOnly: true,
sameSite: "lax",
maxAge: 60 * 60 * 24 * 7, // 1 week
});
return new Response(JSON.stringify({ success: true }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
} catch {
return new Response(JSON.stringify({ error: "Invalid credentials" }), {
status: 401,
headers: { "Content-Type": "application/json" },
});
}
};
+6
View File
@@ -0,0 +1,6 @@
import type { APIRoute } from "astro";
export const POST: APIRoute = async ({ cookies, redirect }) => {
cookies.delete("pb_auth", { path: "/" });
return redirect("/login");
};
+93
View File
@@ -0,0 +1,93 @@
import type { APIRoute } from "astro";
export const POST: APIRoute = async ({ request, cookies, locals }) => {
if (!locals.pb.authStore.isValid) {
return new Response(JSON.stringify({ error: "Unauthorized" }), {
status: 401,
headers: { "Content-Type": "application/json" },
});
}
try {
const body = await request.json();
const { name, username, theme_preference, visibility } = body;
const updates: Record<string, string> = {};
if (name !== undefined) {
updates.name = String(name).trim();
}
if (username !== undefined) {
const u = String(username).trim().toLowerCase();
if (u.length < 3 || u.length > 20) {
return new Response(JSON.stringify({ error: "Username must be 3-20 characters" }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
if (!/^[a-z0-9_]+$/.test(u)) {
return new Response(JSON.stringify({ error: "Username can only contain lowercase letters, numbers, and underscores" }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
updates.username = u;
}
if (visibility !== undefined) {
if (!["Public", "Friends", "Private"].includes(visibility)) {
return new Response(JSON.stringify({ error: "Invalid visibility" }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
updates.visibility = visibility;
}
if (theme_preference !== undefined) {
if (!["System", "Light", "Dark"].includes(theme_preference)) {
return new Response(JSON.stringify({ error: "Invalid theme preference" }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
updates.theme_preference = theme_preference;
}
if (Object.keys(updates).length === 0) {
return new Response(JSON.stringify({ error: "Nothing to update" }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
// Update the user record
await locals.pb.collection("users").update(locals.pb.authStore.record!.id, updates);
// Re-set the auth cookie with the updated record
cookies.set("pb_auth", locals.pb.authStore.exportToCookie(), {
path: "/",
httpOnly: true,
sameSite: "lax",
maxAge: 60 * 60 * 24 * 7,
});
return new Response(JSON.stringify({ success: true }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
} catch (err: any) {
console.error("Failed to update profile:", err);
if (err.message?.includes("username")) {
return new Response(JSON.stringify({ error: "Username already taken" }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
return new Response(JSON.stringify({ error: "Failed to update profile" }), {
status: 500,
headers: { "Content-Type": "application/json" },
});
}
};
+805
View File
@@ -0,0 +1,805 @@
---
import BaseLayout from "../../layouts/BaseLayout.astro";
import {
getTypeGradient,
getPrimaryColor,
getTint,
getShort,
} from "../../lib/tcg-types";
import { getCard } from "../../lib/tcgdex-cache";
const { pb } = Astro.locals;
if (!pb.authStore.isValid) {
return Astro.redirect("/login");
}
const { id } = Astro.params;
if (!id) {
return Astro.redirect("/");
}
const card = await getCard(id);
if (!card) {
return Astro.redirect("/");
}
// Map variant keys to their Cardmarket avg price field
const variantPriceMap: Record<string, string> = {
normal: "avg",
holo: "avg-holo",
reverse: "avg-reverse",
firstEdition: "avg-firstEdition",
wPromo: "avg-wPromo",
};
const variantLabels: Record<string, string> = {
normal: "Normal",
holo: "Holo",
reverse: "Reverse",
firstEdition: "First Edition",
wPromo: "Promo",
};
const cardmarket = card.pricing?.cardmarket;
const availableVariants = card.variants
? Object.entries(card.variants)
.filter(([_, available]) => available)
.map(([key]) => key)
: [];
const variantPrices: { label: string; price: number }[] = [];
// Always include the base avg price (no variant suffix)
if (cardmarket?.avg != null) {
variantPrices.push({ label: "Base", price: cardmarket.avg });
}
// Add prices for each available variant
for (const variant of availableVariants) {
if (variant === "normal") continue; // normal uses the base avg, already added
const priceKey = variantPriceMap[variant];
const price = cardmarket?.[priceKey];
if (price != null) {
variantPrices.push({ label: variantLabels[variant] ?? variant, price });
}
}
// Determine which tabs have content
const hasAttacks = card.attacks && card.attacks.length > 0;
const hasAbilities = card.abilities && card.abilities.length > 0;
const hasPricing = variantPrices.length > 0;
// Type theming
const typeNames: string[] = card.types ?? [];
const typeGradient = getTypeGradient(typeNames);
const primaryColor = getPrimaryColor(typeNames);
---
<BaseLayout>
<a
href="javascript:history.back()"
class="inline-block px-8 pt-6 mb-0 no-underline opacity-70 hover:opacity-100 text-sm"
>
← Retour
</a>
<div class="card-detail">
<!-- Left column: image + pricing -->
<div class="card-left">
<div class="card-image-section">
<div
class="card-image-frame"
style={`background: ${typeGradient}`}
>
{
card.image ? (
<img
src={`${card.image}/low.webp`}
alt={card.name}
/>
) : (
<img
src="/assets/placeholder_card.png"
alt={card.name}
/>
)
}
</div>
</div>
{
hasPricing && (
<div class="card">
<header>
<h2>Prix Cardmarket</h2>
</header>
<section>
<div class="flex flex-col gap-1.5">
{variantPrices.map(({ label, price }) => (
<div class="flex justify-between items-center py-1 px-2 rounded bg-muted">
<span class="text-sm opacity-70">
{label}
</span>
<span class="text-base font-bold">
{price.toFixed(2)} €
</span>
</div>
))}
</div>
{cardmarket?.updated && (
<small class="block mt-3 opacity-50 text-right text-xs">
Mis à jour le{" "}
{new Date(
cardmarket.updated,
).toLocaleDateString("fr-FR")}
</small>
)}
</section>
</div>
)
}
</div>
<!-- Right column: tabs with details -->
<div class="card-right">
<div class="card-header">
<div>
<h1>{card.name}</h1>
<span class="text-xs opacity-50">{card.id}</span>
</div>
<button id="add-to-collection-btn" class="btn" type="button">
<svg
xmlns="http://www.w3.org/2000/svg"
width="1.2em"
height="1.2em"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M5 12h14"></path>
<path d="M12 5v14"></path>
</svg>
Ajouter à ma collection
</button>
</div>
<!-- Tabs -->
<div class="tabs" id="card-tabs">
<nav role="tablist" aria-orientation="horizontal">
<button
type="button"
role="tab"
id="tab-details"
aria-controls="panel-details"
aria-selected="true"
tabindex="0"
>
Détails
</button>
{
hasAttacks && (
<button
type="button"
role="tab"
id="tab-attacks"
aria-controls="panel-attacks"
aria-selected="false"
tabindex="-1"
>
Attaques
</button>
)
}
{
hasAbilities && (
<button
type="button"
role="tab"
id="tab-abilities"
aria-controls="panel-abilities"
aria-selected="false"
tabindex="-1"
>
Talents
</button>
)
}
</nav>
<!-- Details Panel -->
<div
role="tabpanel"
id="panel-details"
aria-labelledby="tab-details"
tabindex="-1"
aria-selected="true"
>
<div class="card">
<section>
<div class="card-meta">
<div class="meta-item">
<span
class="text-xs uppercase tracking-wide opacity-70"
>Set</span
>
<span class="font-semibold"
>{card.set.name}</span
>
</div>
<div class="meta-item">
<span
class="text-xs uppercase tracking-wide opacity-70"
>Rareté</span
>
<span class="font-semibold"
>{card.rarity}</span
>
</div>
<div class="meta-item">
<span
class="text-xs uppercase tracking-wide opacity-70"
>Catégorie</span
>
<span class="font-semibold"
>{card.category}</span
>
</div>
{
card.illustrator && (
<div class="meta-item">
<span class="text-xs uppercase tracking-wide opacity-70">
Illustrateur
</span>
<span class="font-semibold">
{card.illustrator}
</span>
</div>
)
}
{
card.hp && (
<div class="meta-item">
<span class="text-xs uppercase tracking-wide opacity-70">
HP
</span>
<span class="font-semibold">
{card.hp}
</span>
</div>
)
}
{
card.types && card.types.length > 0 && (
<div class="meta-item">
<span class="text-xs uppercase tracking-wide opacity-70">
Types
</span>
<span class="font-semibold flex gap-1.5">
{card.types.map((t: string) => (
<span
class="type-badge"
style={`background: ${getTint(t)}; color: ${getShort(t) === "F" || getShort(t) === "É" || getShort(t) === "P" || getShort(t) === "I" || getShort(t) === "M" ? "#333" : "#fff"}`}
>
{t}
</span>
))}
</span>
</div>
)
}
{
card.stage && (
<div class="meta-item">
<span class="text-xs uppercase tracking-wide opacity-70">
Stage
</span>
<span class="font-semibold">
{card.stage}
</span>
</div>
)
}
{
card.evolveFrom && (
<div class="meta-item">
<span class="text-xs uppercase tracking-wide opacity-70">
Évolue de
</span>
<span class="font-semibold">
{card.evolveFrom}
</span>
</div>
)
}
{
card.retreat !== undefined && (
<div class="meta-item">
<span class="text-xs uppercase tracking-wide opacity-70">
Retraite
</span>
<span class="font-semibold">
{card.retreat}
</span>
</div>
)
}
</div>
{
card.description && (
<div
class="mt-4 p-3 rounded-lg bg-muted italic leading-relaxed"
>
{card.description}
</div>
)
}
{
(card.weaknesses?.length > 0 ||
card.resistances?.length > 0) && (
<div class="flex flex-wrap items-center gap-2 mt-4">
{card.weaknesses?.map((w: any) => (
<span
class="badge"
data-variant="destructive"
>
<span
class="w-2 h-2 rounded-full inline-block mr-1.5"
style={`background: ${getTint(w.type)}`}
/>
Faiblesse {w.type}
{w.value ? ` ${w.value}` : ""}
</span>
))}
{card.resistances?.map((r: any) => (
<span
class="badge"
data-variant="outline"
>
<span
class="w-2 h-2 rounded-full inline-block mr-1.5"
style={`background: ${getTint(r.type)}`}
/>
Résistance {r.type}
{r.value ? ` ${r.value}` : ""}
</span>
))}
</div>
)
}
</section>
</div>
</div>
<!-- Attacks Panel -->
{
hasAttacks && (
<div
role="tabpanel"
id="panel-attacks"
aria-labelledby="tab-attacks"
tabindex="-1"
aria-selected="false"
hidden
>
<div class="flex flex-col gap-3">
{card.attacks.map((attack: any) => (
<article
class="item"
data-variant="outline"
>
<section>
<div class="flex items-center gap-2">
<h3>{attack.name}</h3>
{attack.damage && (
<span
class="badge"
data-variant="secondary"
>
{attack.damage}
</span>
)}
</div>
{attack.cost &&
attack.cost.length > 0 && (
<p class="text-xs opacity-60 flex items-center gap-1">
{attack.cost.map(
(c: string) => (
<span
class="cost-icon"
style={`background: ${getTint(c)}; color: ${getShort(c) === "F" || getShort(c) === "É" || getShort(c) === "P" || getShort(c) === "I" || getShort(c) === "M" ? "#333" : "#fff"}`}
title={c}
>
{getShort(
c,
)}
</span>
),
)}
</p>
)}
{attack.effect && (
<p>{attack.effect}</p>
)}
</section>
</article>
))}
</div>
</div>
)
}
<!-- Abilities Panel -->
{
hasAbilities && (
<div
role="tabpanel"
id="panel-abilities"
aria-labelledby="tab-abilities"
tabindex="-1"
aria-selected="false"
hidden
>
<div class="flex flex-col gap-3">
{card.abilities.map((ability: any) => (
<article
class="item"
data-variant="outline"
>
<section>
<div class="flex items-center gap-2">
<h3>{ability.name}</h3>
<span
class="badge"
data-variant="secondary"
>
{ability.type}
</span>
</div>
{ability.effect && (
<p>{ability.effect}</p>
)}
</section>
</article>
))}
</div>
</div>
)
}
</div>
</div>
</div>
<!-- Add to collection dialog -->
<dialog
id="add-card-dialog"
class="dialog"
aria-labelledby="add-card-dialog-title"
onclick="if (event.target === this) this.close()"
>
<div class="sm:max-w-md">
<header>
<h2 id="add-card-dialog-title">Ajouter à ma collection</h2>
<p>
Enregistrez cette carte dans votre collection avec ses
détails.
</p>
</header>
<section>
<form id="add-card-form" class="grid gap-4">
<input type="hidden" name="card_id" value={card.id} />
<input type="hidden" name="card_name" value={card.name} />
<input
type="hidden"
name="card_image"
value={card.image ?? ""}
/>
<input
type="hidden"
name="set_name"
value={card.set.name}
/>
<div role="group" class="field">
<label for="variant">Variant</label>
<select
name="variant"
id="variant"
class="select w-full"
required
>
<option value="Base">Base</option>
{
variantPrices
.filter((v) => v.label !== "Base")
.map(({ label }) => (
<option value={label}>{label}</option>
))
}
</select>
</div>
<div role="group" class="field">
<label for="quantity">Quantité</label>
<input
type="number"
name="quantity"
id="quantity"
class="input"
value="1"
min="1"
required
/>
</div>
<div role="group" class="field">
<label for="condition">État</label>
<select
name="condition"
id="condition"
class="select w-full"
>
<option value="">—</option>
<option value="Neuf">Neuf</option>
<option value="Bon état" selected>Bon état</option>
<option value="Joué">Joué</option>
</select>
</div>
<div role="group" class="field">
<label for="notes">Notes</label>
<textarea
name="notes"
id="notes"
class="textarea"
rows="2"></textarea>
</div>
</form>
</section>
<footer>
<button
type="button"
class="btn"
data-variant="outline"
onclick="this.closest('dialog').close()"
>
Annuler
</button>
<button
type="submit"
class="btn"
form="add-card-form"
id="add-card-submit"
>
Ajouter
</button>
</footer>
<button
type="button"
class="btn"
data-variant="ghost"
data-size="icon-sm"
aria-label="Close dialog"
onclick="this.closest('dialog').close()"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M18 6 6 18"></path>
<path d="m6 6 12 12"></path>
</svg>
</button>
</div>
</dialog>
</BaseLayout>
<script>
const btn = document.getElementById("add-to-collection-btn");
const dialog = document.getElementById(
"add-card-dialog",
) as HTMLDialogElement;
const form = document.getElementById("add-card-form") as HTMLFormElement;
const submitBtn = document.getElementById(
"add-card-submit",
) as HTMLButtonElement;
const toaster = document.getElementById("toaster") as HTMLDivElement & {
toast: (config: {
category?: "success" | "error" | "info" | "warning";
title: string;
description?: string;
}) => void;
};
const showToast = (
category: "success" | "error" | "info" | "warning",
title: string,
description: string,
) => {
if (toaster?.toast) {
toaster.toast({ category, title, description });
}
};
btn?.addEventListener("click", () => {
(dialog as any).showModal();
});
form?.addEventListener("submit", async (e) => {
e.preventDefault();
const formData = new FormData(form);
const body = {
card_id: formData.get("card_id"),
card_name: formData.get("card_name"),
card_image: formData.get("card_image"),
set_name: formData.get("set_name"),
variant: formData.get("variant"),
quantity: Number(formData.get("quantity")),
condition: formData.get("condition"),
notes: formData.get("notes"),
};
submitBtn.disabled = true;
const originalText = submitBtn.innerHTML;
submitBtn.innerHTML = `<svg aria-label="Loading" role="status" data-icon="inline-start" class="animate-spin lucide lucide-loader-circle" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-6.219-8.56" /></svg> Ajout...`;
try {
const res = await fetch("/api/collection", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (res.ok) {
showToast(
"success",
"Succès",
"Carte ajoutée à votre collection !",
);
form.reset();
(dialog as any).close();
} else {
const data = await res.json();
showToast(
"error",
"Erreur",
data.error ?? "Erreur lors de l'ajout",
);
}
} catch {
showToast("error", "Erreur", "Erreur réseau");
} finally {
submitBtn.disabled = false;
submitBtn.innerHTML = originalText;
}
});
</script>
<style>
.card-detail {
display: grid;
grid-template-columns: 320px 1fr;
gap: 3rem;
padding: 0 2rem 3rem;
max-width: 1400px;
margin: 0 auto;
}
.card-left {
display: flex;
flex-direction: column;
gap: 1.5rem;
align-self: start;
width: 100%;
}
.card-image-section {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
}
.card-image-frame {
width: 100%;
max-width: 280px;
padding: 4px;
border-radius: 1.1rem;
}
.card-image-frame img {
width: 100%;
border-radius: 0.85rem;
display: block;
}
.type-badge {
display: inline-block;
padding: 0.2rem 0.6rem;
border-radius: 1rem;
font-size: 0.8rem;
font-weight: 600;
}
.cost-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.5rem;
height: 1.5rem;
border-radius: 50%;
font-size: 0.75rem;
font-weight: 700;
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1.5rem;
flex-wrap: wrap;
}
.card-header h1 {
margin-bottom: 0;
font-size: 2rem;
}
.card-meta {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 0.75rem;
}
.meta-item {
display: flex;
flex-direction: column;
padding: 0.5rem 0.75rem;
border-radius: 0.5rem;
background: var(--muted);
}
/* Tabs content spacing */
.tabs [role="tabpanel"] {
padding-top: 1rem;
}
@media (max-width: 900px) {
.card-detail {
grid-template-columns: 1fr;
justify-items: center;
padding: 0 1rem 2rem;
gap: 2rem;
}
.card-left {
width: 100%;
align-items: stretch;
gap: 1rem;
}
.card-image-section {
display: flex;
justify-content: center;
}
.card-image-frame {
max-width: 240px;
}
.card-right {
width: 100%;
}
}
</style>
+387
View File
@@ -0,0 +1,387 @@
---
import BaseLayout from "../layouts/BaseLayout.astro";
const { pb } = Astro.locals;
if (!pb.authStore.isValid) {
return Astro.redirect("/login");
}
const userId = pb.authStore.record!.id;
// Get all friendships involving this user
const friendships = await pb.collection("friendships").getFullList({
filter: pb.filter("requester = {:userId} || addressee = {:userId}", {
userId,
}),
expand: "requester,addressee",
});
const pendingReceived = friendships.filter(
(f) => f.addressee === userId && f.status === "pending",
);
const pendingSent = friendships.filter(
(f) => f.requester === userId && f.status === "pending",
);
const accepted = friendships.filter((f) => f.status === "accepted");
function getOtherUser(friendship: any) {
// expand contains the full user records; requester/addressee are just IDs
if (friendship.requester === userId) {
return friendship.expand?.addressee;
}
return friendship.expand?.requester;
}
---
<BaseLayout>
<div class="max-w-2xl mx-auto px-4 md:px-8 py-8">
<h1 class="text-xl font-bold mb-1">Amis</h1>
<p class="text-sm opacity-70 mb-8">
Gérez vos amis et leurs collections.
</p>
<!-- Search for new friends -->
<div class="card mb-6">
<header>
<h2>Ajouter un ami</h2>
</header>
<section>
<form id="search-form" class="flex gap-2">
<div class="input-group flex-1">
<input
type="search"
name="q"
placeholder="Rechercher par @username..."
minlength="3"
maxlength="20"
required
/>
<span data-align="start" aria-hidden="true">
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="m21 21-4.34-4.34"></path>
<circle cx="11" cy="11" r="8"></circle>
</svg>
</span>
</div>
<button type="submit" class="btn">Rechercher</button>
</form>
<div id="search-results" class="mt-4 flex flex-col gap-2"></div>
</section>
</div>
<!-- Pending invites received -->
{
pendingReceived.length > 0 && (
<div class="card mb-6">
<header>
<h2>Invitations reçues</h2>
</header>
<section class="flex flex-col gap-3">
{pendingReceived.map((f) => {
const other = getOtherUser(f);
return (
<div class="flex items-center justify-between p-3 rounded-lg bg-muted">
<div class="flex items-center gap-3">
<span class="avatar" data-size="sm">
<span>
{(
other?.name ||
other?.username ||
other?.email ||
"??"
)
.slice(0, 2)
.toUpperCase()}
</span>
</span>
<div>
<div class="font-semibold text-sm">
{other?.name || "—"}
</div>
<div class="text-xs opacity-60">
@{other?.username || "—"}
</div>
</div>
</div>
<div class="flex gap-2">
<button
class="btn"
data-size="sm"
onclick={`acceptFriend('${f.id}')`}
>
Accepter
</button>
<button
class="btn"
data-variant="outline"
data-size="sm"
onclick={`declineFriend('${f.id}')`}
>
Refuser
</button>
</div>
</div>
);
})}
</section>
</div>
)
}
<!-- Pending invites sent -->
{
pendingSent.length > 0 && (
<div class="card mb-6">
<header>
<h2>Invitations envoyées</h2>
</header>
<section class="flex flex-col gap-3">
{pendingSent.map((f) => {
const other = getOtherUser(f);
return (
<div class="flex items-center justify-between p-3 rounded-lg bg-muted">
<div class="flex items-center gap-3">
<span class="avatar" data-size="sm">
<span>
{(
other?.name ||
other?.username ||
other?.email ||
"??"
)
.slice(0, 2)
.toUpperCase()}
</span>
</span>
<div>
<div class="font-semibold text-sm">
{other?.name || "—"}
</div>
<div class="text-xs opacity-60">
@{other?.username || "—"}
</div>
</div>
</div>
<span
class="badge"
data-variant="secondary"
>
En attente
</span>
</div>
);
})}
</section>
</div>
)
}
<!-- Friends list -->
<div class="card">
<header>
<h2>Mes amis</h2>
</header>
<section>
{
accepted.length === 0 ? (
<section class="empty">
<header>
<h3>Aucun ami</h3>
<p>
Recherchez des utilisateurs par leur
@username pour les ajouter.
</p>
</header>
</section>
) : (
<div class="flex flex-col gap-3">
{accepted.map((f) => {
const other = getOtherUser(f);
return (
<a
href={`/friends/${other?.username || other?.id}`}
class="item no-underline"
data-variant="outline"
>
<figure>
<span class="avatar">
<span>
{(
other?.name ||
other?.username ||
other?.email ||
"??"
)
.slice(0, 2)
.toUpperCase()}
</span>
</span>
</figure>
<section>
<h3>{other?.name || "—"}</h3>
<p class="text-sm opacity-60">
@{other?.username || "—"}
</p>
</section>
<aside>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="m9 18 6-6-6-6" />
</svg>
</aside>
</a>
);
})}
</div>
)
}
</section>
</div>
</div>
</BaseLayout>
<script>
const toaster = document.getElementById("toaster") as ToasterElement;
const showToast = (
category: "success" | "error",
title: string,
description: string,
) => {
if (toaster?.toast) {
toaster.toast({ category, title, description });
}
};
// Search
const searchForm = document.getElementById("search-form");
const searchResults = document.getElementById("search-results");
searchForm?.addEventListener("submit", async (e) => {
e.preventDefault();
const formData = new FormData(searchForm as HTMLFormElement);
const q = formData.get("q");
const res = await fetch(
`/api/friends/search?q=${encodeURIComponent(q as string)}`,
);
const data = await res.json();
if (!res.ok) {
showToast("error", "Erreur", data.error ?? "Recherche échouée");
return;
}
if (data.users.length === 0) {
searchResults!.innerHTML = `<p class="text-sm opacity-60">Aucun utilisateur trouvé.</p>`;
return;
}
searchResults!.innerHTML = data.users
.map(
(u: any) => `
<div class="flex items-center justify-between p-3 rounded-lg bg-muted">
<div class="flex items-center gap-3">
<span class="avatar" data-size="sm">
<span>${(u.name || u.username || u.email || "??").slice(0, 2).toUpperCase()}</span>
</span>
<div>
<div class="font-semibold text-sm">${u.name || "—"}</div>
<div class="text-xs opacity-60">@${u.username || "—"}</div>
</div>
</div>
<button class="btn" data-size="sm" onclick="sendInvite('${u.username}')">Ajouter</button>
</div>
`,
)
.join("");
});
// Send invite
(window as any).sendInvite = async (username: string) => {
try {
const res = await fetch("/api/friends/request", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username }),
});
const data = await res.json();
if (res.ok) {
showToast(
"success",
"Invitation envoyée",
`Invitation envoyée à @${username}.`,
);
searchResults!.innerHTML = "";
(searchForm as HTMLFormElement).reset();
} else {
showToast("error", "Erreur", data.error ?? "Échec de l'envoi");
}
} catch {
showToast("error", "Erreur", "Erreur réseau");
}
};
// Accept
(window as any).acceptFriend = async (friendshipId: string) => {
try {
const res = await fetch("/api/friends/accept", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ friendshipId }),
});
if (res.ok) {
showToast(
"success",
"Ami ajouté",
"Vous êtes maintenant amis.",
);
setTimeout(() => window.location.reload(), 1000);
} else {
const data = await res.json();
showToast("error", "Erreur", data.error ?? "Échec");
}
} catch {
showToast("error", "Erreur", "Erreur réseau");
}
};
// Decline
(window as any).declineFriend = async (friendshipId: string) => {
try {
const res = await fetch("/api/friends/decline", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ friendshipId }),
});
if (res.ok) {
showToast("success", "Refusé", "Invitation refusée.");
setTimeout(() => window.location.reload(), 1000);
} else {
const data = await res.json();
showToast("error", "Erreur", data.error ?? "Échec");
}
} catch {
showToast("error", "Erreur", "Erreur réseau");
}
};
</script>
+205
View File
@@ -0,0 +1,205 @@
---
import BaseLayout from "../../layouts/BaseLayout.astro";
import {
priceCollectionCards,
type MyCardRecord,
} from "../../lib/collection";
const { pb } = Astro.locals;
if (!pb.authStore.isValid) {
return Astro.redirect("/login");
}
const { username } = Astro.params;
if (!username) {
return Astro.redirect("/friends");
}
// Find the target user by username (same public view the friends API uses)
let targetUser;
try {
targetUser = await pb
.collection("public_users")
.getFirstListItem(pb.filter("username = {:username}", { username }));
} catch {
return Astro.redirect("/friends");
}
// Check visibility — Private users' collections are never visible
if (targetUser.visibility === "Private") {
return Astro.redirect("/friends");
}
// Friends-only collections require an accepted friendship
if (targetUser.visibility !== "Public") {
const userId = pb.authStore.record!.id;
const friendship = await pb.collection("friendships").getList(1, 1, {
filter: pb.filter(
"((requester = {:userId} && addressee = {:targetId}) || (requester = {:targetId} && addressee = {:userId})) && status = 'accepted'",
{ userId, targetId: targetUser.id },
),
});
if (friendship.items.length === 0) {
return Astro.redirect("/friends");
}
}
// Fetch their collection
const myCards = (await pb.collection("mycards").getFullList({
filter: pb.filter("user = {:userId}", { userId: targetUser.id }),
sort: "-created",
})) as unknown as MyCardRecord[];
const cardsWithPrice = await priceCollectionCards(myCards);
const totalValue = cardsWithPrice.reduce((s, c) => s + c.totalPrice, 0);
const totalCards = cardsWithPrice.reduce((s, c) => s + c.quantity, 0);
---
<BaseLayout>
<div class="max-w-[1600px] mx-auto px-4 md:px-8 pb-12 mt-8">
<a
href="/friends"
class="inline-block no-underline opacity-70 hover:opacity-100 text-sm mb-4"
>← Amis</a
>
<div class="flex flex-wrap items-center justify-between gap-4 mb-6">
<div class="flex items-center gap-4">
<span class="avatar" data-size="lg">
<span
>{
(
targetUser.name ||
targetUser.username ||
targetUser.email ||
"??"
)
.slice(0, 2)
.toUpperCase()
}</span
>
</span>
<div>
<h1 class="m-0">{targetUser.name || "—"}</h1>
<p class="text-sm opacity-60 m-0">
@{targetUser.username || "—"}
</p>
</div>
</div>
<div class="flex gap-6">
<div
class="bg-muted rounded-lg px-6 py-3 flex flex-col items-center"
>
<span class="text-xs uppercase tracking-wide opacity-70"
>Cartes</span
>
<span class="text-2xl font-bold">{totalCards}</span>
</div>
<div
class="bg-muted rounded-lg px-6 py-3 flex flex-col items-center"
>
<span class="text-xs uppercase tracking-wide opacity-70"
>Valeur totale</span
>
<span class="text-2xl font-bold"
>{totalValue.toFixed(2)} €</span
>
</div>
</div>
</div>
{
cardsWithPrice.length === 0 ? (
<section class="empty">
<header>
<h3>Collection vide</h3>
<p>
{targetUser.name || targetUser.username} n'a pas
encore de cartes dans sa collection.
</p>
</header>
</section>
) : (
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
{cardsWithPrice.map((card) => (
<a
href={`/card/${card.card_id}`}
class="card no-underline hover:-translate-y-0.5 hover:shadow-lg transition-all duration-150 overflow-hidden"
>
<div class="aspect-2.5/3.5 overflow-hidden">
{card.card_image ? (
<img
src={`${card.card_image}/low.webp`}
alt={card.card_name}
class="w-full h-full object-cover"
/>
) : (
<img
src="/assets/placeholder_card.png"
alt={card.card_name}
class="w-full h-full object-cover"
/>
)}
</div>
<div class="p-3 flex flex-col gap-1.5">
<h3 class="m-0 text-sm font-semibold leading-tight truncate">
{card.card_name}
</h3>
{card.set_name && (
<small class="opacity-60 text-xs truncate">
{card.set_name}
</small>
)}
<div class="flex items-center justify-between">
<span
class="badge"
data-variant="secondary"
>
{card.variant}
</span>
<span class="font-semibold text-sm">
×{card.quantity}
</span>
</div>
{card.condition && (
<span
class="badge"
data-variant={
card.condition === "Neuf"
? "primary"
: card.condition === "Joué"
? "destructive"
: "outline"
}
>
{card.condition}
</span>
)}
<div class="flex items-center justify-between mt-2 pt-2 border-t border-border">
<span class="font-bold text-base">
{card.price.toFixed(2)} €
</span>
{card.quantity > 1 && (
<span class="text-xs opacity-70">
= {card.totalPrice.toFixed(2)} €
</span>
)}
</div>
</div>
</a>
))}
</div>
)
}
</div>
</BaseLayout>
<style is:global>
a.card {
text-decoration: none;
}
</style>
+212
View File
@@ -0,0 +1,212 @@
---
import BaseLayout from "../layouts/BaseLayout.astro";
import { getSeries } from "../lib/tcgdex-cache";
import {
priceCollectionCards,
type MyCardRecord,
} from "../lib/collection";
const { pb } = Astro.locals;
if (!pb.authStore.isValid) {
return Astro.redirect("/login");
}
const userId = pb.authStore.record!.id;
const myCards = (await pb.collection("mycards").getFullList({
filter: pb.filter("user = {:userId}", { userId }),
})) as unknown as MyCardRecord[];
// Fetch prices through the 24h-cached card endpoint
const cardsWithPrice = await priceCollectionCards(myCards);
// Insights
const totalCards = cardsWithPrice.reduce((s, c) => s + c.quantity, 0);
const totalValue = cardsWithPrice.reduce((s, c) => s + c.totalPrice, 0);
const bySet = new Map<string, { count: number; value: number }>();
for (const c of cardsWithPrice) {
const key = c.set_name || "Inconnu";
const entry = bySet.get(key) ?? { count: 0, value: 0 };
entry.count += c.quantity;
entry.value += c.totalPrice;
bySet.set(key, entry);
}
const topSets = [...bySet.entries()]
.sort((a, b) => b[1].value - a[1].value)
.slice(0, 5);
const topCards = [...cardsWithPrice]
.sort((a, b) => b.totalPrice - a.totalPrice)
.slice(0, 5);
// Series for the explorer
const series = await getSeries();
---
<BaseLayout>
<div class="max-w-350 mx-auto px-4 md:px-8 py-8">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
<!-- Collection insights -->
<section>
<h2 class="text-xl font-bold mb-1">Ma collection</h2>
<p class="text-sm opacity-70 mb-4">
Aperçu de vos cartes et de leur valeur.
</p>
{
cardsWithPrice.length === 0 ? (
<section class="empty border border-dashed">
<header>
<h3>Collection vide</h3>
<p>
Ajoutez des cartes à votre collection pour
voir vos statistiques ici.
</p>
</header>
<footer>
<a href="/search" class="btn" data-size="sm">
Rechercher des cartes
</a>
</footer>
</section>
) : (
<div class="flex flex-col gap-4">
<div class="grid grid-cols-2 gap-4">
<div class="card">
<section class="text-center py-2">
<p class="text-xs uppercase tracking-wide opacity-70">
Cartes
</p>
<p class="text-3xl font-bold">
{totalCards}
</p>
</section>
</div>
<div class="card">
<section class="text-center py-2">
<p class="text-xs uppercase tracking-wide opacity-70">
Valeur totale
</p>
<p class="text-3xl font-bold">
{totalValue.toFixed(2)} €
</p>
</section>
</div>
</div>
<div class="card">
<header>
<h3>Top sets</h3>
</header>
<section class="flex flex-col gap-1.5">
{topSets.map(([name, stats]) => (
<div class="flex justify-between items-center py-1 px-2 rounded bg-muted text-sm">
<span class="truncate">{name}</span>
<span class="font-semibold whitespace-nowrap ml-2">
{stats.count} × —{" "}
{stats.value.toFixed(2)} €
</span>
</div>
))}
</section>
</div>
<div class="card">
<header>
<h3>Cartes les plus chères</h3>
</header>
<section class="flex flex-col gap-1.5">
{topCards.map((card) => (
<a
href={`/card/${card.card_id}`}
class="flex items-center gap-3 py-1 px-2 rounded bg-muted no-underline"
>
{card.card_image && (
<img
src={`${card.card_image}/low.webp`}
alt={card.card_name}
class="w-8 h-11 object-cover rounded-sm"
/>
)}
<span class="flex-1 truncate text-sm">
{card.card_name}
</span>
<span class="text-sm font-bold whitespace-nowrap">
{card.totalPrice.toFixed(2)} €
</span>
</a>
))}
</section>
</div>
<a
href="/mycards"
class="btn self-start"
data-variant="outline"
data-size="sm"
>
Voir toute la collection →
</a>
</div>
)
}
</section>
<!-- Explorer -->
<section>
<h2 class="text-xl font-bold mb-1">Explorer</h2>
<p class="text-sm opacity-70 mb-4">
Parcourez les cartes par série puis par extension.
</p>
<div class="grid grid-cols-3 sm:grid-cols-4 gap-3">
{
series.map((serie) => (
<a
href={`/series/${serie.id}`}
class="card no-underline hover:shadow-lg transition-shadow"
>
<section class="flex flex-col items-center justify-center gap-2 aspect-square p-2 text-center">
{serie.logo ? (
<img
src={`${serie.logo}.webp`}
alt={serie.name}
class="h-10 max-w-full object-contain"
/>
) : (
<svg
xmlns="http://www.w3.org/2000/svg"
width="28"
height="28"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="opacity-40"
>
<rect
width="18"
height="18"
x="3"
y="3"
rx="2"
/>
<circle cx="9" cy="9" r="2" />
<path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" />
</svg>
)}
<span class="text-xs font-semibold leading-tight line-clamp-2">
{serie.name}
</span>
</section>
</a>
))
}
</div>
</section>
</div>
</div>
</BaseLayout>
+8
View File
@@ -0,0 +1,8 @@
---
import LoginForm from "../components/LoginForm.astro";
import BaseLayout from "../layouts/BaseLayout.astro";
---
<BaseLayout>
<LoginForm />
</BaseLayout>
+533
View File
@@ -0,0 +1,533 @@
---
import BaseLayout from "../layouts/BaseLayout.astro";
import {
priceCollectionCards,
type MyCardRecord,
} from "../lib/collection";
const { pb } = Astro.locals;
if (!pb.authStore.isValid) {
return Astro.redirect("/login");
}
const userId = pb.authStore.record!.id;
// Fetch all user's cards
const myCards = (await pb.collection("mycards").getFullList({
filter: pb.filter("user = {:userId}", { userId }),
sort: "-created",
})) as unknown as MyCardRecord[];
const cardsWithPrice = await priceCollectionCards(myCards);
const totalValue = cardsWithPrice.reduce(
(sum, card) => sum + card.totalPrice,
0,
);
const totalCards = cardsWithPrice.reduce((sum, card) => sum + card.quantity, 0);
// Distinct values present in the collection, for the filter dropdowns
const distinctSets = [
...new Set(cardsWithPrice.map((c) => c.set_name).filter(Boolean)),
].sort();
const distinctVariants = [
...new Set(cardsWithPrice.map((c) => c.variant)),
].sort();
const distinctConditions = [
...new Set(cardsWithPrice.map((c) => c.condition).filter(Boolean)),
].sort();
---
<BaseLayout>
<div class="max-w-[1600px] mx-auto px-4 md:px-8 pb-12 mt-8">
<!-- Header -->
<div class="flex flex-wrap items-center justify-between gap-4 mb-6">
<h1 class="m-0">Ma Collection</h1>
<div class="flex items-center gap-6">
<div
class="bg-muted rounded-lg px-6 py-3 flex flex-col items-center"
>
<span class="text-xs uppercase tracking-wide opacity-70"
>Cartes</span
>
<span class="text-2xl font-bold">{totalCards}</span>
</div>
<div
class="bg-muted rounded-lg px-6 py-3 flex flex-col items-center"
>
<span class="text-xs uppercase tracking-wide opacity-70"
>Valeur totale</span
>
<span class="text-2xl font-bold"
>{totalValue.toFixed(2)} €</span
>
</div>
</div>
</div>
{
cardsWithPrice.length === 0 ? (
<section class="empty">
<header>
<figure>
<svg
class="lucide lucide-folder-open"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="m6 14 1.5-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.54 6a2 2 0 0 1-1.95 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2" />
</svg>
</figure>
<h3>Votre collection est vide</h3>
<p>
Commencez par rechercher des cartes et ajoutez-les
à votre collection.
</p>
</header>
<footer>
<a href="/search" class="btn">
Rechercher des cartes
</a>
</footer>
</section>
) : (
<>
<!-- Filters -->
<div class="filter-bar mb-4">
<div class="pills-row">
<span class="pills-label">Variant</span>
{distinctVariants.map((v) => (
<button
type="button"
class="pill"
data-cf-group="variant"
data-value={v}
>
{v}
</button>
))}
</div>
<div class="pills-row">
<span class="pills-label">État</span>
{distinctConditions.map((c) => (
<button
type="button"
class="pill"
data-cf-group="condition"
data-value={c}
>
{c}
</button>
))}
</div>
<div class="filter-selects">
<input
type="search"
id="cf-name"
class="input"
placeholder="Filtrer par nom..."
/>
<label>
Set
<select id="cf-set" class="select">
<option value="">Tous</option>
{distinctSets.map((s) => (
<option value={s}>{s}</option>
))}
</select>
</label>
<label>
Tri
<select id="cf-sort" class="select">
<option value="">Ajout récent</option>
<option value="name">Nom A→Z</option>
<option value="price-asc">Prix ↑</option>
<option value="price-desc">Prix ↓</option>
<option value="total-desc">
Valeur totale ↓
</option>
<option value="qty-desc">Quantité ↓</option>
</select>
</label>
<button type="button" id="cf-reset" class="pill">
✕ Réinitialiser
</button>
</div>
</div>
<!-- View Toggle -->
<div class="flex justify-end mb-4">
<div role="group" class="button-group">
<button
type="button"
id="view-grid-btn"
class="btn"
data-variant="outline"
data-size="icon-sm"
aria-label="Vue grille"
title="Vue grille"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect width="7" height="7" x="3" y="3" rx="1" />
<rect width="7" height="7" x="14" y="3" rx="1" />
<rect width="7" height="7" x="14" y="14" rx="1" />
<rect width="7" height="7" x="3" y="14" rx="1" />
</svg>
</button>
<button
type="button"
id="view-list-btn"
class="btn"
data-variant="ghost"
data-size="icon-sm"
aria-label="Vue liste"
title="Vue liste"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M3 5h.01" />
<path d="M3 12h.01" />
<path d="M3 19h.01" />
<path d="M8 5h13" />
<path d="M8 12h13" />
<path d="M8 19h13" />
</svg>
</button>
</div>
</div>
<!-- Grid View -->
<div
id="grid-view"
class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4"
>
{cardsWithPrice.map((card) => (
<div
class="relative group"
data-name={card.card_name.toLowerCase()}
data-set={card.set_name}
data-variant={card.variant}
data-condition={card.condition}
data-price={card.price}
data-total={card.totalPrice}
data-qty={card.quantity}
>
<a
href={`/card/${card.card_id}`}
class="card no-underline hover:-translate-y-0.5 hover:shadow-lg transition-all duration-150 overflow-hidden"
>
<div class="aspect-2.5/3.5 overflow-hidden">
{card.card_image ? (
<img
src={`${card.card_image}/low.webp`}
alt={card.card_name}
class="w-full h-full object-cover"
/>
) : (
<img
src="/assets/placeholder_card.png"
alt={card.card_name}
class="w-full h-full object-cover"
/>
)}
</div>
<div class="p-3 flex flex-col gap-1.5">
<h3 class="m-0 text-sm font-semibold leading-tight truncate">
{card.card_name}
</h3>
{card.set_name && (
<small class="opacity-60 text-xs truncate">
{card.set_name}
</small>
)}
<div class="flex items-center justify-between">
<span class="badge" data-variant="secondary">
{card.variant}
</span>
<span class="font-semibold text-sm">
×{card.quantity}
</span>
</div>
{card.condition && (
<span class="badge" data-variant={card.condition === "Neuf" ? "primary" : card.condition === "Joué" ? "destructive" : "outline"}>
{card.condition}
</span>
)}
<div class="flex items-center justify-between mt-2 pt-2 border-t border-border">
<span class="font-bold text-base">
{card.price.toFixed(2)} €
</span>
{card.quantity > 1 && (
<span class="text-xs opacity-70">
= {card.totalPrice.toFixed(2)} €
</span>
)}
</div>
</div>
</a>
<button
type="button"
class="delete-btn"
aria-label={`Supprimer ${card.card_name}`}
title="Supprimer"
onclick={`showDeleteConfirm('${card.id}', '${card.card_name.replace(/'/g, "\\'")}')`}
>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 6h18" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" />
<path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
</button>
</div>
))}
</div>
<!-- List View (hidden by default) -->
<div id="list-view" class="hidden">
<div class="table-container">
<table class="table">
<thead>
<tr>
<th class="w-16"></th>
<th>Nom</th>
<th>Set</th>
<th>Variant</th>
<th>État</th>
<th class="text-end">Qté</th>
<th class="text-end">Prix</th>
<th class="text-end">Total</th>
</tr>
</thead>
<tbody>
{cardsWithPrice.map((card) => (
<tr
data-name={card.card_name.toLowerCase()}
data-set={card.set_name}
data-variant={card.variant}
data-condition={card.condition}
data-price={card.price}
data-total={card.totalPrice}
data-qty={card.quantity}
>
<td>
<a href={`/card/${card.card_id}`}>
{card.card_image ? (
<img
src={`${card.card_image}/low.webp`}
alt={card.card_name}
class="w-12 h-16 object-cover rounded-sm"
/>
) : (
<img
src="/assets/placeholder_card.png"
alt={card.card_name}
class="w-12 h-16 object-cover rounded-sm"
/>
)}
</a>
</td>
<td class="font-medium">
<a
href={`/card/${card.card_id}`}
class="no-underline"
>
{card.card_name}
</a>
</td>
<td class="text-sm opacity-70">
{card.set_name}
</td>
<td>
<span class="badge" data-variant="secondary">{card.variant}</span>
</td>
<td>
{card.condition && (
<span class="badge" data-variant={card.condition === "Neuf" ? "primary" : card.condition === "Joué" ? "destructive" : "outline"}>{card.condition}</span>
)}
</td>
<td class="text-end tabular-nums">
×{card.quantity}
</td>
<td class="text-end tabular-nums font-semibold">
{card.price.toFixed(2)} €
</td>
<td class="text-end tabular-nums font-bold">
{card.totalPrice.toFixed(2)} €
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<!-- Shown when filters hide every card -->
<div id="cf-empty" class="hidden">
<section class="empty">
<header>
<h3>Aucune carte ne correspond à ces filtres</h3>
<p>
Modifiez ou réinitialisez les filtres pour
voir vos cartes.
</p>
</header>
</section>
</div>
</>
)
}
</div>
<!-- Delete confirmation dialog -->
<dialog id="delete-dialog" class="alert-dialog" aria-labelledby="delete-dialog-title" aria-describedby="delete-dialog-description">
<div>
<header>
<figure>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 6h18" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" />
<path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
</figure>
<h2 id="delete-dialog-title">Supprimer cette carte ?</h2>
<p id="delete-dialog-description">
Cette action est irréversible. La carte sera retirée de votre collection.
</p>
</header>
<footer>
<button class="btn" data-variant="outline" onclick="this.closest('dialog').close()">Annuler</button>
<button class="btn" data-variant="destructive" id="delete-confirm-btn">Supprimer</button>
</footer>
</div>
</dialog>
</BaseLayout>
<script>
import { initViewToggle } from "../lib/view-toggle";
import { initCollectionFilter } from "../lib/collection-filter";
initViewToggle("collectionView");
initCollectionFilter();
// Delete flow
let deleteTargetId: string | null = null;
const deleteDialog = document.getElementById("delete-dialog") as HTMLDialogElement;
const deleteConfirmBtn = document.getElementById("delete-confirm-btn") as HTMLButtonElement;
const toaster = document.getElementById("toaster") as ToasterElement;
function showToast(category: "success" | "error", title: string, description: string) {
if (toaster?.toast) {
toaster.toast({ category, title, description });
}
}
(window as any).showDeleteConfirm = (id: string, name: string) => {
deleteTargetId = id;
const desc = deleteDialog.querySelector("#delete-dialog-description");
if (desc) {
desc.textContent = `Voulez-vous vraiment supprimer « ${name} » de votre collection ? Cette action est irréversible.`;
}
(deleteDialog as any).showModal();
};
deleteConfirmBtn?.addEventListener("click", async () => {
if (!deleteTargetId) return;
deleteConfirmBtn.disabled = true;
const originalText = deleteConfirmBtn.innerHTML;
deleteConfirmBtn.innerHTML = `<svg aria-label="Loading" role="status" data-icon="inline-start" class="animate-spin lucide lucide-loader-circle" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-6.219-8.56" /></svg> Suppression...`;
try {
const res = await fetch(`/api/collection/${deleteTargetId}`, {
method: "DELETE",
});
if (res.ok) {
showToast("success", "Supprimée", "Carte retirée de votre collection.");
(deleteDialog as any).close();
// Remove the card from the DOM
const cardEl = deleteConfirmBtn.closest(".group");
if (cardEl) {
cardEl.remove();
}
// Reload to refresh totals
setTimeout(() => window.location.reload(), 1500);
} else {
const data = await res.json();
showToast("error", "Erreur", data.error ?? "Échec de la suppression");
}
} catch {
showToast("error", "Erreur", "Erreur réseau");
} finally {
deleteConfirmBtn.disabled = false;
deleteConfirmBtn.innerHTML = originalText;
deleteTargetId = null;
}
});
</script>
<style is:global>
a.card {
text-decoration: none;
}
.delete-btn {
position: absolute;
top: 0.5rem;
right: 0.5rem;
z-index: 10;
display: inline-flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border-radius: 50%;
border: none;
background: color-mix(in oklab, var(--destructive) 75%, transparent);
color: #fff;
cursor: pointer;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
opacity: 0;
transition:
opacity 150ms ease,
transform 150ms ease,
background 150ms ease;
backdrop-filter: blur(4px);
}
.delete-btn:hover {
background: color-mix(in oklab, var(--destructive) 95%, transparent);
transform: scale(1.1);
}
.group:hover .delete-btn {
opacity: 1;
}
</style>
+342
View File
@@ -0,0 +1,342 @@
---
import BaseLayout from "../layouts/BaseLayout.astro";
const { pb } = Astro.locals;
if (!pb.authStore.isValid) {
return Astro.redirect("/login");
}
const user = pb.authStore.record!;
const initials = (user.name || user.email || "")
.split(/[\s@.]+/)
.filter(Boolean)
.slice(0, 2)
.map((s: string) => s[0].toUpperCase())
.join("");
const themePreference = user.theme_preference ?? "System";
---
<BaseLayout>
<div class="max-w-2xl mx-auto px-4 md:px-8 py-8">
<h1 class="text-xl font-bold mb-1">Profil</h1>
<p class="text-sm opacity-70 mb-8">
Gérez votre compte et vos préférences.
</p>
<div class="tabs" id="profile-tabs">
<nav role="tablist" aria-orientation="horizontal" class="mb-6">
<button
type="button"
role="tab"
id="tab-account"
aria-controls="panel-account"
aria-selected="true"
tabindex="0"
>
Compte
</button>
<button
type="button"
role="tab"
id="tab-preferences"
aria-controls="panel-preferences"
aria-selected="false"
tabindex="-1"
>
Préférences
</button>
</nav>
<!-- Account Panel -->
<div
role="tabpanel"
id="panel-account"
aria-labelledby="tab-account"
tabindex="-1"
aria-selected="true"
>
<div class="card">
<header>
<h2>Compte</h2>
</header>
<section>
<div class="flex items-center gap-4 mb-6">
<span class="avatar" data-size="lg">
<span>{initials}</span>
</span>
<div class="flex flex-col">
<span class="font-semibold"
>{user.name || "—"}</span
>
<span class="text-sm opacity-70"
>{user.email}</span
>
</div>
</div>
<form id="account-form" class="grid gap-4">
<div role="group" class="field">
<label for="username">Nom d'utilisateur</label>
<input
class="input"
type="text"
id="username"
name="username"
value={user.username ?? ""}
placeholder="ex: pikalover"
minlength="3"
maxlength="20"
/>
<p>
Votre @username unique (3-20 caractères,
lettres minuscules et chiffres).
</p>
</div>
<div role="group" class="field">
<label for="name">Nom complet</label>
<input
class="input"
type="text"
id="name"
name="name"
value={user.name ?? ""}
placeholder="Votre nom complet"
/>
<p>Votre nom affiché dans l'application.</p>
</div>
</form>
</section>
<footer>
<button
class="btn"
type="submit"
form="account-form"
id="account-submit"
>
Enregistrer
</button>
</footer>
</div>
</div>
<!-- Preferences Panel -->
<div
role="tabpanel"
id="panel-preferences"
aria-labelledby="tab-preferences"
tabindex="-1"
aria-selected="false"
hidden
>
<div class="card">
<header>
<h2>Préférences</h2>
<p>Personnalisez votre expérience.</p>
</header>
<section>
<form id="profile-form" class="grid gap-4">
<div role="group" class="field">
<label for="theme_preference">Thème</label>
<select
name="theme_preference"
id="theme_preference"
class="select w-full"
>
<option
value="System"
selected={themePreference === "System"}
>
Système
</option>
<option
value="Light"
selected={themePreference === "Light"}
>
Clair
</option>
<option
value="Dark"
selected={themePreference === "Dark"}
>
Sombre
</option>
</select>
<p>Choisissez le thème de l'interface.</p>
</div>
<div role="group" class="field">
<label for="visibility"
>Visibilité de la collection</label
>
<select
name="visibility"
id="visibility"
class="select w-full"
>
<option
value="Friends"
selected={user.visibility !==
"Public" &&
user.visibility !== "Private"}
>
Amis seulement
</option>
<option
value="Public"
selected={user.visibility === "Public"}
>
Publique
</option>
<option
value="Private"
selected={user.visibility === "Private"}
>
Privée
</option>
</select>
<p>Qui peut voir votre collection.</p>
</div>
</form>
</section>
<footer>
<button
class="btn"
type="submit"
form="profile-form"
id="profile-submit"
>
Enregistrer
</button>
</footer>
</div>
</div>
</div>
</div>
</BaseLayout>
<script>
const toaster = document.getElementById("toaster") as ToasterElement;
const showToast = (
category: "success" | "error",
title: string,
description: string,
) => {
if (toaster?.toast) {
toaster.toast({ category, title, description });
}
};
// Account form (name)
const accountForm = document.getElementById("account-form");
const accountBtn = document.getElementById(
"account-submit",
) as HTMLButtonElement;
accountForm?.addEventListener("submit", async (e) => {
e.preventDefault();
const formData = new FormData(accountForm as HTMLFormElement);
const name = formData.get("name");
const username = formData.get("username");
accountBtn.disabled = true;
const originalText = accountBtn.innerHTML;
accountBtn.innerHTML = `<svg aria-label="Loading" role="status" data-icon="inline-start" class="animate-spin lucide lucide-loader-circle" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-6.219-8.56" /></svg> Enregistrement...`;
try {
const res = await fetch("/api/profile", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, username }),
});
if (res.ok) {
showToast(
"success",
"Enregistré",
"Votre nom a été mis à jour.",
);
// Reload to reflect the new name in the nav
setTimeout(() => window.location.reload(), 1000);
} else {
const data = await res.json();
showToast(
"error",
"Erreur",
data.error ?? "Échec de l'enregistrement",
);
}
} catch {
showToast("error", "Erreur", "Erreur réseau");
} finally {
accountBtn.disabled = false;
accountBtn.innerHTML = originalText;
}
});
// Preferences form (theme)
const profileForm = document.getElementById("profile-form");
const profileBtn = document.getElementById(
"profile-submit",
) as HTMLButtonElement;
profileForm?.addEventListener("submit", async (e) => {
e.preventDefault();
const formData = new FormData(profileForm as HTMLFormElement);
const themePreference = formData.get("theme_preference");
const visibility = formData.get("visibility");
profileBtn.disabled = true;
const originalText = profileBtn.innerHTML;
profileBtn.innerHTML = `<svg aria-label="Loading" role="status" data-icon="inline-start" class="animate-spin lucide lucide-loader-circle" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-6.219-8.56" /></svg> Enregistrement...`;
try {
const res = await fetch("/api/profile", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
theme_preference: themePreference,
visibility,
}),
});
if (res.ok) {
showToast(
"success",
"Enregistré",
"Vos préférences ont été mises à jour.",
);
// Apply theme instantly without refresh
const html = document.documentElement;
if (themePreference === "Dark") {
html.classList.add("dark");
html.removeAttribute("data-theme");
} else if (themePreference === "Light") {
html.classList.remove("dark");
html.setAttribute("data-theme", "light");
} else {
// System — use system preference
html.classList.toggle(
"dark",
matchMedia("(prefers-color-scheme: dark)").matches,
);
html.removeAttribute("data-theme");
}
} else {
const data = await res.json();
showToast(
"error",
"Erreur",
data.error ?? "Échec de l'enregistrement",
);
}
} catch {
showToast("error", "Erreur", "Erreur réseau");
} finally {
profileBtn.disabled = false;
profileBtn.innerHTML = originalText;
}
});
</script>
+394
View File
@@ -0,0 +1,394 @@
---
import BaseLayout from "../layouts/BaseLayout.astro";
import { getSets } from "../lib/tcgdex-cache";
const { pb } = Astro.locals;
if (!pb.authStore.isValid) {
return Astro.redirect("/login");
}
const search = Astro.url.searchParams.get("search") ?? "";
const serie = Astro.url.searchParams.get("serie") ?? "";
const set = Astro.url.searchParams.get("set") ?? "";
const sort = Astro.url.searchParams.get("sort") ?? "";
const hasFilter = !!(serie || set);
interface CardSummary {
id: string;
name: string;
localId?: string;
image?: string;
setId: string;
serieId: string;
serieName: string;
set: { name: string };
}
let cards: CardSummary[] = [];
let serieFacets: { id: string; name: string; count: number }[] = [];
let setFacets: { id: string; name: string; count: number }[] = [];
if (search.length >= 3) {
try {
const params = new URLSearchParams();
params.set("name", `like:${search}*`);
if (sort === "name-asc" || sort === "name-desc") {
params.set("sort:field", "name");
params.set("sort:order", sort === "name-asc" ? "asc" : "desc");
}
// The list endpoint returns summaries (id, name, localId, image) only,
// so serie/set filters are derived from the results themselves: a
// card's set id is the part of its id before the last "-" and the
// cached set metadata maps each set to its serie. Filtering is
// applied locally so the facet pills always reflect the full results.
const [res, allSets] = await Promise.all([
fetch(`https://api.tcgdex.net/v2/fr/cards?${params}`),
getSets(),
]);
if (res.ok) {
const summaries: { id: string }[] = await res.json();
const setById = new Map(allSets.map((s) => [s.id, s]));
const enriched: CardSummary[] = summaries.map((card) => {
const setId = card.id.replace(/-[^-]+$/, "");
const meta = setById.get(setId);
return {
...card,
setId,
serieId: meta?.serie?.id ?? "",
serieName: meta?.serie?.name ?? "",
set: { name: meta?.name ?? setId },
};
});
const countBy = (
key: "serieId" | "setId",
): { id: string; name: string; count: number }[] => {
const counts = new Map<
string,
{ name: string; count: number }
>();
for (const c of enriched) {
const id = c[key];
if (!id) continue;
const name = key === "serieId" ? c.serieName : c.set.name;
const entry = counts.get(id) ?? { name, count: 0 };
entry.count++;
counts.set(id, entry);
}
return [...counts.entries()]
.map(([id, v]) => ({ id, ...v }))
.sort((a, b) => b.count - a.count);
};
serieFacets = countBy("serieId");
setFacets = countBy("setId");
cards = enriched.filter(
(c) =>
(!serie || c.serieId === serie) && (!set || c.setId === set),
);
}
} catch {
// Keep empty results on network failure
}
}
// A search can span dozens of sets — keep the pill row to the most common ones
const MAX_SET_PILLS = 12;
const visibleSetFacets = setFacets.slice(0, MAX_SET_PILLS);
---
<BaseLayout>
<div class="max-w-[1600px] mx-auto px-4 md:px-8 py-8">
<form class="mb-6" action="/search" method="get">
<div class="input-group">
<input
type="search"
name="search"
value={search}
placeholder="Rechercher une carte..."
minlength="3"
autofocus
/>
<span data-align="start" aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m21 21-4.34-4.34" />
<circle cx="11" cy="11" r="8" />
</svg>
</span>
{search && (
<button type="button" class="btn" data-variant="ghost" data-size="icon-xs" data-align="end" aria-label="Effacer la recherche" onclick="this.closest('form').querySelector('input').value=''; this.closest('form').submit()">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M18 6 6 18" />
<path d="m6 6 12 12" />
</svg>
</button>
)}
<button type="submit" class="btn" data-size="sm" data-align="end">Rechercher</button>
</div>
<!-- Dynamic filters, extracted from the results -->
{serieFacets.length > 0 && (
<>
<input type="hidden" name="serie" value={serie} />
<input type="hidden" name="set" value={set} />
<div class="filter-bar mt-4">
<div class="pills-row">
<span class="pills-label">Série</span>
{serieFacets.map((f) => (
<button
type="button"
class:list={[
"pill",
{ active: serie === f.id },
]}
data-pill-group="serie"
data-value={f.id}
>
{f.name}
<span class="pill-count">{f.count}</span>
</button>
))}
</div>
<div class="pills-row">
<span class="pills-label">Set</span>
{visibleSetFacets.map((f) => (
<button
type="button"
class:list={[
"pill",
{ active: set === f.id },
]}
data-pill-group="set"
data-value={f.id}
>
{f.name}
<span class="pill-count">{f.count}</span>
</button>
))}
</div>
<div class="filter-selects">
<label>
Tri
<select
name="sort"
class="select"
onchange="this.form.submit()"
>
<option value="">Pertinence</option>
<option
value="name-asc"
selected={sort === "name-asc"}
>
Nom A→Z
</option>
<option
value="name-desc"
selected={sort === "name-desc"}
>
Nom Z→A
</option>
</select>
</label>
{(hasFilter || sort) && (
<a
href={`/search?search=${encodeURIComponent(search)}`}
class="pill"
>
✕ Réinitialiser
</a>
)}
</div>
</div>
</>
)}
</form>
{
search.length > 0 && search.length < 3 ? (
<section class="empty">
<header>
<figure>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m21 21-4.34-4.34" />
<circle cx="11" cy="11" r="8" />
</svg>
</figure>
<h3>Recherche trop courte</h3>
<p>Entrez au moins 3 caractères pour lancer la recherche.</p>
</header>
</section>
) : search.length >= 3 && cards.length === 0 ? (
<section class="empty">
<header>
<figure>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m21 21-4.34-4.34" />
<circle cx="11" cy="11" r="8" />
</svg>
</figure>
<h3>Aucun résultat</h3>
<p>Aucune carte trouvée pour "{search}"{hasFilter && " avec ces filtres"}. Essayez d'autres critères.</p>
</header>
</section>
) : cards.length > 0 ? (
<>
<div class="flex items-center justify-between mb-4">
<p class="text-sm opacity-70 m-0">{cards.length} carte{cards.length > 1 ? "s" : ""} trouvée{cards.length > 1 ? "s" : ""}</p>
<!-- View Toggle -->
<div role="group" class="button-group">
<button
type="button"
id="view-grid-btn"
class="btn"
data-variant="outline"
data-size="icon-sm"
aria-label="Vue grille"
title="Vue grille"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect width="7" height="7" x="3" y="3" rx="1" />
<rect width="7" height="7" x="14" y="3" rx="1" />
<rect width="7" height="7" x="14" y="14" rx="1" />
<rect width="7" height="7" x="3" y="14" rx="1" />
</svg>
</button>
<button
type="button"
id="view-list-btn"
class="btn"
data-variant="ghost"
data-size="icon-sm"
aria-label="Vue liste"
title="Vue liste"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 5h.01" />
<path d="M3 12h.01" />
<path d="M3 19h.01" />
<path d="M8 5h13" />
<path d="M8 12h13" />
<path d="M8 19h13" />
</svg>
</button>
</div>
</div>
<!-- Grid View -->
<div
id="grid-view"
class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4"
>
{cards.map((card) => (
<a
href={`/card/${card.id}`}
class="card hover:shadow-lg transition-shadow no-underline"
>
<img
class="aspect-2.5/3.5 w-full object-cover"
src={card.image ? `${card.image}/low.webp` : "/assets/placeholder_card.png"}
alt={card.name}
/>
<header class="px-3 pb-3">
<h3 class="text-sm font-semibold truncate">{card.name}</h3>
<span class="text-xs opacity-50">{card.id}</span>
</header>
</a>
))}
</div>
<!-- List View (hidden by default) -->
<div id="list-view" class="hidden">
<div class="table-container">
<table class="table">
<thead>
<tr>
<th class="w-16"></th>
<th>Nom</th>
<th>Set</th>
<th>Numéro</th>
</tr>
</thead>
<tbody>
{cards.map((card) => (
<tr>
<td>
<a href={`/card/${card.id}`}>
<img
src={card.image ? `${card.image}/low.webp` : "/assets/placeholder_card.png"}
alt={card.name}
class="w-12 h-16 object-cover rounded-sm"
/>
</a>
</td>
<td class="font-medium">
<a href={`/card/${card.id}`} class="no-underline">
{card.name}
<span class="text-xs opacity-50 ml-1.5">{card.id}</span>
</a>
</td>
<td class="text-sm opacity-70">
{card.set?.name ?? "—"}
</td>
<td class="text-sm opacity-70">
{card.localId ?? "—"}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</>
) : (
<section class="empty">
<header>
<figure>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m21 21-4.34-4.34" />
<circle cx="11" cy="11" r="8" />
</svg>
</figure>
<h3>Rechercher une carte</h3>
<p>Recherchez une carte par nom, puis filtrez les résultats par série et par set.</p>
</header>
</section>
)
}
</div>
</BaseLayout>
<script>
import { initViewToggle } from "../lib/view-toggle";
initViewToggle("searchView");
// Pill filters: toggle the hidden input for the group, then resubmit the
// GET form so the current search text and other filters are preserved.
document
.querySelectorAll<HTMLButtonElement>("[data-pill-group]")
.forEach((btn) => {
btn.addEventListener("click", () => {
const group = btn.dataset.pillGroup!;
const hidden = document.querySelector<HTMLInputElement>(
`input[type="hidden"][name="${group}"]`,
);
if (!hidden) return;
hidden.value =
hidden.value === btn.dataset.value
? ""
: (btn.dataset.value ?? "");
btn.closest("form")?.submit();
});
});
</script>
<style is:global>
a.card {
text-decoration: none;
}
</style>
+95
View File
@@ -0,0 +1,95 @@
---
import BaseLayout from "../../layouts/BaseLayout.astro";
import { getSeries, getSetsBySerie } from "../../lib/tcgdex-cache";
const { pb } = Astro.locals;
if (!pb.authStore.isValid) {
return Astro.redirect("/login");
}
const { id } = Astro.params;
if (!id) {
return Astro.redirect("/");
}
const [series, sets] = await Promise.all([getSeries(), getSetsBySerie(id)]);
const serie = series.find((s) => s.id === id);
if (!serie) {
return Astro.redirect("/");
}
---
<BaseLayout>
<div class="max-w-350 mx-auto px-4 md:px-8 py-8">
<a
href="/"
class="inline-block no-underline opacity-70 hover:opacity-100 text-sm mb-4"
>
← Accueil
</a>
<div class="flex items-center gap-4 mb-6">
{
serie.logo && (
<img
src={`${serie.logo}.webp`}
alt={serie.name}
class="h-14 w-auto object-contain"
/>
)
}
<div>
<h1 class="m-0">{serie.name}</h1>
<p class="text-sm opacity-70 m-0">
{sets.length} extension{sets.length > 1 ? "s" : ""}
</p>
</div>
</div>
{
sets.length === 0 ? (
<section class="empty">
<header>
<h3>Aucune extension</h3>
<p>Aucune extension trouvée pour cette série.</p>
</header>
</section>
) : (
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4">
{sets.map((set) => (
<a
href={`/sets/${set.id}`}
class="card no-underline hover:shadow-lg transition-shadow"
>
<section class="flex flex-col items-center gap-2 py-4 text-center">
{set.logo && (
<img
src={`${set.logo}.webp`}
alt={set.name}
class="h-16 max-w-full object-contain"
/>
)}
{set.symbol && (
<img
src={`${set.symbol}.webp`}
alt=""
class="h-6 w-6 object-contain"
/>
)}
<span class="font-semibold text-sm leading-tight">
{set.name}
</span>
<span class="text-xs opacity-60">
{set.cardCount.official} cartes
</span>
</section>
</a>
))}
</div>
)
}
</div>
</BaseLayout>
+101
View File
@@ -0,0 +1,101 @@
---
import BaseLayout from "../../layouts/BaseLayout.astro";
const { pb } = Astro.locals;
if (!pb.authStore.isValid) {
return Astro.redirect("/login");
}
const { id } = Astro.params;
if (!id) {
return Astro.redirect("/");
}
// The set detail endpoint includes the full card list (summaries)
let set: any = null;
try {
const res = await fetch(
`https://api.tcgdex.net/v2/fr/sets/${encodeURIComponent(id)}`,
);
if (res.ok) {
set = await res.json();
}
} catch {
// fall through
}
if (!set) {
return Astro.redirect("/");
}
---
<BaseLayout>
<div class="max-w-[1600px] mx-auto px-4 md:px-8 py-8">
<a
href={set.serie?.id ? `/series/${set.serie.id}` : "/"}
class="inline-block no-underline opacity-70 hover:opacity-100 text-sm mb-4"
>
← {set.serie?.name ?? "Accueil"}
</a>
<div class="flex items-center gap-4 mb-6 flex-wrap">
{set.logo && (
<img
src={`${set.logo}.webp`}
alt={set.name}
class="h-14 w-auto object-contain"
/>
)}
<div>
<h1 class="m-0">{set.name}</h1>
<p class="text-sm opacity-70 m-0">
{set.cards?.length ?? 0} carte{set.cards?.length > 1 ? "s" : ""}
{set.releaseDate && (
<> — sortie le {new Date(set.releaseDate).toLocaleDateString("fr-FR")}</>
)}
</p>
</div>
</div>
{
!set.cards || set.cards.length === 0 ? (
<section class="empty">
<header>
<h3>Aucune carte</h3>
<p>Aucune carte trouvée pour cette extension.</p>
</header>
</section>
) : (
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
{set.cards.map((card: any) => (
<a
href={`/card/${card.id}`}
class="card hover:shadow-lg transition-shadow no-underline"
>
<img
class="aspect-2.5/3.5 w-full object-cover"
src={card.image ? `${card.image}/low.webp` : "/assets/placeholder_card.png"}
alt={card.name}
loading="lazy"
/>
<header class="px-3 pb-3">
<h3 class="text-sm font-semibold truncate">
{card.name}
</h3>
<p class="text-xs opacity-60 m-0">#{card.localId}</p>
</header>
</a>
))}
</div>
)
}
</div>
</BaseLayout>
<style is:global>
a.card {
text-decoration: none;
}
</style>
+127
View File
@@ -0,0 +1,127 @@
@import "tailwindcss";
@import "basecoat-css/vega.css";
/* Global base styles */
html {
scroll-behavior: smooth;
}
a {
text-decoration: none;
color: inherit;
}
a:hover {
text-decoration: none;
}
/* Smooth transitions for interactive elements */
.btn,
.badge,
.card,
.item {
transition-property: color, background-color, border-color, box-shadow, transform;
transition-duration: 150ms;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
}
/* Focus visible for accessibility */
:focus-visible {
outline: 2px solid var(--ring);
outline-offset: 2px;
}
/* Hide the native clear button on search inputs — the pages provide their
own clear button, and Chrome renders both, overlapping the input text */
input[type="search"]::-webkit-search-cancel-button {
appearance: none;
display: none;
}
/* Filter bars (search + collection pages) */
.filter-bar {
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.pills-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.4rem;
}
.pills-label {
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.05em;
opacity: 0.6;
min-width: 4.5rem;
}
.pill {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.22rem 0.75rem;
border-radius: 999px;
border: 1px solid var(--border);
background: transparent;
color: inherit;
font-family: inherit;
font-size: 0.8rem;
line-height: 1.4;
cursor: pointer;
text-decoration: none;
transition:
background-color 150ms ease,
color 150ms ease,
border-color 150ms ease,
filter 150ms ease;
}
.pill:hover {
background: var(--muted);
}
.pill.active {
background: var(--primary);
color: var(--primary-foreground);
border-color: transparent;
}
.pill-count {
font-size: 0.72rem;
opacity: 0.55;
}
.pill.active .pill-count {
opacity: 0.85;
}
/* Compact inline selects row (no full-width stacked fields) */
.filter-selects {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem;
}
.filter-selects label {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-size: 0.8rem;
opacity: 0.8;
white-space: nowrap;
}
.filter-selects select {
width: auto;
min-width: 7rem;
}
.filter-selects .input {
width: 12rem;
}