Skip to content
Open
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
60 changes: 41 additions & 19 deletions client/src/components/menu/PreconDeckModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next";
import { isCommanderPreconDeck, useDecks, type DeckEntry } from "../../hooks/useDecks";
import { preconExists, savePreconDeck } from "../../services/preconDecks";
import { menuButtonClass } from "./buttonStyles";
import { SelectField } from "../ui/SelectField";
import { MenuSelect } from "../ui/MenuSelect";

interface PreconDeckModalProps {
open: boolean;
Expand Down Expand Up @@ -41,7 +41,7 @@ function coverageTone(pct: number): string {

export function PreconDeckModal({ open, onClose, onImported }: PreconDeckModalProps) {
const { t } = useTranslation("menu");
const decks = useDecks();
const { decks, status } = useDecks();
const [query, setQuery] = useState("");
const [typeFilter, setTypeFilter] = useState<string>(ALL_TYPES);
// Multi-select state. Keyed by deck id (filename stem) so it survives the
Expand Down Expand Up @@ -91,6 +91,30 @@ export function PreconDeckModal({ open, onClose, onImported }: PreconDeckModalPr
.slice(0, MAX_RESULTS);
}, [decks, query, typeFilter]);

const typeOptions = useMemo(
() =>
Array.from(typeCounts.entries())
.filter(([, n]) => n > 0)
.sort((a, b) => a[0].localeCompare(b[0])),
[typeCounts],
);

const typeFilterItems = useMemo(
() => [
{ value: ALL_TYPES, label: t("precon.allTypes", { count: totalMatches }) },
...typeOptions.map(([type, n]) => ({ value: type, label: `${type} (${n})` })),
],
[t, totalMatches, typeOptions],
);

const typeFilterLabel = useMemo(() => {
if (typeFilter === ALL_TYPES) {
return t("precon.allTypes", { count: totalMatches });
}
const match = typeOptions.find(([type]) => type === typeFilter);
return match ? `${match[0]} (${match[1]})` : typeFilter;
}, [typeFilter, totalMatches, typeOptions, t]);

if (!open) return null;

const handlePick = (deck: DeckEntry) => {
Expand Down Expand Up @@ -163,10 +187,6 @@ export function PreconDeckModal({ open, onClose, onImported }: PreconDeckModalPr
}
};

const typeOptions = Array.from(typeCounts.entries())
.filter(([, n]) => n > 0)
.sort((a, b) => a[0].localeCompare(b[0]));

return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4 backdrop-blur-sm"
Expand Down Expand Up @@ -211,24 +231,26 @@ export function PreconDeckModal({ open, onClose, onImported }: PreconDeckModalPr
className="flex-1 rounded-lg border border-white/10 bg-black/30 px-3 py-2 text-sm text-white placeholder:text-slate-500 focus:border-white/30 focus:outline-none"
autoFocus
/>
<SelectField
value={typeFilter}
onChange={(e) => setTypeFilter(e.target.value)}
className="rounded-lg border border-white/10 bg-black/30 px-3 py-2 text-sm text-white focus:border-white/30 focus:outline-none"
>
<option value={ALL_TYPES}>{t("precon.allTypes", { count: totalMatches })}</option>
{typeOptions.map(([type, n]) => (
<option key={type} value={type}>
{type} ({n})
</option>
))}
</SelectField>
<MenuSelect
ariaLabel={t("precon.typeFilter")}
label={typeFilterLabel}
selectedValue={typeFilter}
items={typeFilterItems}
onSelect={setTypeFilter}
menuLayout="dropdown"
wrapperClassName="sm:w-52 shrink-0"
fitContainer
disabled={status === "loading" || status === "error"}
className="rounded-lg border-white/10 bg-black/30 py-2 focus-visible:ring-white/30"
/>
</div>


<div className="flex-1 overflow-y-auto rounded-lg border border-white/5 bg-black/20">
{!decks ? (
{status === "loading" ? (
<div className="p-8 text-center text-sm text-slate-500">{t("precon.loadingCatalog")}</div>
) : status === "error" ? (
<div className="p-8 text-center text-sm text-rose-300/90">{t("precon.loadFailed")}</div>
) : filtered.length === 0 ? (
<div className="p-8 text-center text-sm text-slate-500">{t("precon.noMatch")}</div>
) : (
Expand Down
2 changes: 1 addition & 1 deletion client/src/components/menu/__tests__/MyDecks.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ vi.mock("../../../services/deckCompatibility", () => ({
vi.mock("../../../hooks/useDecks", () => ({
loadPreconDeckMap: vi.fn(),
isCommanderPreconDeck: (deck: { type: string }) => deck.type === "Commander Deck",
useDecks: vi.fn(() => null),
useDecks: vi.fn(() => ({ decks: null, status: "loading" as const })),
}));

function saveDeck(name: string, deck: ParsedDeck): void {
Expand Down
24 changes: 20 additions & 4 deletions client/src/hooks/useDecks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,20 +43,36 @@ export function loadPreconDeckMap(): Promise<DeckMap | null> {
return fetchPromise;
}

export type UseDecksStatus = "loading" | "success" | "error";

export interface UseDecksResult {
/** Catalog entries keyed by MTGJSON filename stem; `null` until the first fetch settles. */
decks: DeckMap | null;
status: UseDecksStatus;
}
Comment thread
carlh7777 marked this conversation as resolved.

/**
* Returns the preconstructed deck catalog keyed by deck id (MTGJSON filename
* stem, e.g. `RedDeckB_10E`). Includes every deck above MIN_DECK_CARDS — each
* entry carries a `coveragePct`, so consumers (e.g. the precon picker) can
* apply their own coverage-floor filter rather than dropping decks at build
* time. `null` while loading or on fetch failure.
* time.
*/
export function useDecks(): DeckMap | null {
export function useDecks(): UseDecksResult {
const [decks, setDecks] = useState<DeckMap | null>(cached);
const [status, setStatus] = useState<UseDecksStatus>(cached ? "success" : "loading");

useEffect(() => {
if (cached) return;
loadPreconDeckMap().then((d) => { if (d) setDecks(d); });
loadPreconDeckMap().then((d) => {
if (d) {
setDecks(d);
setStatus("success");
return;
}
setStatus("error");
});
}, []);

return decks;
return { decks, status };
}
Comment thread
carlh7777 marked this conversation as resolved.
2 changes: 2 additions & 0 deletions client/src/i18n/locales/de/menu.json
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,9 @@
"totalCount": "· {{count}} insgesamt",
"searchPlaceholder": "Nach Name, Set-Code oder Typ suchen…",
"allTypes": "Alle ({{count}})",
"typeFilter": "Decktyp",
"loadingCatalog": "Deckkatalog wird geladen…",
"loadFailed": "Der Katalog vorkonstruierter Decks konnte nicht geladen werden. Verbindung prüfen und erneut versuchen.",
"noMatch": "Keine Decks passen.",
"selectDeck": "{{name}} auswählen",
"cardCount_one": "{{count}} Karte",
Expand Down
2 changes: 2 additions & 0 deletions client/src/i18n/locales/en/menu.json
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,9 @@
"totalCount": "· {{count}} total",
"searchPlaceholder": "Search by name, set code, or type…",
"allTypes": "All ({{count}})",
"typeFilter": "Deck type",
"loadingCatalog": "Loading deck catalog…",
"loadFailed": "Could not load the preconstructed deck catalog. Check your connection and try again.",
"noMatch": "No decks match.",
"selectDeck": "Select {{name}}",
"cardCount_one": "{{count}} card",
Expand Down
2 changes: 2 additions & 0 deletions client/src/i18n/locales/es/menu.json
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,9 @@
"totalCount": "· {{count}} en total",
"searchPlaceholder": "Buscar por nombre, código de colección o tipo…",
"allTypes": "Todos ({{count}})",
"typeFilter": "Tipo de mazo",
"loadingCatalog": "Cargando catálogo de mazos…",
"loadFailed": "No se pudo cargar el catálogo de mazos preconstruidos. Comprueba tu conexión e inténtalo de nuevo.",
"noMatch": "Ningún mazo coincide.",
"selectDeck": "Seleccionar {{name}}",
"cardCount_one": "{{count}} carta",
Expand Down
2 changes: 2 additions & 0 deletions client/src/i18n/locales/fr/menu.json
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,9 @@
"totalCount": "· {{count}} au total",
"searchPlaceholder": "Rechercher par nom, code d'extension ou type…",
"allTypes": "Tous ({{count}})",
"typeFilter": "Type de deck",
"loadingCatalog": "Chargement du catalogue de decks…",
"loadFailed": "Impossible de charger le catalogue de decks préconstruits. Vérifiez votre connexion et réessayez.",
"noMatch": "Aucun deck ne correspond.",
"selectDeck": "Sélectionner {{name}}",
"cardCount_one": "{{count}} carte",
Expand Down
2 changes: 2 additions & 0 deletions client/src/i18n/locales/it/menu.json
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,9 @@
"totalCount": "· {{count}} totali",
"searchPlaceholder": "Cerca per nome, codice set o tipo…",
"allTypes": "Tutti ({{count}})",
"typeFilter": "Tipo di mazzo",
"loadingCatalog": "Caricamento catalogo mazzi…",
"loadFailed": "Impossibile caricare il catalogo dei mazzi precostruiti. Controlla la connessione e riprova.",
"noMatch": "Nessun mazzo corrisponde.",
"selectDeck": "Seleziona {{name}}",
"cardCount_one": "{{count}} carta",
Expand Down
2 changes: 2 additions & 0 deletions client/src/i18n/locales/pl/menu.json
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,9 @@
"totalCount": "· {{count}} łącznie",
"searchPlaceholder": "Szukaj według nazwy, kodu dodatku lub typu…",
"allTypes": "Wszystkie ({{count}})",
"typeFilter": "Typ talii",
"loadingCatalog": "Wczytywanie katalogu talii…",
"loadFailed": "Nie udało się wczytać katalogu gotowych talii. Sprawdź połączenie i spróbuj ponownie.",
"noMatch": "Brak pasujących talii.",
"selectDeck": "Wybierz {{name}}",
"cardCount_one": "{{count}} karta",
Expand Down
2 changes: 2 additions & 0 deletions client/src/i18n/locales/pt/menu.json
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,9 @@
"totalCount": "· {{count}} no total",
"searchPlaceholder": "Buscar por nome, código de coleção ou tipo…",
"allTypes": "Todos ({{count}})",
"typeFilter": "Tipo de deck",
"loadingCatalog": "Carregando catálogo de decks…",
"loadFailed": "Não foi possível carregar o catálogo de decks pré-construídos. Verifique sua conexão e tente novamente.",
"noMatch": "Nenhum deck corresponde.",
"selectDeck": "Selecionar {{name}}",
"cardCount_one": "{{count}} carta",
Expand Down
2 changes: 1 addition & 1 deletion client/src/pages/__tests__/GameSetupPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ vi.mock("../../hooks/useDecks", async () => {
);
return {
...actual,
useDecks: () => null,
useDecks: () => ({ decks: null, status: "success" as const }),
};
});

Expand Down
Loading