Initial commit
This commit is contained in:
@@ -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" },
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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" },
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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" },
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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" },
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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" },
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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" },
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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" },
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { APIRoute } from "astro";
|
||||
|
||||
export const POST: APIRoute = async ({ cookies, redirect }) => {
|
||||
cookies.delete("pb_auth", { path: "/" });
|
||||
return redirect("/login");
|
||||
};
|
||||
@@ -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" },
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
import LoginForm from "../components/LoginForm.astro";
|
||||
import BaseLayout from "../layouts/BaseLayout.astro";
|
||||
---
|
||||
|
||||
<BaseLayout>
|
||||
<LoginForm />
|
||||
</BaseLayout>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user