88 lines
3.5 KiB
TypeScript
88 lines
3.5 KiB
TypeScript
import { useState, useMemo, useCallback, useEffect } from 'react';
|
|
import type { CardData } from './types';
|
|
import cardDataRaw from '../card_data.json?raw';
|
|
import { AE_LABELS, MFL_LABELS, SDG_LABELS } from './data/labels';
|
|
import IconBar from './components/IconBar';
|
|
import IconColumn from './components/IconColumn';
|
|
import CardField from './components/CardField';
|
|
import CardFocusOverlay from './components/CardFocusOverlay';
|
|
import './styles/app.css';
|
|
|
|
const cards: CardData[] = JSON.parse(cardDataRaw);
|
|
|
|
const AE_COUNT = 13;
|
|
const MFL_COUNT = 12;
|
|
const SDG_COUNT = 17;
|
|
|
|
function matchesAll(card: CardData, selectedAE: Set<number>, selectedMFL: Set<number>, selectedSDG: Set<number>): boolean {
|
|
for (const v of selectedAE) if (!card.ae.includes(v)) return false;
|
|
for (const v of selectedMFL) if (!card.mfl.includes(v)) return false;
|
|
for (const v of selectedSDG) if (!card.sdgs.includes(v)) return false;
|
|
return true;
|
|
}
|
|
|
|
export default function App() {
|
|
const [selectedAE, setSelectedAE] = useState<Set<number>>(new Set());
|
|
const [selectedMFL, setSelectedMFL] = useState<Set<number>>(new Set());
|
|
const [selectedSDG, setSelectedSDG] = useState<Set<number>>(new Set());
|
|
const [focusedCardId, setFocusedCardId] = useState<number | null>(null);
|
|
|
|
useEffect(() => {
|
|
const handler = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape') setFocusedCardId(null);
|
|
};
|
|
window.addEventListener('keydown', handler);
|
|
return () => window.removeEventListener('keydown', handler);
|
|
}, []);
|
|
|
|
const toggleSet = useCallback((value: number, set: Set<number>, setter: (s: Set<number>) => void) => {
|
|
const next = new Set(set);
|
|
if (next.has(value)) next.delete(value);
|
|
else next.add(value);
|
|
setter(next);
|
|
setFocusedCardId(null);
|
|
}, []);
|
|
|
|
const filteredCards = useMemo(() => {
|
|
const total = selectedAE.size + selectedMFL.size + selectedSDG.size;
|
|
if (total === 0) return [];
|
|
return cards.filter((c) => matchesAll(c, selectedAE, selectedMFL, selectedSDG));
|
|
}, [selectedAE, selectedMFL, selectedSDG]);
|
|
|
|
const active = { ae: selectedAE.size > 0, mfl: selectedMFL.size > 0, sdg: selectedSDG.size > 0 };
|
|
|
|
const handleFocus = useCallback((id: number | null) => {
|
|
setFocusedCardId(id);
|
|
}, []);
|
|
|
|
const focusedCard = focusedCardId !== null ? cards.find((c) => c.id === focusedCardId) ?? null : null;
|
|
|
|
return (
|
|
<div className="app">
|
|
{focusedCard && <CardFocusOverlay card={focusedCard} onClose={() => setFocusedCardId(null)} />}
|
|
|
|
<div className="ae-banner">
|
|
<IconBar set="ae" count={AE_COUNT} labels={AE_LABELS} selected={selectedAE} onToggle={(v) => toggleSet(v, selectedAE, setSelectedAE)} />
|
|
</div>
|
|
|
|
<div className="sdg-banner">
|
|
<IconBar set="sdg" count={SDG_COUNT} labels={SDG_LABELS} selected={selectedSDG} onToggle={(v) => toggleSet(v, selectedSDG, setSelectedSDG)} />
|
|
</div>
|
|
|
|
<div className="mfl-sidebar">
|
|
<IconColumn set="mfl" count={MFL_COUNT} labels={MFL_LABELS} selected={selectedMFL} onToggle={(v) => toggleSet(v, selectedMFL, setSelectedMFL)} />
|
|
</div>
|
|
|
|
<div className="center-field">
|
|
<div className="field-label-top">
|
|
<span className="label-ae">{active.ae ? 'AE' : ''}</span>
|
|
<span className="label-mfl">{active.mfl ? 'MFL' : ''}</span>
|
|
<span className="label-sdg">{active.sdg ? 'SDG' : ''}</span>
|
|
<span className="label-count">{filteredCards.length} cards</span>
|
|
</div>
|
|
<CardField cards={filteredCards} focusedId={focusedCardId} onFocus={handleFocus} />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|