Files
pokeshare/src/pages/search.astro
T
nlevesque fc51371dad Migrate backend from PocketBase to Directus
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
2026-07-28 20:33:46 +02:00

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>