Initial commit
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user