Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,13 @@
},
"collection": {
"title": "Collection",
"subtitle": "Browse, filter, and search your entire collection."
"subtitle": "Browse, filter, and search your entire collection.",
"browsePlayers": "Players in your collection",
"playersInCollection": "{count} players in your collection",
"browseVariations": "Variations in your collection",
"variationsBrowseHint": "Filter by brand and set, then search or pick a variation.",
"variationsInCollection": "{count} variation(s) for this selection",
"variationsNoneFound": "No variations found for this brand and set."
},
"players": {
"title": "Players",
Expand Down Expand Up @@ -198,6 +204,7 @@
"activeYear": "Year = {value}",
"activeBrand": "Brand = {value}",
"activeSet": "Set = {value}",
"activeVariation": "Variation = {value}",
"resetFilters": "Reset"
},
"admin": {
Expand Down
9 changes: 8 additions & 1 deletion messages/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,13 @@
},
"collection": {
"title": "Collection",
"subtitle": "Parcourez, filtrez et recherchez toute votre collection."
"subtitle": "Parcourez, filtrez et recherchez toute votre collection.",
"browsePlayers": "Joueurs de la collection",
"playersInCollection": "{count} joueurs dans la collection",
"browseVariations": "Variations de la collection",
"variationsBrowseHint": "Filtrez par marque et set, puis recherchez ou sélectionnez une variation.",
"variationsInCollection": "{count} variation(s) pour cette sélection",
"variationsNoneFound": "Aucune variation trouvée pour cette marque et ce set."
},
"players": {
"title": "Joueurs",
Expand Down Expand Up @@ -198,6 +204,7 @@
"activeYear": "Année = {value}",
"activeBrand": "Marque = {value}",
"activeSet": "Set = {value}",
"activeVariation": "Variation = {value}",
"resetFilters": "Réinitialiser"
},
"admin": {
Expand Down
1 change: 1 addition & 0 deletions src/app/(app)/collection/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export default async function CollectionPage({
year: filterValueSchema.parse(firstParam(params, "year")),
brand: filterValueSchema.parse(firstParam(params, "brand")),
set: filterValueSchema.parse(firstParam(params, "set")),
variation: filterValueSchema.parse(firstParam(params, "variation")),
tag: tagSchema.parse(firstParam(params, "tag")),
}}
/>
Expand Down
118 changes: 3 additions & 115 deletions src/components/admin/admin-cards-section.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
"use client";

import { useDeferredValue, useId, useMemo, useState } from "react";
import { useDeferredValue, useMemo, useState } from "react";
import { Card, References } from "@/lib/types";
import { uniqueSorted } from "@/lib/string-list";
import { CardForm } from "@/components/card-form";
import { CardBadges } from "@/components/card-badges";
import { ColumnFilterCombobox } from "@/components/column-filter-combobox";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Expand Down Expand Up @@ -32,120 +34,6 @@ interface AdminCardsSectionProps {
onReferencesChange: (references: References) => void;
}

function uniqueSorted(values: string[]): string[] {
return Array.from(
new Set(values.map((value) => value.trim()).filter(Boolean))
).sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
}

interface ColumnFilterComboboxProps {
value: string;
onChange: (value: string) => void;
placeholder: string;
suggestions: string[];
className?: string;
}

function ColumnFilterCombobox({
value,
onChange,
placeholder,
suggestions,
className,
}: ColumnFilterComboboxProps) {
const inputId = useId();
const listboxId = `${inputId}-listbox`;
const [open, setOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState(0);
const query = value.trim().toLowerCase();
const visibleSuggestions = useMemo(
() =>
suggestions.filter((suggestion) =>
suggestion.toLowerCase().includes(query)
),
[query, suggestions]
);

function selectSuggestion(nextValue: string): void {
onChange(nextValue);
setOpen(false);
setActiveIndex(0);
}

return (
<div className="relative">
<Input
id={inputId}
value={value}
onChange={(event) => {
onChange(event.target.value);
setOpen(true);
setActiveIndex(0);
}}
onFocus={() => setOpen(true)}
onBlur={() => setOpen(false)}
onKeyDown={(event) => {
if (!open && ["ArrowDown", "ArrowUp"].includes(event.key)) {
setOpen(true);
return;
}
if (event.key === "Escape") {
setOpen(false);
return;
}
if (visibleSuggestions.length === 0) return;
if (event.key === "ArrowDown") {
event.preventDefault();
setActiveIndex((index) => (index + 1) % visibleSuggestions.length);
}
if (event.key === "ArrowUp") {
event.preventDefault();
setActiveIndex(
(index) =>
(index - 1 + visibleSuggestions.length) %
visibleSuggestions.length
);
}
if (event.key === "Enter" && open) {
event.preventDefault();
selectSuggestion(visibleSuggestions[activeIndex]);
}
}}
placeholder={placeholder}
role="combobox"
aria-expanded={open && visibleSuggestions.length > 0}
aria-controls={listboxId}
aria-autocomplete="list"
className={className}
/>
{open && visibleSuggestions.length > 0 && (
<div
id={listboxId}
role="listbox"
className="absolute left-0 right-0 top-full z-30 mt-1 max-h-56 overflow-auto rounded-md border border-border bg-popover p-1 text-xs font-normal shadow-lg"
>
{visibleSuggestions.map((suggestion, index) => (
<button
key={suggestion}
type="button"
role="option"
aria-selected={index === activeIndex}
className="block w-full rounded px-2 py-1.5 text-left hover:bg-accent aria-selected:bg-accent"
onMouseDown={(event) => {
event.preventDefault();
selectSuggestion(suggestion);
}}
onMouseEnter={() => setActiveIndex(index)}
>
{suggestion}
</button>
))}
</div>
)}
</div>
);
}

export function AdminCardsSection({
cards,
references,
Expand Down
47 changes: 12 additions & 35 deletions src/components/admin/admin-players-section.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
"use client";

import { useMemo, useState } from "react";
import { useState } from "react";
import { References } from "@/lib/types";
import { parseSingleColumnValues } from "@/lib/csv-parse";
import { patchReferences } from "@/lib/references-client";
import { BatchTextImport } from "@/components/admin/batch-text-import";
import { FilterableListBrowser } from "@/components/filterable-list-browser";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { AdminFeedback } from "@/components/admin/admin-feedback";
import { Search } from "lucide-react";
import { useTranslations } from "@/i18n/client";

interface AdminPlayersSectionProps {
Expand All @@ -23,17 +23,10 @@ export function AdminPlayersSection({
}: AdminPlayersSectionProps) {
const t = useTranslations();
const [player, setPlayer] = useState("");
const [search, setSearch] = useState("");
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const [loading, setLoading] = useState(false);

const filtered = useMemo(() => {
if (!search) return references.players;
const q = search.toLowerCase();
return references.players.filter((name) => name.toLowerCase().includes(q));
}, [references.players, search]);

async function handleAddPlayer() {
const name = player.trim();
if (!name) {
Expand Down Expand Up @@ -102,32 +95,16 @@ export function AdminPlayersSection({
/>
</div>

<div className="space-y-3">
<div className="relative max-w-md">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t("admin.players.filter")}
className="pl-9"
/>
</div>
<p className="text-sm text-muted-foreground">
{t("admin.players.referenced", { count: references.players.length })}
{search ? ` ${t("admin.players.shown", { count: filtered.length })}` : ""}
</p>
<div className="max-h-72 overflow-auto rounded-lg border border-border p-3 text-sm">
{filtered.length === 0 ? (
<p className="text-muted-foreground">{t("admin.players.noneFound")}</p>
) : (
<ul className="grid gap-1 sm:grid-cols-2 lg:grid-cols-3">
{filtered.map((name) => (
<li key={name}>{name}</li>
))}
</ul>
)}
</div>
</div>
<FilterableListBrowser
items={references.players}
filterPlaceholder={t("admin.players.filter")}
countLabel={t("admin.players.referenced", {
count: references.players.length,
})}
filteredCountLabel={(count) => t("admin.players.shown", { count })}
emptyLabel={t("admin.players.noneFound")}
className="max-w-none border-0 p-0"
/>
</div>
);
}
Loading