211 lines
9.8 KiB
React
211 lines
9.8 KiB
React
/* SAPL Garment Library — app state & composition */
|
|
|
|
const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
|
|
"cardVariant": "Gallery",
|
|
"gridStyle": "Mixed",
|
|
"cardSize": 250,
|
|
"accent": "#9a7322"
|
|
}/*EDITMODE-END*/;
|
|
|
|
const STOPWORDS = ["the", "for", "and", "with", "something", "edit", "in", "a", "of"];
|
|
|
|
function matchesQuery(g, q) {
|
|
if (!q.trim()) return true;
|
|
const hay = [
|
|
g.style, g.season, g.category, g.dna, g.fabric, g.silhouette, g.value, g.team,
|
|
g.craft.join(" "), g.colours.map((c) => c.name).join(" "),
|
|
g.sample ? "sample" : "", g.pending ? "review pending" : "",
|
|
].join(" ").toLowerCase();
|
|
const tokens = q.toLowerCase().split(/[^a-z0-9]+/).filter((t) => t.length > 2 && !STOPWORDS.includes(t));
|
|
if (!tokens.length) return true;
|
|
return tokens.some((t) => hay.includes(t) || (t.endsWith("s") && hay.includes(t.slice(0, -1))));
|
|
}
|
|
|
|
function isSimilar(g, ref) {
|
|
if (g.id === ref.id) return true;
|
|
return g.dna === ref.dna || g.fabric === ref.fabric || g.silhouette === ref.silhouette || g.value === ref.value;
|
|
}
|
|
|
|
function App() {
|
|
const { GARMENTS, DNA_MOODS, FACETS, BOARDS, SEARCH_SEEDS } = window.GL_DATA;
|
|
const [t, setTweak] = useTweaks(TWEAK_DEFAULTS);
|
|
|
|
const [loading, setLoading] = React.useState(true);
|
|
const [activeDna, setActiveDna] = React.useState(null);
|
|
const [facetSel, setFacetSel] = React.useState({});
|
|
const [appliedQuery, setAppliedQuery] = React.useState("");
|
|
const [similarTo, setSimilarTo] = React.useState(null);
|
|
const [searchOpen, setSearchOpen] = React.useState(false);
|
|
const [searchDraft, setSearchDraft] = React.useState("");
|
|
const [selection, setSelection] = React.useState([]);
|
|
const [boardsOpen, setBoardsOpen] = React.useState(false);
|
|
const [uploadOpen, setUploadOpen] = React.useState(false);
|
|
const [pinned, setPinned] = React.useState({});
|
|
const [toast, setToast] = React.useState(null);
|
|
|
|
React.useEffect(() => { const id = setTimeout(() => setLoading(false), 900); return () => clearTimeout(id); }, []);
|
|
React.useEffect(() => {
|
|
const onKey = (e) => {
|
|
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") { e.preventDefault(); setSearchOpen(true); }
|
|
if (e.key === "Escape") { setSearchOpen(false); setBoardsOpen(false); setUploadOpen(false); }
|
|
};
|
|
window.addEventListener("keydown", onKey);
|
|
return () => window.removeEventListener("keydown", onKey);
|
|
}, []);
|
|
|
|
const showToast = (msg) => { setToast(msg); setTimeout(() => setToast((m) => (m === msg ? null : m)), 2600); };
|
|
const reshimmer = () => { setLoading(true); setTimeout(() => setLoading(false), 450); };
|
|
|
|
/* ---- filtering ---- */
|
|
const passesFacets = (g) =>
|
|
FACETS.every((f) => {
|
|
const sel = facetSel[f.key] || [];
|
|
if (!sel.length) return true;
|
|
const v = f.get(g);
|
|
return Array.isArray(v) ? v.some((x) => sel.includes(x)) : sel.includes(v);
|
|
});
|
|
|
|
const filtered = GARMENTS.filter(
|
|
(g) =>
|
|
(!activeDna || g.dna === activeDna) &&
|
|
passesFacets(g) &&
|
|
matchesQuery(g, appliedQuery) &&
|
|
(!similarTo || isSimilar(g, similarTo))
|
|
);
|
|
|
|
const draftCount = GARMENTS.filter((g) => matchesQuery(g, searchDraft)).length;
|
|
|
|
const dnaCounts = {};
|
|
GARMENTS.forEach((g) => { dnaCounts[g.dna] = (dnaCounts[g.dna] || 0) + 1; });
|
|
const dominant = Object.entries(dnaCounts).sort((a, b) => b[1] - a[1])[0][0];
|
|
const pendingCount = GARMENTS.filter((g) => g.pending).length;
|
|
|
|
const facetOptions = (f) => {
|
|
const m = new Map();
|
|
GARMENTS.forEach((g) => {
|
|
const v = f.get(g);
|
|
(Array.isArray(v) ? v : [v]).forEach((x) => m.set(x, (m.get(x) || 0) + 1));
|
|
});
|
|
return [...m.entries()].sort().map(([value, count]) => ({ value, count }));
|
|
};
|
|
|
|
const toggleFacet = (key, value) =>
|
|
setFacetSel((s) => {
|
|
const cur = s[key] || [];
|
|
return { ...s, [key]: cur.includes(value) ? cur.filter((v) => v !== value) : [...cur, value] };
|
|
});
|
|
|
|
const pills = [];
|
|
FACETS.forEach((f) => (facetSel[f.key] || []).forEach((v) => pills.push({ key: f.key, value: v, label: v, type: "facet" })));
|
|
if (appliedQuery) pills.push({ key: "q", value: appliedQuery, label: "“" + appliedQuery + "”", type: "query" });
|
|
if (similarTo) pills.push({ key: "sim", value: similarTo.id, label: "Similar to " + similarTo.style, type: "similar" });
|
|
|
|
const removePill = (p) => {
|
|
if (p.type === "facet") toggleFacet(p.key, p.value);
|
|
if (p.type === "query") setAppliedQuery("");
|
|
if (p.type === "similar") setSimilarTo(null);
|
|
};
|
|
const clearAll = () => { setFacetSel({}); setAppliedQuery(""); setSimilarTo(null); setActiveDna(null); };
|
|
|
|
/* ---- actions ---- */
|
|
const toggleSelect = (g) =>
|
|
setSelection((s) => (s.includes(g.id) ? s.filter((id) => id !== g.id) : [...s, g.id]));
|
|
|
|
const saveOne = (g) => { setSelection((s) => (s.includes(g.id) ? s : [...s, g.id])); };
|
|
|
|
const addToBoard = (board) => {
|
|
const name = board ? board.name : "Untitled board";
|
|
if (board) setPinned((p) => ({ ...p, [board.id]: [...new Set([...(p[board.id] || []), ...selection])] }));
|
|
showToast(selection.length + (selection.length === 1 ? " piece pinned to " : " pieces pinned to ") + name);
|
|
setSelection([]);
|
|
};
|
|
|
|
const findSimilar = (g) => { clearAll(); setSimilarTo(g); reshimmer(); showToast("Pieces cut from the same cloth as " + g.style); };
|
|
|
|
const commitSearch = () => { setAppliedQuery(searchDraft.trim()); setSearchOpen(false); setSimilarTo(null); reshimmer(); };
|
|
|
|
/* ---- layout ---- */
|
|
const size = t.cardSize;
|
|
const gridStyleObj = {
|
|
display: "grid",
|
|
gridTemplateColumns: "repeat(auto-fill, minmax(min(" + size + "px, 100%), 1fr))",
|
|
gridAutoFlow: "dense",
|
|
gap: "28px 22px",
|
|
};
|
|
|
|
return (
|
|
<div style={{ "--accent": t.accent }} className="min-h-screen text-ink">
|
|
<HeaderBar onSearch={() => { setSearchDraft(""); setSearchOpen(true); }} onUpload={() => setUploadOpen(true)} onBoards={() => setBoardsOpen(true)} />
|
|
<div className="max-w-[1480px] mx-auto px-5 sm:px-8">
|
|
|
|
<section className="pt-8 pb-7 flex flex-col gap-7">
|
|
<EditorialStats garments={GARMENTS} pendingCount={pendingCount} dominant={dominant} />
|
|
<DnaStrip moods={DNA_MOODS} counts={dnaCounts} active={activeDna}
|
|
onToggle={(m) => setActiveDna((cur) => (cur === m ? null : m))} />
|
|
</section>
|
|
|
|
<section className="sticky top-0 z-20 bg-paper/95 backdrop-blur-sm border-y border-line py-3 flex flex-col gap-3" data-screen-label="Filter rail">
|
|
<div className="flex items-center gap-2 overflow-x-auto">
|
|
<span className="font-mono text-[9.5px] uppercase tracking-[0.2em] text-muted shrink-0 mr-1">Refine</span>
|
|
{FACETS.map((f) => (
|
|
<FacetChip key={f.key} facet={f} options={facetOptions(f)} selected={facetSel[f.key] || []} onToggle={toggleFacet} />
|
|
))}
|
|
<span className="flex-1"></span>
|
|
<span className="font-mono text-[10px] text-muted shrink-0">{loading ? "—" : filtered.length + " of " + GARMENTS.length}</span>
|
|
</div>
|
|
<ActivePills pills={pills} onRemove={removePill} onClearAll={clearAll} />
|
|
</section>
|
|
|
|
<main className="py-9" data-screen-label="Garment grid">
|
|
<div style={gridStyleObj}>
|
|
{loading
|
|
? Array.from({ length: 8 }).map((_, i) => <SkeletonCard key={i} />)
|
|
: filtered.length === 0
|
|
? <EmptyState onClear={clearAll} onSearch={() => setSearchOpen(true)} />
|
|
: filtered.map((g) => {
|
|
const hero = t.gridStyle === "Mixed" && g.hero;
|
|
return (
|
|
<div key={g.id} style={hero ? { gridColumn: "span 2", gridRow: "span 2" } : undefined}
|
|
onClick={() => { if (selection.length) toggleSelect(g); }}>
|
|
<GarmentCard variant={t.cardVariant} g={g} hero={hero}
|
|
selected={selection.includes(g.id)} selectMode={selection.length > 0}
|
|
onToggleSelect={toggleSelect} onSave={saveOne} onSimilar={findSimilar} />
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</main>
|
|
|
|
<footer className="border-t border-line py-6 flex items-baseline justify-between">
|
|
<span className="font-mono text-[10px] uppercase tracking-[0.24em] text-muted">SAPL · Garment Intelligence</span>
|
|
<span className="font-display italic text-[14px] text-muted">the archive remembers everything</span>
|
|
</footer>
|
|
</div>
|
|
|
|
<SearchOverlay open={searchOpen} onClose={() => setSearchOpen(false)} query={searchDraft} setQuery={setSearchDraft}
|
|
resultCount={draftCount} seeds={SEARCH_SEEDS} onCommit={commitSearch} />
|
|
<SelectionTray selection={selection} garments={GARMENTS} onClear={() => setSelection([])} onAddToBoard={addToBoard} boards={BOARDS} />
|
|
<BoardsDrawer open={boardsOpen} onClose={() => setBoardsOpen(false)} boards={BOARDS} pinned={pinned} garments={GARMENTS} />
|
|
<UploadModal open={uploadOpen} onClose={() => setUploadOpen(false)} />
|
|
<Toast toast={toast} />
|
|
|
|
<TweaksPanel>
|
|
<TweakSection label="Garment card" />
|
|
<TweakSelect label="Card design" value={t.cardVariant}
|
|
options={["Gallery", "Index plate", "Lookbook"]}
|
|
onChange={(v) => setTweak("cardVariant", v)} />
|
|
<TweakSection label="Grid" />
|
|
<TweakRadio label="Rhythm" value={t.gridStyle} options={["Mixed", "Uniform"]}
|
|
onChange={(v) => setTweak("gridStyle", v)} />
|
|
<TweakSlider label="Card size" value={t.cardSize} min={200} max={330} step={10} unit="px"
|
|
onChange={(v) => setTweak("cardSize", v)} />
|
|
<TweakSection label="Accent" />
|
|
<TweakColor label="Accent" value={t.accent} options={["#9a7322", "#7a2e26", "#3d4f6b", "#3f6049"]}
|
|
onChange={(v) => setTweak("accent", v)} />
|
|
</TweaksPanel>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
ReactDOM.createRoot(document.getElementById("root")).render(<App />);
|