Files
sapl/docs/Design library/app.jsx
T

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 />);