| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; |
| import { createPortal } from "react-dom"; |
| import { |
| CATALOG_COLS, |
| CATALOG_NAME_MAX, |
| CATALOG_ORIENTATIONS, |
| CATALOG_PAGE_KINDS, |
| CATALOG_PAPERS, |
| MAX_CATALOGS, |
| MAX_CATALOG_PAGES, |
| MAX_CATALOG_CODES, |
| } from "./types"; |
| import type { |
| CatalogOrientation, |
| CatalogPage, |
| CatalogPageKind, |
| CatalogPaper, |
| CatalogQuality, |
| CatalogSpec, |
| Field, |
| Row, |
| } from "./types"; |
| import { |
| CATALOG_CODE_FIELD, |
| DEFAULT_BRAND, |
| EDITORIAL_PREFIX, |
| PAGE_KIND_LABELS, |
| PAPER_LABELS, |
| addCodes, |
| assetUrl, |
| autoBind, |
| codesUsed, |
| editorialSlug, |
| fetchAssetCodes, |
| fetchEditorialSlugs, |
| indexByCode, |
| newCatalog, |
| newPage, |
| pageBox, |
| resolveItems, |
| sectionLayout, |
| slugFromFilename, |
| uploadEditorial, |
| } from "./catalogData"; |
| import type { CatalogItem } from "./catalogData"; |
| |
| |
| import { RECORD_IMAGE_PREFIX } from "./types"; |
| import { applyPrintCss, awaitImages, printOnce } from "./catalogPrint"; |
| import { formatDisplay } from "./display"; |
|
|
| |
|
|
| const STROKE = { |
| stroke: "currentColor", |
| strokeWidth: 1.5, |
| strokeLinecap: "round" as const, |
| strokeLinejoin: "round" as const, |
| fill: "none", |
| }; |
|
|
| function IconPlus() { |
| return ( |
| <svg width="13" height="13" viewBox="0 0 16 16" aria-hidden> |
| <path d="M8 3.4v9.2M3.4 8h9.2" {...STROKE} /> |
| </svg> |
| ); |
| } |
| function IconTrash() { |
| return ( |
| <svg width="13" height="13" viewBox="0 0 16 16" aria-hidden> |
| <path d="M3.4 4.6h9.2M6.4 4.6V3.2h3.2v1.4M4.8 4.6l.6 8h5.2l.6-8" {...STROKE} /> |
| </svg> |
| ); |
| } |
| function IconUp() { |
| return ( |
| <svg width="13" height="13" viewBox="0 0 16 16" aria-hidden> |
| <path d="M4.4 9.6L8 6l3.6 3.6" {...STROKE} /> |
| </svg> |
| ); |
| } |
| function IconDown() { |
| return ( |
| <svg width="13" height="13" viewBox="0 0 16 16" aria-hidden> |
| <path d="M4.4 6.4L8 10l3.6-3.6" {...STROKE} /> |
| </svg> |
| ); |
| } |
| function IconPrint() { |
| return ( |
| <svg width="14" height="14" viewBox="0 0 16 16" aria-hidden> |
| <path d="M4.6 6.4V2.8h6.8v3.6" {...STROKE} /> |
| <path d="M2.8 6.4h10.4v4.4H11v2.4H5v-2.4H2.8z" {...STROKE} /> |
| <path d="M5 10.8h6" {...STROKE} /> |
| </svg> |
| ); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| function CrownMark({ className }: { className?: string }) { |
| return ( |
| <svg className={className} viewBox="0 0 44 26" aria-hidden> |
| <g fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinejoin="round"> |
| <path d="M11 4.5l6.5 6.5L11 17.5 4.5 11z" /> |
| <path d="M22 2.5l7.5 7.5L22 17.5 14.5 10z" /> |
| <path d="M33 4.5L39.5 11 33 17.5 26.5 11z" /> |
| </g> |
| <rect x="4" y="20" width="36" height="2.6" fill="currentColor" /> |
| </svg> |
| ); |
| } |
|
|
| |
|
|
| |
| |
| |
| |
| |
| function Shot({ |
| code, |
| quality, |
| missing, |
| cover, |
| }: { |
| code?: string; |
| quality: CatalogQuality; |
| missing?: boolean; |
| cover?: boolean; |
| }) { |
| if (!code) |
| return ( |
| <div className="cg-cat-shot cg-cat-shot-empty"> |
| <span>No image</span> |
| </div> |
| ); |
| if (missing) |
| return ( |
| <div className="cg-cat-shot cg-cat-shot-empty"> |
| <span> |
| {editorialSlug(code) !== null |
| ? `No editorial image named ${editorialSlug(code)}` |
| : `No master for ${code}`} |
| </span> |
| </div> |
| ); |
| return ( |
| <div className={cover ? "cg-cat-shot cg-cat-shot-cover" : "cg-cat-shot"}> |
| <img src={assetUrl(code, quality)} alt="" loading="eager" /> |
| </div> |
| ); |
| } |
|
|
| function ProductCard({ |
| item, |
| quality, |
| layout, |
| hasAsset, |
| }: { |
| item: CatalogItem; |
| quality: CatalogQuality; |
| layout: ReturnType<typeof sectionLayout>; |
| hasAsset: (code: string) => boolean | null; |
| }) { |
| |
| |
| |
| const known = hasAsset(item.image); |
| return ( |
| <div className="cg-cat-card"> |
| <Shot code={item.image} quality={quality} missing={known === false} /> |
| {/* CAPS in CSS (`text-transform`), never by upper-casing the string: the |
| value stays the value, so a copy of it is not shouting. */} |
| <div className="cg-cat-desc">{item.name ?? (item.known ? "" : "Not in this table")}</div> |
| {layout.showPack && item.pack ? <div className="cg-cat-pill">{item.pack}</div> : null} |
| <div className="cg-cat-meta"> |
| <span className="cg-cat-sku">{item.code}</span> |
| {layout.showColor && item.color ? ( |
| <span className="cg-cat-color">{item.color}</span> |
| ) : null} |
| </div> |
| {layout.showPrice && item.price ? <div className="cg-cat-price">{item.price}</div> : null} |
| </div> |
| ); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| function PageFooter({ |
| folio, |
| light, |
| mark = true, |
| }: { |
| folio: number; |
| light?: boolean; |
| mark?: boolean; |
| }) { |
| return ( |
| <div className={light ? "cg-cat-foot cg-cat-foot-light" : "cg-cat-foot"}> |
| {mark ? <CrownMark className="cg-cat-foot-mark" /> : null} |
| <span className="cg-cat-folio">{folio}</span> |
| </div> |
| ); |
| } |
|
|
| |
| |
| |
| const CatalogPageView = memo(function CatalogPageView({ |
| cat, |
| page, |
| index, |
| items, |
| quality, |
| hasAsset, |
| }: { |
| cat: CatalogSpec; |
| page: CatalogPage; |
| index: number; |
| items: CatalogItem[]; |
| quality: CatalogQuality; |
| hasAsset: (code: string) => boolean | null; |
| }) { |
| const box = pageBox(cat.paper, cat.orientation); |
| const px = box.unit === "in" ? 96 : 96 / 25.4; |
| const brand = cat.brand ?? {}; |
| const primary = brand.primary ?? DEFAULT_BRAND.primary; |
| const accent = brand.accent ?? DEFAULT_BRAND.accent; |
| const company = brand.company ?? DEFAULT_BRAND.company; |
| const style = { |
| "--cat-w": `${box.w * px}px`, |
| "--cat-h": `${box.h * px}px`, |
| "--cat-primary": primary, |
| "--cat-accent": accent, |
| } as React.CSSProperties; |
| const folio = index + 1; |
| const logoKnown = brand.logo ? hasAsset(brand.logo) : null; |
|
|
| const Wordmark = ( |
| <div className="cg-cat-mark"> |
| {brand.logo && logoKnown !== false ? ( |
| <img src={assetUrl(brand.logo, quality)} alt="" /> |
| ) : ( |
| <CrownMark className="cg-cat-mark-crown" /> |
| )} |
| <span className="cg-cat-mark-co">{company}</span> |
| </div> |
| ); |
|
|
| if (page.kind === "cover") { |
| const known = page.imageCode ? hasAsset(page.imageCode) : null; |
| return ( |
| <div className="cg-cat-page cg-cat-p-cover" style={style} data-kind="cover"> |
| <Shot code={page.imageCode} quality={quality} missing={known === false} cover /> |
| <div className="cg-cat-cover-scrim" /> |
| {Wordmark} |
| <div className="cg-cat-cover-ttl"> |
| <span className="cg-cat-cover-a">{page.title}</span> |
| {page.body ? <span className="cg-cat-cover-b">{page.body}</span> : null} |
| </div> |
| </div> |
| ); |
| } |
|
|
| if (page.kind === "intro") { |
| return ( |
| <div className="cg-cat-page cg-cat-p-intro" style={style} data-kind="intro"> |
| <div className="cg-cat-intro-field"> |
| {/* The terracotta lattice bleeding off the left edge — the reference's |
| own device for the letter spread, drawn from the crown's diamond. */} |
| <svg className="cg-cat-lattice" viewBox="0 0 200 260" aria-hidden> |
| <g fill="none" stroke="currentColor" strokeWidth="7"> |
| <path d="M-40 130l60-60 60 60-60 60z" /> |
| <path d="M40 190l60-60 60 60-60 60z" /> |
| <path d="M100 130l40 40 40-90" /> |
| </g> |
| </svg> |
| </div> |
| <div className="cg-cat-intro-body"> |
| <h2 className="cg-cat-intro-ttl">{page.title}</h2> |
| {/* Paragraphs, not a wall: the reference's letter is set in short |
| blocks and reads as one because of the spacing between them. */} |
| {(page.body ?? "").split(/\n{2,}/).map((para, i) => |
| para.trim() ? ( |
| <p key={i} className="cg-cat-intro-p"> |
| {para.trim()} |
| </p> |
| ) : null |
| )} |
| <div className="cg-cat-intro-sign">The {company} Team</div> |
| </div> |
| <PageFooter folio={folio} light mark={false} /> |
| </div> |
| ); |
| } |
|
|
| if (page.kind === "gallery") { |
| const known = page.imageCode ? hasAsset(page.imageCode) : null; |
| return ( |
| <div className="cg-cat-page cg-cat-p-gallery" style={style} data-kind="gallery"> |
| <Shot code={page.imageCode} quality={quality} missing={known === false} cover /> |
| <div className="cg-cat-gal-wash" /> |
| {page.title ? <div className="cg-cat-gal-ttl">{page.title}</div> : null} |
| <PageFooter folio={folio} light /> |
| </div> |
| ); |
| } |
|
|
| const layout = sectionLayout(page); |
| const stripKnown = page.imageCode ? hasAsset(page.imageCode) : null; |
| return ( |
| <div |
| className={`cg-cat-page cg-cat-p-section${page.imageCode ? " has-strip" : ""}`} |
| style={style} |
| data-kind="section" |
| > |
| <h2 className="cg-cat-sec-ttl">{page.title}</h2> |
| <div className="cg-cat-grid" style={{ "--cat-cols": layout.cols } as React.CSSProperties}> |
| {items.map((item) => ( |
| <ProductCard |
| key={item.code} |
| item={item} |
| quality={quality} |
| layout={layout} |
| hasAsset={hasAsset} |
| /> |
| ))} |
| </div> |
| {page.imageCode ? ( |
| <div className="cg-cat-strip"> |
| <Shot code={page.imageCode} quality={quality} missing={stripKnown === false} cover /> |
| </div> |
| ) : null} |
| <PageFooter folio={folio} /> |
| </div> |
| ); |
| }); |
|
|
| |
|
|
| function CatalogPages({ |
| cat, |
| itemsFor, |
| quality, |
| hasAsset, |
| scale, |
| selectedId, |
| onSelect, |
| }: { |
| cat: CatalogSpec; |
| itemsFor: (page: CatalogPage) => CatalogItem[]; |
| quality: CatalogQuality; |
| hasAsset: (code: string) => boolean | null; |
| /** Absent = paint at true size (the PRINT tree). */ |
| scale?: number; |
| selectedId?: string; |
| onSelect?: (id: string) => void; |
| }) { |
| const box = pageBox(cat.paper, cat.orientation); |
| const px = box.unit === "in" ? 96 : 96 / 25.4; |
| return ( |
| <> |
| {cat.pages.map((page, i) => { |
| const body = ( |
| <CatalogPageView |
| cat={cat} |
| page={page} |
| index={i} |
| items={itemsFor(page)} |
| quality={quality} |
| hasAsset={hasAsset} |
| /> |
| ); |
| if (scale === undefined) return <div key={page.id}>{body}</div>; |
| return ( |
| <div |
| key={page.id} |
| className={`cg-cat-slot${selectedId === page.id ? " is-sel" : ""}`} |
| style={{ |
| width: box.w * px * scale, |
| height: box.h * px * scale, |
| "--cat-scale": scale, |
| } as React.CSSProperties} |
| onMouseDown={() => onSelect?.(page.id)} |
| > |
| {body} |
| </div> |
| ); |
| })} |
| </> |
| ); |
| } |
|
|
| |
|
|
| export const CatalogView = memo(function CatalogView({ |
| catalogs, |
| onCatalogs, |
| fields, |
| fieldByKey, |
| poolRows, |
| filteredCodes, |
| }: { |
| catalogs: CatalogSpec[]; |
| onCatalogs: (next: CatalogSpec[]) => void; |
| fields: Field[]; |
| fieldByKey: Map<string, Field>; |
| /** The WHOLE pool — a catalogue keeps painting what it names after the view |
| * is filtered, so the join must not run over the filtered rows. */ |
| poolRows: Row[]; |
| /** The codes the CURRENT filter kept — the "add what the view shows" door. */ |
| filteredCodes: string[]; |
| }) { |
| const [openId, setOpenId] = useState<string | null>(catalogs[0]?.id ?? null); |
| const [pageId, setPageId] = useState<string | null>(null); |
| const [search, setSearch] = useState(""); |
| const [picked, setPicked] = useState<Set<string>>(() => new Set()); |
| const [assetCodes, setAssetCodes] = useState<Set<string> | null>(null); |
| const [editorialSlugs, setEditorialSlugs] = useState<Set<string> | null>(null); |
| const edFileRef = useRef<HTMLInputElement | null>(null); |
| const [printing, setPrinting] = useState<{ cat: CatalogSpec; quality: CatalogQuality } | null>( |
| null |
| ); |
| const [askQuality, setAskQuality] = useState<CatalogSpec | null>(null); |
| const stageRef = useRef<HTMLDivElement | null>(null); |
| const printRef = useRef<HTMLDivElement | null>(null); |
| const [stageW, setStageW] = useState(0); |
|
|
| const cat = catalogs.find((c) => c.id === openId) ?? null; |
| const page = cat?.pages.find((p) => p.id === pageId) ?? null; |
|
|
| |
| |
| useEffect(() => { |
| let live = true; |
| void fetchAssetCodes().then((codes) => { |
| if (live) setAssetCodes(codes); |
| }); |
| void fetchEditorialSlugs().then((slugs) => { |
| if (live) setEditorialSlugs(slugs); |
| }); |
| return () => { |
| live = false; |
| }; |
| }, []); |
|
|
| const hasAsset = useCallback( |
| (code: string): boolean | null => { |
| |
| |
| |
| const slug = editorialSlug(code); |
| if (slug !== null) return editorialSlugs ? editorialSlugs.has(slug) : null; |
| |
| |
| |
| |
| |
| if (code.startsWith(RECORD_IMAGE_PREFIX)) return null; |
| return assetCodes ? assetCodes.has(code) : null; |
| }, |
| [assetCodes, editorialSlugs] |
| ); |
|
|
| useLayoutEffect(() => { |
| const el = stageRef.current; |
| if (!el || typeof ResizeObserver === "undefined") return; |
| const ro = new ResizeObserver(() => setStageW(el.clientWidth)); |
| ro.observe(el); |
| setStageW(el.clientWidth); |
| return () => ro.disconnect(); |
| }, []); |
|
|
| const byCode = useMemo(() => indexByCode(poolRows), [poolRows]); |
| const itemsFor = useCallback( |
| (p: CatalogPage) => |
| resolveItems(p.products ?? [], byCode, cat?.fields, formatDisplay, fieldByKey), |
| [byCode, cat?.fields, fieldByKey] |
| ); |
|
|
| const scale = useMemo(() => { |
| if (!cat || !stageW) return 0.5; |
| const box = pageBox(cat.paper, cat.orientation); |
| const px = box.unit === "in" ? 96 : 96 / 25.4; |
| return Math.min(1, Math.max(0.2, (stageW - 56) / (box.w * px))); |
| }, [cat, stageW]); |
|
|
| |
| const write = useCallback( |
| (next: CatalogSpec[]) => { |
| onCatalogs(next); |
| }, |
| [onCatalogs] |
| ); |
| const patchCat = useCallback( |
| (id: string, patch: Partial<CatalogSpec>) => { |
| write(catalogs.map((c) => (c.id === id ? { ...c, ...patch } : c))); |
| }, |
| [catalogs, write] |
| ); |
| const patchPage = useCallback( |
| (catId: string, pgId: string, patch: Partial<CatalogPage>) => { |
| write( |
| catalogs.map((c) => |
| c.id !== catId |
| ? c |
| : { ...c, pages: c.pages.map((p) => (p.id === pgId ? { ...p, ...patch } : p)) } |
| ) |
| ); |
| }, |
| [catalogs, write] |
| ); |
|
|
| const addCatalog = () => { |
| if (catalogs.length >= MAX_CATALOGS) return; |
| const made = newCatalog(`Catalog ${catalogs.length + 1}`, fields); |
| write([...catalogs, made]); |
| setOpenId(made.id); |
| setPageId(made.pages[0]?.id ?? null); |
| }; |
|
|
| const addPage = (kind: CatalogPageKind) => { |
| if (!cat || cat.pages.length >= MAX_CATALOG_PAGES) return; |
| const made = newPage(kind); |
| patchCat(cat.id, { pages: [...cat.pages, made] }); |
| setPageId(made.id); |
| }; |
|
|
| const movePage = (delta: number) => { |
| if (!cat || !page) return; |
| const i = cat.pages.findIndex((p) => p.id === page.id); |
| const j = i + delta; |
| if (i < 0 || j < 0 || j >= cat.pages.length) return; |
| const pages = [...cat.pages]; |
| [pages[i], pages[j]] = [pages[j], pages[i]]; |
| patchCat(cat.id, { pages }); |
| }; |
|
|
| |
| const matches = useMemo(() => { |
| if (!cat) return []; |
| const q = search.trim().toLowerCase(); |
| const nameKey = cat.fields?.name; |
| const out: { code: string; label: string }[] = []; |
| for (const r of poolRows) { |
| const code = r[CATALOG_CODE_FIELD]; |
| if (typeof code !== "string" || !code) continue; |
| const nameField = nameKey ? fieldByKey.get(nameKey) : undefined; |
| const label = nameField ? formatDisplay(nameField, r[nameKey as string]) : ""; |
| if (q && !code.toLowerCase().includes(q) && !label.toLowerCase().includes(q)) continue; |
| out.push({ code, label }); |
| if (out.length >= 200) break; |
| } |
| return out; |
| }, [cat, poolRows, search, fieldByKey]); |
|
|
| const commitCodes = (codes: string[]) => { |
| if (!cat || !page) return; |
| const res = addCodes(cat, page.id, codes); |
| patchCat(cat.id, { pages: res.pages }); |
| setPicked(new Set()); |
| if (res.skippedCap || res.skippedDuplicate) { |
| const parts: string[] = []; |
| if (res.skippedDuplicate) parts.push(`${res.skippedDuplicate} already on the page`); |
| if (res.skippedCap) |
| parts.push(`${res.skippedCap} over the ${MAX_CATALOG_CODES}-product catalog limit`); |
| setNote(`Added ${res.added}. Skipped ${parts.join("; ")}.`); |
| } else { |
| setNote(`Added ${res.added}.`); |
| } |
| }; |
| const [note, setNote] = useState(""); |
|
|
| |
| |
| const uploadEditorialFile = async (f: File) => { |
| const slug = slugFromFilename(f.name); |
| if (!slug) { |
| setNote("That filename does not reduce to a usable name — rename the file first."); |
| return; |
| } |
| setNote(`Uploading ${slug}…`); |
| try { |
| const stored = await uploadEditorial(slug, f); |
| setEditorialSlugs(await fetchEditorialSlugs()); |
| if (cat && page) patchPage(cat.id, page.id, { imageCode: `${EDITORIAL_PREFIX}${stored}` }); |
| setNote(`Editorial image "${stored}" stored and bound to this page.`); |
| } catch (e) { |
| setNote(e instanceof Error ? e.message : "The upload failed."); |
| } |
| }; |
|
|
| |
| useEffect(() => { |
| if (!printing) return; |
| let cancelled = false; |
| void (async () => { |
| |
| await new Promise<void>((res) => requestAnimationFrame(() => res())); |
| await awaitImages(printRef.current); |
| if (cancelled) return; |
| const drop = applyPrintCss(printing.cat); |
| await printOnce(); |
| drop(); |
| if (!cancelled) setPrinting(null); |
| })(); |
| return () => { |
| cancelled = true; |
| }; |
| }, [printing]); |
|
|
| |
| if (!catalogs.length) |
| return ( |
| <div className="cg-cat cg-cat-blank"> |
| <div className="cg-cat-blank-in"> |
| <CrownMark className="cg-cat-blank-mark" /> |
| <h3>No catalogs yet</h3> |
| <p> |
| A catalog is a printable document built from this table’s products — cover, |
| introduction, product sections and full-page images. Print it to PDF from the browser |
| when it is ready. |
| </p> |
| <button type="button" className="cg-btn cg-btn--primary" onClick={addCatalog}> |
| <IconPlus /> New catalog |
| </button> |
| </div> |
| </div> |
| ); |
|
|
| const used = cat ? codesUsed(cat) : 0; |
|
|
| return ( |
| <div className="cg-cat"> |
| {/* ------------------------------------------------------------ rail */} |
| <aside className="cg-cat-rail"> |
| <div className="cg-cat-rail-head"> |
| <span>Catalogs</span> |
| <button |
| type="button" |
| className="cg-cat-icon-btn" |
| title={ |
| catalogs.length >= MAX_CATALOGS |
| ? `A view holds ${MAX_CATALOGS} catalogs.` |
| : "New catalog" |
| } |
| aria-label="New catalog" |
| disabled={catalogs.length >= MAX_CATALOGS} |
| onClick={addCatalog} |
| > |
| <IconPlus /> |
| </button> |
| </div> |
| <ul className="cg-cat-list"> |
| {catalogs.map((c) => ( |
| <li key={c.id}> |
| <button |
| type="button" |
| className={`cg-cat-item${c.id === openId ? " is-open" : ""}`} |
| onClick={() => { |
| setOpenId(c.id); |
| setPageId(c.pages[0]?.id ?? null); |
| }} |
| > |
| <span className="cg-cat-item-name">{c.name || "Untitled catalog"}</span> |
| <span className="cg-cat-item-meta"> |
| {c.pages.length} {c.pages.length === 1 ? "page" : "pages"} |
| </span> |
| </button> |
| </li> |
| ))} |
| </ul> |
| {cat ? ( |
| <> |
| <div className="cg-cat-rail-head cg-cat-rail-sub"> |
| <span>Pages</span> |
| <span className="cg-cat-count"> |
| {cat.pages.length}/{MAX_CATALOG_PAGES} |
| </span> |
| </div> |
| <ul className="cg-cat-pagelist"> |
| {cat.pages.map((p, i) => ( |
| <li key={p.id}> |
| <button |
| type="button" |
| className={`cg-cat-pageitem${p.id === pageId ? " is-sel" : ""}`} |
| onClick={() => setPageId(p.id)} |
| > |
| <span className="cg-cat-pageno">{i + 1}</span> |
| <span className="cg-cat-pagettl"> |
| {p.title || PAGE_KIND_LABELS[p.kind]} |
| </span> |
| <span className="cg-cat-pagekind">{PAGE_KIND_LABELS[p.kind]}</span> |
| </button> |
| </li> |
| ))} |
| </ul> |
| <div className="cg-cat-addrow"> |
| {CATALOG_PAGE_KINDS.map((k) => ( |
| <button |
| key={k} |
| type="button" |
| className="cg-btn" |
| disabled={cat.pages.length >= MAX_CATALOG_PAGES} |
| onClick={() => addPage(k)} |
| > |
| <IconPlus /> {PAGE_KIND_LABELS[k]} |
| </button> |
| ))} |
| </div> |
| </> |
| ) : null} |
| </aside> |
|
|
| {} |
| <div className="cg-cat-stage" ref={stageRef}> |
| {cat ? ( |
| <> |
| <div className="cg-cat-bar"> |
| <input |
| className="cg-input cg-cat-name" |
| value={cat.name} |
| maxLength={CATALOG_NAME_MAX} |
| aria-label="Catalog name" |
| onChange={(e) => patchCat(cat.id, { name: e.target.value })} |
| /> |
| <select |
| className="cg-select" |
| aria-label="Paper size" |
| value={cat.paper} |
| onChange={(e) => patchCat(cat.id, { paper: e.target.value as CatalogPaper })} |
| > |
| {CATALOG_PAPERS.map((p) => ( |
| <option key={p} value={p}> |
| {PAPER_LABELS[p]} |
| </option> |
| ))} |
| </select> |
| <select |
| className="cg-select" |
| aria-label="Orientation" |
| value={cat.orientation} |
| onChange={(e) => |
| patchCat(cat.id, { orientation: e.target.value as CatalogOrientation }) |
| } |
| > |
| {CATALOG_ORIENTATIONS.map((o) => ( |
| <option key={o} value={o}> |
| {o === "portrait" ? "Portrait" : "Landscape"} |
| </option> |
| ))} |
| </select> |
| <span className="cg-cat-budget" title="Products placed across this catalog's pages"> |
| {used}/{MAX_CATALOG_CODES} products |
| </span> |
| <button |
| type="button" |
| className="cg-btn cg-btn--primary cg-cat-print" |
| onClick={() => setAskQuality(cat)} |
| > |
| <IconPrint /> Print |
| </button> |
| </div> |
| <div className="cg-cat-scroll"> |
| <CatalogPages |
| cat={cat} |
| itemsFor={itemsFor} |
| quality="web" |
| hasAsset={hasAsset} |
| scale={scale} |
| selectedId={pageId ?? undefined} |
| onSelect={setPageId} |
| /> |
| </div> |
| </> |
| ) : ( |
| <div className="cg-mode-empty">Pick a catalog to open it.</div> |
| )} |
| </div> |
|
|
| {} |
| <aside className="cg-cat-insp"> |
| {cat && page ? ( |
| <> |
| <div className="cg-cat-insp-head"> |
| <span>{PAGE_KIND_LABELS[page.kind]}</span> |
| <span className="cg-cat-insp-tools"> |
| <button |
| type="button" |
| className="cg-cat-icon-btn" |
| aria-label="Move page up" |
| onClick={() => movePage(-1)} |
| > |
| <IconUp /> |
| </button> |
| <button |
| type="button" |
| className="cg-cat-icon-btn" |
| aria-label="Move page down" |
| onClick={() => movePage(1)} |
| > |
| <IconDown /> |
| </button> |
| <button |
| type="button" |
| className="cg-cat-icon-btn cg-cat-del" |
| aria-label="Delete page" |
| onClick={() => |
| patchCat(cat.id, { pages: cat.pages.filter((p) => p.id !== page.id) }) |
| } |
| > |
| <IconTrash /> |
| </button> |
| </span> |
| </div> |
| |
| <label className="cg-cat-fld"> |
| <span>Title</span> |
| <input |
| className="cg-input" |
| value={page.title ?? ""} |
| onChange={(e) => patchPage(cat.id, page.id, { title: e.target.value })} |
| /> |
| </label> |
| |
| {page.kind === "cover" || page.kind === "intro" ? ( |
| <label className="cg-cat-fld"> |
| <span>{page.kind === "cover" ? "Second line" : "Body"}</span> |
| <textarea |
| className="cg-input cg-cat-area" |
| rows={page.kind === "cover" ? 2 : 9} |
| value={page.body ?? ""} |
| onChange={(e) => patchPage(cat.id, page.id, { body: e.target.value })} |
| /> |
| </label> |
| ) : null} |
| |
| {page.kind !== "intro" ? ( |
| <> |
| <label className="cg-cat-fld"> |
| <span>{page.kind === "section" ? "Footer image (code)" : "Image (code)"}</span> |
| <input |
| className="cg-input" |
| value={page.imageCode ?? ""} |
| placeholder="e.g. CRY-2003 or ed:showroom" |
| onChange={(e) => patchPage(cat.id, page.id, { imageCode: e.target.value })} |
| /> |
| {page.imageCode && hasAsset(page.imageCode) === false ? ( |
| <em className="cg-cat-warn"> |
| {editorialSlug(page.imageCode) !== null |
| ? "No editorial image is stored under this name — the page will print an empty frame." |
| : "No image is archived under this code — the page will print an empty frame."} |
| </em> |
| ) : ( |
| <em className="cg-cat-hint"> |
| Fills the width edge to edge, so a wide photograph reads best here. A cut-out |
| product render will be cropped and enlarged to fill it. |
| </em> |
| )} |
| </label> |
| <label className="cg-cat-fld"> |
| <span>Editorial library</span> |
| <select |
| className="cg-select" |
| value={editorialSlug(page.imageCode ?? "") ?? ""} |
| onChange={(e) => |
| patchPage(cat.id, page.id, { |
| imageCode: e.target.value ? `${EDITORIAL_PREFIX}${e.target.value}` : "", |
| }) |
| } |
| > |
| <option value="">(use a product code above)</option> |
| {[...(editorialSlugs ?? [])].sort().map((s) => ( |
| <option key={s} value={s}> |
| {s} |
| </option> |
| ))} |
| </select> |
| <button |
| type="button" |
| className="cg-btn" |
| onClick={() => edFileRef.current?.click()} |
| > |
| Upload a photograph… |
| </button> |
| <input |
| ref={edFileRef} |
| type="file" |
| accept="image/png,image/jpeg" |
| style={{ display: "none" }} |
| onChange={(e) => { |
| const f = e.target.files?.[0]; |
| if (f) void uploadEditorialFile(f); |
| e.target.value = ""; |
| }} |
| /> |
| <em className="cg-cat-hint"> |
| Lifestyle photography without a SKU lives here. Storing a new one needs an |
| admin login; everyone can pick from the library. |
| </em> |
| </label> |
| </> |
| ) : null} |
|
|
| {page.kind === "section" ? ( |
| <> |
| <div className="cg-cat-insp-sub">Layout</div> |
| <div className="cg-cat-row"> |
| <label className="cg-cat-fld cg-cat-fld-inline"> |
| <span>Columns</span> |
| <select |
| className="cg-select" |
| value={sectionLayout(page).cols} |
| onChange={(e) => |
| patchPage(cat.id, page.id, { |
| layout: { ...page.layout, cols: Number(e.target.value) }, |
| }) |
| } |
| > |
| {CATALOG_COLS.map((c) => ( |
| <option key={c} value={c}> |
| {c} |
| </option> |
| ))} |
| </select> |
| </label> |
| </div> |
| {( |
| [ |
| ["showPack", "Pack line", false], |
| ["showColor", "Colour", false], |
| ["showPrice", "Price", true], |
| ] as const |
| ).map(([key, label, optIn]) => { |
| const l = sectionLayout(page); |
| const on = key === "showPrice" ? l.showPrice : key === "showPack" ? l.showPack : l.showColor; |
| return ( |
| <label key={key} className="cg-check-row"> |
| <input |
| type="checkbox" |
| checked={on} |
| onChange={() => { |
| // Literal-only storage: the toggle writes the opt-out (or the |
| // opt-in) and DELETES the key to go back to the default, so the |
| // default has exactly one spelling on the wire. |
| const next = { ...page.layout }; |
| if (optIn) { |
| if (on) delete next.showPrice; |
| else next.showPrice = true; |
| } else if (key === "showPack") { |
| if (on) next.showPack = false; |
| else delete next.showPack; |
| } else { |
| if (on) next.showColor = false; |
| else delete next.showColor; |
| } |
| patchPage(cat.id, page.id, { |
| layout: Object.keys(next).length ? next : undefined, |
| }); |
| }} |
| /> |
| <span>{label}</span> |
| </label> |
| ); |
| })} |
| |
| <div className="cg-cat-insp-sub"> |
| Products |
| <span className="cg-cat-count">{page.products?.length ?? 0} on this page</span> |
| </div> |
| <div className="cg-cat-picked"> |
| {(page.products ?? []).map((code) => ( |
| <span key={code} className="cg-cat-chip"> |
| {code} |
| <button |
| type="button" |
| aria-label={`Remove ${code}`} |
| onClick={() => |
| patchPage(cat.id, page.id, { |
| products: (page.products ?? []).filter((c) => c !== code), |
| }) |
| } |
| > |
| × |
| </button> |
| </span> |
| ))} |
| </div> |
| <button |
| type="button" |
| className="cg-btn cg-cat-addfiltered" |
| disabled={!filteredCodes.length} |
| onClick={() => commitCodes(filteredCodes)} |
| > |
| <IconPlus /> Add the {filteredCodes.length} products this view shows |
| </button> |
| <input |
| className="cg-input" |
| placeholder="Search products by SKU or name" |
| value={search} |
| onChange={(e) => setSearch(e.target.value)} |
| /> |
| <div className="cg-cat-results"> |
| {matches.map((m) => ( |
| <label key={m.code} className="cg-check-row"> |
| <input |
| type="checkbox" |
| checked={picked.has(m.code)} |
| onChange={() => |
| setPicked((prev) => { |
| const next = new Set(prev); |
| if (next.has(m.code)) next.delete(m.code); |
| else next.add(m.code); |
| return next; |
| }) |
| } |
| /> |
| <span className="cg-cat-res-code">{m.code}</span> |
| <span className="cg-cat-res-name">{m.label}</span> |
| </label> |
| ))} |
| {/* The 200 is STATED, never silent: a search that stops early has |
| to say so or the user reads absence as "we do not stock it". */} |
| {matches.length >= 200 ? ( |
| <div className="cg-cat-more"> |
| Showing the first 200 matches — narrow the search to see the rest. |
| </div> |
| ) : null} |
| </div> |
| <button |
| type="button" |
| className="cg-btn cg-btn--primary" |
| disabled={!picked.size} |
| onClick={() => commitCodes([...picked])} |
| > |
| Add {picked.size} selected |
| </button> |
| {note ? <div className="cg-cat-note">{note}</div> : null} |
| </> |
| ) : null} |
|
|
| <div className="cg-cat-insp-sub">Brand</div> |
| <div className="cg-cat-row"> |
| <label className="cg-cat-fld cg-cat-fld-inline"> |
| <span>Primary</span> |
| <input |
| type="color" |
| className="cg-cat-swatch" |
| value={cat.brand?.primary ?? DEFAULT_BRAND.primary} |
| onChange={(e) => |
| patchCat(cat.id, { brand: { ...cat.brand, primary: e.target.value } }) |
| } |
| /> |
| </label> |
| <label className="cg-cat-fld cg-cat-fld-inline"> |
| <span>Accent</span> |
| <input |
| type="color" |
| className="cg-cat-swatch" |
| value={cat.brand?.accent ?? DEFAULT_BRAND.accent} |
| onChange={(e) => |
| patchCat(cat.id, { brand: { ...cat.brand, accent: e.target.value } }) |
| } |
| /> |
| </label> |
| </div> |
| <label className="cg-cat-fld"> |
| <span>Company</span> |
| <input |
| className="cg-input" |
| value={cat.brand?.company ?? ""} |
| placeholder={DEFAULT_BRAND.company} |
| onChange={(e) => |
| patchCat(cat.id, { brand: { ...cat.brand, company: e.target.value } }) |
| } |
| /> |
| </label> |
|
|
| <div className="cg-cat-insp-sub">Listing columns</div> |
| {( |
| [ |
| ["name", "Description"], |
| ["pack", "Pack line"], |
| ["color", "Colour"], |
| ["price", "Price"], |
| ] as const |
| ).map(([slot, label]) => ( |
| <label key={slot} className="cg-cat-fld cg-cat-fld-inline"> |
| <span>{label}</span> |
| <select |
| className="cg-select" |
| value={cat.fields?.[slot] ?? ""} |
| onChange={(e) => { |
| const next = { ...cat.fields }; |
| if (e.target.value) next[slot] = e.target.value; |
| else delete next[slot]; |
| patchCat(cat.id, { fields: Object.keys(next).length ? next : undefined }); |
| }} |
| > |
| {/* An explicit empty option — the wave-7 trap: a <select> whose value |
| is not among its options renders the FIRST one, so "unbound" needs a |
| row of its own rather than being the absence of one. */} |
| <option value="">Not shown</option> |
| {fields.map((f) => ( |
| <option key={f.key} value={f.key}> |
| {f.label} |
| </option> |
| ))} |
| </select> |
| </label> |
| ))} |
| <button |
| type="button" |
| className="cg-btn" |
| onClick={() => { |
| const guess = autoBind(fields); |
| patchCat(cat.id, { fields: Object.keys(guess).length ? guess : undefined }); |
| }} |
| > |
| Guess from this table |
| </button> |
| <div className="cg-cat-insp-foot"> |
| <button |
| type="button" |
| className="cg-btn cg-btn--danger" |
| onClick={() => { |
| const next = catalogs.filter((c) => c.id !== cat.id); |
| write(next); |
| setOpenId(next[0]?.id ?? null); |
| setPageId(null); |
| }} |
| > |
| <IconTrash /> Delete this catalog |
| </button> |
| </div> |
| </> |
| ) : ( |
| <div className="cg-cat-insp-blank">Select a page to edit it.</div> |
| )} |
| </aside> |
|
|
| {} |
| {askQuality ? ( |
| <div className="cg-cat-modal-wrap" role="dialog" aria-modal="true" aria-label="Print quality"> |
| <div className="cg-cat-modal"> |
| <h3>Print “{askQuality.name || "Untitled catalog"}”</h3> |
| <p> |
| Your browser’s print dialog opens next — choose “Save as PDF” there. |
| Pick the image quality first. |
| </p> |
| <button |
| type="button" |
| className="cg-cat-choice" |
| onClick={() => { |
| setPrinting({ cat: askQuality, quality: "print" }); |
| setAskQuality(null); |
| }} |
| > |
| <strong>High quality</strong> |
| <em>Original master images. Slower, and a much larger PDF — use this for print.</em> |
| </button> |
| <button |
| type="button" |
| className="cg-cat-choice" |
| onClick={() => { |
| setPrinting({ cat: askQuality, quality: "web" }); |
| setAskQuality(null); |
| }} |
| > |
| <strong>Standard</strong> |
| <em>The same images you see here. Fast, small file — fine for a proof or email.</em> |
| </button> |
| <button type="button" className="cg-btn" onClick={() => setAskQuality(null)}> |
| Cancel |
| </button> |
| </div> |
| </div> |
| ) : null} |
|
|
| {} |
| {printing |
| ? createPortal( |
| <div className="cg-cat-print-portal" ref={printRef}> |
| <CatalogPages |
| cat={printing.cat} |
| itemsFor={itemsFor} |
| quality={printing.quality} |
| hasAsset={hasAsset} |
| /> |
| </div>, |
| document.body |
| ) |
| : null} |
| </div> |
| ); |
| }); |
|
|
| export default CatalogView; |
|
|