Full cutover: auth, users, collection and friendships now run on Directus via @directus/sdk, with owner/visibility rules enforced by Directus permissions. - middleware: Directus session (directus_auth cookie) with token refresh - /login is Directus (email normalized to lowercase), new /register page (first/last name, username) backed by public registration - collection, friends and profile pages + all API endpoints ported; ownership and addressee-only accept/decline enforced by permissions - display names use first_name/last_name everywhere (nav, friends, profile) - PocketBase SDK, POCKETBASE_URL and pb_schema.json removed - README and compose updated for DIRECTUS_URL
397 lines
18 KiB
Plaintext
397 lines
18 KiB
Plaintext
---
|
|
import BaseLayout from "../layouts/BaseLayout.astro";
|
|
import { getSets } from "../lib/tcgdex-cache";
|
|
|
|
import { isLoggedIn } from "../lib/auth";
|
|
|
|
const { pb } = Astro.locals;
|
|
|
|
if (!isLoggedIn(Astro.locals)) {
|
|
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>
|