@protoboardai/views 0.0.0-stage → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +13 -0
  3. package/README.md +21 -2
  4. package/package.json +57 -4
  5. package/src/applies.ts +54 -0
  6. package/src/builtins.tsx +89 -0
  7. package/src/drawing/BoardDrawingView.tsx +515 -0
  8. package/src/drawing/CircuitPcbView.tsx +494 -0
  9. package/src/drawing/DrawingControls.tsx +39 -0
  10. package/src/drawing/boards.ts +82 -0
  11. package/src/drawing/circuit.ts +413 -0
  12. package/src/drawing/kicadSchematic.ts +277 -0
  13. package/src/drawing/palettes.ts +150 -0
  14. package/src/drawing/svg.ts +445 -0
  15. package/src/drawing/usePanZoom.ts +400 -0
  16. package/src/icons.tsx +69 -0
  17. package/src/index.ts +33 -0
  18. package/src/inspector/DrcPanel.tsx +302 -0
  19. package/src/inspector/SelectionInspector.tsx +392 -0
  20. package/src/inspector/index.ts +9 -0
  21. package/src/inspector/model.ts +224 -0
  22. package/src/pair/PairView.tsx +314 -0
  23. package/src/pair/model.ts +206 -0
  24. package/src/parts/BomEditor.tsx +564 -0
  25. package/src/parts/CardPages.tsx +317 -0
  26. package/src/parts/CategoryList.tsx +72 -0
  27. package/src/parts/CloudPanel.tsx +160 -0
  28. package/src/parts/GlbPreview.tsx +116 -0
  29. package/src/parts/LibrariesManager.tsx +251 -0
  30. package/src/parts/LibraryBrowser.tsx +191 -0
  31. package/src/parts/LibraryPage.tsx +168 -0
  32. package/src/parts/PartCard.tsx +274 -0
  33. package/src/parts/PartDetail.tsx +613 -0
  34. package/src/parts/PartStatus.tsx +54 -0
  35. package/src/parts/Prices.tsx +241 -0
  36. package/src/parts/ProjectParts.tsx +174 -0
  37. package/src/parts/ProjectPartsPage.tsx +264 -0
  38. package/src/parts/SettingsPage.tsx +268 -0
  39. package/src/parts/Surface.tsx +51 -0
  40. package/src/parts/Windowed.tsx +70 -0
  41. package/src/parts/carousel.tsx +344 -0
  42. package/src/parts/index.ts +32 -0
  43. package/src/parts/library-data.ts +726 -0
  44. package/src/parts/matte.ts +69 -0
  45. package/src/parts/model.ts +590 -0
  46. package/src/parts/part-pages.ts +379 -0
  47. package/src/parts/part-status.ts +88 -0
  48. package/src/parts/revision.ts +11 -0
  49. package/src/parts/three.d.ts +5 -0
  50. package/src/parts/ui.tsx +335 -0
  51. package/src/parts/views.tsx +433 -0
  52. package/src/registry.ts +107 -0
  53. package/src/tabs.ts +118 -0
  54. package/src/theme.ts +225 -0
  55. package/src/types.ts +93 -0
  56. package/src/ui/Select.tsx +99 -0
@@ -0,0 +1,251 @@
1
+ /**
2
+ * Manage libraries, as a settings page in its own tab (PB-902, design 03):
3
+ * the account the libraries use, the libraries in search order (the first
4
+ * that has an id wins unless a part is pinned to one) with their address,
5
+ * size and access, and adding one by URL. Tokens are never stored: a
6
+ * library that needs one names an environment variable, or uses the
7
+ * Protoboard sign-in.
8
+ */
9
+ import { useState } from "react";
10
+ import { Icon, ScrollArea, cx } from "@deltarobotics/uhd-viewer/components";
11
+ import type { AccountModel, LibrariesActions, LibrariesModel, ManagedLibraryModel } from "./model.js";
12
+ import { PartsSurface } from "./Surface.js";
13
+ import { Avatar, BODY, Button, Card, Pill, RowAction, StatusText, type Tone } from "./ui.js";
14
+ import { Select } from "../ui/Select.js";
15
+
16
+ const ACCESS: Record<ManagedLibraryModel["access"], { tone: Tone; label: (l: ManagedLibraryModel) => string }> = {
17
+ "signed-in": { tone: "ok", label: () => "Signed in" },
18
+ token: { tone: "ok", label: (l) => `Token from ${l.authEnv}` },
19
+ "token-missing": { tone: "warning", label: (l) => `${l.authEnv} is not set` },
20
+ public: { tone: "neutral", label: () => "Public" },
21
+ "sign-in": { tone: "warning", label: () => "Needs a sign-in" },
22
+ error: { tone: "error", label: (l) => l.error ?? "Not reachable" },
23
+ checking: { tone: "neutral", label: () => "Checking…" },
24
+ };
25
+
26
+ /** A service URL as its host ("cloud.protoboard.ai"), or the text as given. */
27
+ const hostOf = (service: string | undefined): string | undefined => {
28
+ if (!service) return undefined;
29
+ try {
30
+ return new URL(service).host || service;
31
+ } catch {
32
+ return service;
33
+ }
34
+ };
35
+
36
+ /** The account the libraries use: who, and the sign-in action. */
37
+ export function AccountCard({ account, actions }: { account: AccountModel; actions: Pick<LibrariesActions, "signIn" | "signOut"> }) {
38
+ const signedIn = account.state === "signed-in";
39
+ const title =
40
+ account.state === "signed-in"
41
+ ? `Signed in as ${account.email ?? "you"}`
42
+ : account.state === "expired"
43
+ ? "Sign in again"
44
+ : account.state === "open"
45
+ ? "No account needed"
46
+ : account.state === "signed-out"
47
+ ? "Not signed in"
48
+ : account.detail
49
+ ? "Sign-in not checked"
50
+ : "Checking the sign-in…";
51
+ const note =
52
+ account.state === "signed-in"
53
+ ? (account.detail ?? hostOf(account.service) ?? "")
54
+ : account.state === "expired"
55
+ ? `The sign-in${account.email ? ` as ${account.email}` : ""} no longer works${account.problem ? `: ${account.problem}` : ""}`
56
+ : account.state === "open"
57
+ ? `${hostOf(account.service) ?? "This library"} needs no sign-in`
58
+ : account.state === "signed-out"
59
+ ? "Needed for library parts and prices"
60
+ : (account.detail ?? "");
61
+ return (
62
+ <Card className={cx(BODY, "flex items-center gap-3 px-4 py-3")}>
63
+ <Avatar email={signedIn ? account.email : undefined} size={32} />
64
+ <div className="min-w-0 flex-1">
65
+ <p className="truncate text-[13.5px] text-fg">{title}</p>
66
+ <p className="truncate text-[12px] text-fg-muted">{note}</p>
67
+ </div>
68
+ {signedIn && actions.signOut && (
69
+ <Button size="sm" onClick={actions.signOut}>
70
+ Sign out
71
+ </Button>
72
+ )}
73
+ {(account.state === "signed-out" || account.state === "expired" || account.state === "unknown") && actions.signIn && (
74
+ <Button size="sm" kind="primary" icon="log-in" onClick={actions.signIn}>
75
+ Sign in
76
+ </Button>
77
+ )}
78
+ </Card>
79
+ );
80
+ }
81
+
82
+ const GRID = "grid grid-cols-[20px_minmax(150px,1.3fr)_minmax(160px,2.2fr)_64px_minmax(130px,1.4fr)_76px] items-center gap-3 max-[820px]:grid-cols-[20px_minmax(0,1fr)_76px]";
83
+
84
+ /** A row being dragged by its handle: which library, and where it would land. */
85
+ interface Drag {
86
+ key: string;
87
+ over?: string;
88
+ }
89
+
90
+ const rowKey = (l: ManagedLibraryModel) => `${l.scope}:${l.id}`;
91
+
92
+ function LibraryRow({ lib, first, last, actions, working, alone, drag, setDrag, onDrop }: { lib: ManagedLibraryModel; first: boolean; last: boolean; actions: LibrariesActions; working: boolean; alone: boolean; drag?: Drag; setDrag: (d: Drag | undefined) => void; onDrop: (from: string, to: string) => void }) {
93
+ const a = ACCESS[lib.access];
94
+ const key = rowKey(lib);
95
+ const dragged = drag?.key === key;
96
+ // a library moves within its own scope (the project's order, or your personal list); the default library stays put
97
+ const movable = lib.scope !== "default" && !working && !alone;
98
+ const target = !!drag && !dragged && drag.key.split(":")[0] === lib.scope;
99
+ return (
100
+ <div
101
+ onDragOver={(e) => {
102
+ if (!target) return;
103
+ e.preventDefault();
104
+ e.dataTransfer.dropEffect = "move";
105
+ if (drag?.over !== key) setDrag({ key: drag!.key, over: key });
106
+ }}
107
+ onDragLeave={() => drag?.over === key && setDrag({ key: drag.key })}
108
+ onDrop={(e) => {
109
+ if (!target) return;
110
+ e.preventDefault();
111
+ onDrop(drag!.key, key);
112
+ }}
113
+ className={cx(GRID, BODY, "min-h-11 border-t border-line-subtle px-3 py-1.5 text-[13px]", dragged && "opacity-50", drag?.over === key && "bg-accent-tint outline outline-1 -outline-offset-1 outline-focus")}
114
+ >
115
+ <span
116
+ draggable={movable}
117
+ role="img"
118
+ aria-label={movable ? "Drag to reorder" : undefined}
119
+ data-tooltip={movable ? "Drag to change the search order" : undefined}
120
+ onDragStart={(e) => {
121
+ e.dataTransfer.effectAllowed = "move";
122
+ e.dataTransfer.setData("text/plain", key);
123
+ setDrag({ key });
124
+ }}
125
+ onDragEnd={() => setDrag(undefined)}
126
+ className={cx("grid h-6 place-items-center rounded-[3px]", movable ? "cursor-grab text-fg-muted hover:bg-hover active:cursor-grabbing" : "text-fg-faint opacity-40")}
127
+ >
128
+ <Icon name="grip-vertical" size={14} aria-hidden />
129
+ </span>
130
+ <div className="flex min-w-0 flex-col max-[820px]:gap-0.5">
131
+ <span className="flex min-w-0 items-center gap-2">
132
+ <Icon name="library-big" size={14} strokeWidth={1.75} className="shrink-0 text-fg-muted" aria-hidden />
133
+ <span className="truncate text-fg" title={lib.id}>
134
+ {lib.name ?? lib.id}
135
+ </span>
136
+ <Pill>{lib.scope}</Pill>
137
+ </span>
138
+ <span className="hidden truncate font-ui text-[11.5px] text-fg-muted max-[820px]:block">{lib.url}</span>
139
+ <span className="hidden max-[820px]:block">
140
+ <StatusText tone={a.tone} plain>{a.label(lib)}</StatusText>
141
+ </span>
142
+ </div>
143
+ <span className="truncate font-ui text-[12px] text-fg max-[820px]:hidden" title={lib.url}>
144
+ {lib.url}
145
+ </span>
146
+ <span className="font-ui text-[12px] text-fg max-[820px]:hidden">{lib.parts !== undefined ? lib.parts.toLocaleString("en-US") : "—"}</span>
147
+ <span className="min-w-0 truncate max-[820px]:hidden" title={lib.error}>
148
+ <StatusText tone={a.tone} plain>{a.label(lib)}</StatusText>
149
+ </span>
150
+ <span className="flex justify-end">
151
+ <RowAction icon="arrow-up" label="Search it earlier" disabled={first || working} onClick={() => actions.move(lib.id, lib.scope, "up")} />
152
+ <RowAction icon="arrow-down" label="Search it later" disabled={last || working} onClick={() => actions.move(lib.id, lib.scope, "down")} />
153
+ <RowAction icon="trash" label={lib.scope === "default" ? "The default library stays unless the project lists others" : lib.pinned ? `Remove (${lib.pinned} part(s) pinned to it stay vendored)` : "Remove"} disabled={working || lib.scope === "default"} onClick={() => actions.remove(lib.id, lib.scope)} />
154
+ </span>
155
+ </div>
156
+ );
157
+ }
158
+
159
+ export function LibrariesManager({ model, actions }: { model: LibrariesModel; actions: LibrariesActions }) {
160
+ const [url, setUrl] = useState("");
161
+ const [scope, setScope] = useState<"project" | "personal">("project");
162
+ const [env, setEnv] = useState("");
163
+ const valid = /^https?:\/\/\S+$/.test(url.trim());
164
+ const [drag, setDrag] = useState<Drag>();
165
+ // dropped on another row of the same scope: move that many places within the scope's own order
166
+ const drop = (from: string, to: string) => {
167
+ setDrag(undefined);
168
+ const lib = model.libraries.find((l) => rowKey(l) === from);
169
+ if (!lib) return;
170
+ const same = model.libraries.filter((l) => l.scope === lib.scope).map(rowKey);
171
+ const delta = same.indexOf(to) - same.indexOf(from);
172
+ if (delta && same.indexOf(to) >= 0) actions.move(lib.id, lib.scope, delta < 0 ? "up" : "down", Math.abs(delta));
173
+ };
174
+ const add = () => {
175
+ if (!valid) return;
176
+ actions.add(url.trim(), scope, env.trim() || undefined);
177
+ setUrl("");
178
+ setEnv("");
179
+ };
180
+ return (
181
+ <PartsSurface place="editor">
182
+ <ScrollArea className="size-full" contentClassName="mx-auto flex max-w-[1060px] flex-col gap-4 px-10 pt-7 pb-10 max-[640px]:px-4">
183
+ <header className={BODY}>
184
+ <h1 className="text-[22px] font-semibold text-fg">Libraries</h1>
185
+ <p className="mt-1 text-[13px] text-fg-muted">Where parts come from, in search order.</p>
186
+ </header>
187
+ <AccountCard account={model.account} actions={actions} />
188
+ <Card className="overflow-hidden">
189
+ <div className={cx(GRID, BODY, "h-9 px-3 text-[12px] font-medium text-fg-muted")}>
190
+ <span />
191
+ <span>Library</span>
192
+ <span className="max-[820px]:hidden">URL</span>
193
+ <span className="max-[820px]:hidden">Parts</span>
194
+ <span className="max-[820px]:hidden">Access</span>
195
+ <span className="flex justify-end">{actions.check && <RowAction icon="refresh-cw" label="Check every library again" onClick={actions.check} />}</span>
196
+ </div>
197
+ {model.libraries.map((l, i) => (
198
+ <LibraryRow key={rowKey(l)} lib={l} first={i === 0} last={i === model.libraries.length - 1} actions={actions} working={model.working} alone={model.libraries.filter((x) => x.scope === l.scope).length < 2} drag={drag} setDrag={setDrag} onDrop={drop} />
199
+ ))}
200
+ {!model.libraries.length && <p className={cx(BODY, "border-t border-line-subtle px-4 py-3 text-[13px] text-fg-muted")}>{model.noProject ? "Open a Protoboard project to see its libraries." : "No libraries yet."}</p>}
201
+ </Card>
202
+ <form
203
+ className={cx(BODY, "flex flex-wrap items-center gap-2")}
204
+ onSubmit={(e) => {
205
+ e.preventDefault();
206
+ add();
207
+ }}
208
+ >
209
+ <label className="flex h-8 min-w-[240px] flex-[3_1_320px] items-center gap-2 rounded-[4px] border border-line bg-sunken px-2.5 focus-within:border-focus focus-within:outline focus-within:outline-1 focus-within:-outline-offset-1 focus-within:outline-focus">
210
+ <Icon name="link" size={14} className="shrink-0 text-fg-muted" aria-hidden />
211
+ <input value={url} onChange={(e) => setUrl(e.target.value)} placeholder="https://… a library that speaks uhd-library/v1" aria-label="Library URL" spellCheck={false} className="h-full min-w-0 flex-1 border-0 bg-transparent p-0 text-[13px] text-fg outline-none placeholder:text-fg-faint" />
212
+ </label>
213
+ <label className="flex h-8 min-w-[150px] flex-[1_1_160px] items-center gap-2 rounded-[4px] border border-line bg-sunken px-2.5 focus-within:border-focus" data-tooltip="Only for a library that needs a token: the name of the environment variable holding it">
214
+ <Icon name="key-round" size={14} className="shrink-0 text-fg-muted" aria-hidden />
215
+ <input value={env} onChange={(e) => setEnv(e.target.value.toUpperCase())} placeholder="TOKEN_VARIABLE" aria-label="Token environment variable" spellCheck={false} className="h-full min-w-0 flex-1 border-0 bg-transparent p-0 font-ui text-[12px] text-fg outline-none placeholder:text-fg-faint" />
216
+ </label>
217
+ <Select
218
+ label="Where to add it"
219
+ tooltip="Project: protoboard.config.ts, for everyone. Personal: your settings only"
220
+ value={scope}
221
+ onChange={(v) => setScope(v as "project" | "personal")}
222
+ options={[
223
+ { value: "project", label: "Project" },
224
+ { value: "personal", label: "Personal" },
225
+ ]}
226
+ className="relative flex h-8 items-center gap-1 rounded-[4px] border border-[var(--pb-control-border)] px-2.5 text-[13px] text-fg hover:bg-hover"
227
+ trigger={
228
+ <>
229
+ <Icon name={scope === "project" ? "folder" : "user"} size={14} aria-hidden />
230
+ {scope === "project" ? "Project" : "Personal"}
231
+ <Icon name="chevron-down" size={12} aria-hidden />
232
+ </>
233
+ }
234
+ />
235
+ <Button kind="primary" icon={model.working ? "loader" : "plus"} type="submit" disabled={!valid || model.working || model.noProject}>
236
+ Add library
237
+ </Button>
238
+ </form>
239
+ {model.problems.map((p) => (
240
+ <p key={p} className={cx(BODY, "text-[12px] text-warning")}>
241
+ {p}
242
+ </p>
243
+ ))}
244
+ <Card className={cx(BODY, "flex items-center gap-3 px-4 py-3 text-[13px] text-fg")}>
245
+ <Icon name="info" size={15} className="shrink-0 text-info" aria-hidden />
246
+ Tokens come from an environment variable or your sign-in, never the project.
247
+ </Card>
248
+ </ScrollArea>
249
+ </PartsSurface>
250
+ );
251
+ }
@@ -0,0 +1,191 @@
1
+ /**
2
+ * The Library sidebar (PB-902, design 01; PB-934): search, the category
3
+ * breadcrumb, the categories as a list that drills in and out (it folds away
4
+ * to leave the parts the room), the count and the order, and the part cards.
5
+ * Opening a card shows the part's detail in an editor tab (the host's).
6
+ */
7
+ import { useEffect, useRef, useState } from "react";
8
+ import { EmptyState, Icon, ScrollArea, cx } from "@deltarobotics/uhd-viewer/components";
9
+ import { CategoryList } from "./CategoryList.js";
10
+ import { SORT_LABEL } from "./library-data.js";
11
+ import type { LibraryActions, LibraryModel, LibrarySort, LibrarySortModel } from "./model.js";
12
+ import { CARD_HEIGHT, PartCard } from "./PartCard.js";
13
+ import { LinkPagesToggle, PagerProvider, PagesProvider, useNewPagerStore } from "./carousel.js";
14
+ import { Windowed } from "./Windowed.js";
15
+ import { PartsSurface } from "./Surface.js";
16
+ import { BODY, Button, SearchField, categoryIcon } from "./ui.js";
17
+ import { Select } from "../ui/Select.js";
18
+
19
+ /** "All › Sensors › Environmental": each crumb goes back to that level. */
20
+ export function Crumbs({ crumbs, onPick, className }: { crumbs: LibraryModel["crumbs"]; onPick: (path: string) => void; className?: string }) {
21
+ const list = crumbs.length ? crumbs : [{ path: "", name: "All" }];
22
+ return (
23
+ <nav aria-label="Category" className={cx(BODY, "flex min-w-0 items-center gap-1 text-[12.5px]", className)}>
24
+ <Icon name={list.length > 1 ? categoryIcon(list[list.length - 1].path) : "network"} size={13} strokeWidth={1.75} className="shrink-0 text-fg-muted" aria-hidden />
25
+ {list.map((c, i) => {
26
+ const last = i === list.length - 1;
27
+ return (
28
+ <span key={c.path || "all"} className="flex min-w-0 items-center gap-1">
29
+ {i > 0 && <Icon name="chevron-right" size={12} className="shrink-0 text-fg-faint" aria-hidden />}
30
+ <button
31
+ type="button"
32
+ aria-current={last ? "location" : undefined}
33
+ onClick={() => onPick(c.path)}
34
+ className={cx("cursor-pointer truncate rounded-[3px] px-0.5 hover:underline", last ? "font-semibold text-fg" : "text-fg-muted hover:text-fg")}
35
+ >
36
+ {i === 0 ? "All" : c.name}
37
+ </button>
38
+ </span>
39
+ );
40
+ })}
41
+ </nav>
42
+ );
43
+ }
44
+
45
+ /**
46
+ * The order of the results. It offers only the orders that work (`sort.options`,
47
+ * library-data.ts `sortState`), and says when an order was made here over the
48
+ * parts loaded so far instead of by the library.
49
+ */
50
+ export function SortMenu({ sort, onSort, compact }: { sort: LibrarySortModel; onSort: (s: LibrarySort) => void; compact?: boolean }) {
51
+ const tip = sort.local ? `Ordered by ${SORT_LABEL[sort.value].toLowerCase()} (loaded parts only)` : `Ordered by ${SORT_LABEL[sort.value].toLowerCase()}`;
52
+ return (
53
+ <Select
54
+ label="Order of the results"
55
+ tooltip={tip}
56
+ value={sort.value}
57
+ onChange={(v) => onSort(v as LibrarySort)}
58
+ options={sort.options.map((s) => ({ value: s, label: SORT_LABEL[s] }))}
59
+ className={cx(
60
+ BODY,
61
+ "relative inline-flex shrink-0 items-center gap-1 text-fg-muted hover:text-fg",
62
+ compact ? "h-6 text-[12px]" : "h-8 rounded-[4px] border border-[var(--pb-control-border)] px-3 text-[13px] text-fg hover:bg-hover",
63
+ )}
64
+ trigger={
65
+ <>
66
+ <Icon name="arrow-down-up" size={compact ? 12 : 14} strokeWidth={1.75} aria-hidden />
67
+ <span>{SORT_LABEL[sort.value]}</span>
68
+ {compact && <Icon name="chevron-down" size={12} aria-hidden />}
69
+ </>
70
+ }
71
+ />
72
+ );
73
+ }
74
+
75
+ /** Asks for the next page when the end of the list comes into view. */
76
+ export function LoadMore({ model, onMore }: { model: Pick<LibraryModel, "more" | "loadingMore">; onMore: () => void }) {
77
+ const ref = useRef<HTMLDivElement>(null);
78
+ useEffect(() => {
79
+ const el = ref.current;
80
+ if (!el || !model.more || typeof IntersectionObserver === "undefined") return;
81
+ const io = new IntersectionObserver((e) => e.some((x) => x.isIntersecting) && onMore(), { rootMargin: "200px" });
82
+ io.observe(el);
83
+ return () => io.disconnect();
84
+ }, [model.more, onMore]);
85
+ if (!model.more) return null;
86
+ return (
87
+ <div ref={ref} className="flex justify-center py-2">
88
+ <Button size="sm" kind="ghost" icon={model.loadingMore ? "loader" : "chevrons-down"} disabled={model.loadingMore} onClick={onMore}>
89
+ {model.loadingMore ? "Loading…" : "More parts"}
90
+ </Button>
91
+ </div>
92
+ );
93
+ }
94
+
95
+ export function LibraryProblems({ model, onRetry }: { model: Pick<LibraryModel, "error" | "setupError" | "libraries">; onRetry?: () => void }) {
96
+ const failed = model.libraries.filter((l) => l.error);
97
+ const message = model.setupError ?? model.error;
98
+ if (!message && !failed.length) return null;
99
+ return (
100
+ <div role="status" className={cx(BODY, "flex items-start gap-2 rounded-[4px] border border-line px-2.5 py-2 text-[12px] text-fg-muted")}>
101
+ <Icon name="triangle-alert" size={14} className="mt-px shrink-0 text-warning" aria-hidden />
102
+ <div className="min-w-0 flex-1">
103
+ {message && <p className="text-fg">{message}</p>}
104
+ {failed.map((l) => (
105
+ <p key={l.id}>
106
+ {l.name ?? l.id}: {l.error}
107
+ </p>
108
+ ))}
109
+ </div>
110
+ {onRetry && <Button size="sm" kind="ghost" icon="refresh-cw" onClick={onRetry} aria-label="Try again" />}
111
+ </div>
112
+ );
113
+ }
114
+
115
+ export interface LibraryBrowserProps {
116
+ model: LibraryModel;
117
+ actions: LibraryActions;
118
+ /** The part whose detail is open (its card is outlined). */
119
+ selected?: string;
120
+ }
121
+
122
+ export function LibraryBrowser({ model, actions, selected }: LibraryBrowserProps) {
123
+ // the category list folds away: the breadcrumb stays, and says where the list is
124
+ const [open, setOpen] = useState(true);
125
+ const pager = useNewPagerStore();
126
+ const asked = model.libraries.length;
127
+ const pick = (category: string) => actions.query({ category });
128
+ return (
129
+ <PartsSurface place="sidebar">
130
+ <div className="flex size-full min-w-[220px] flex-col">
131
+ <div className="flex shrink-0 flex-col gap-2 px-3 pt-2 pb-2">
132
+ <SearchField value={model.query.text} placeholder="Search parts" onChange={(text) => actions.query({ text })} />
133
+ <div className="flex min-w-0 items-center gap-1">
134
+ <Crumbs crumbs={model.crumbs} onPick={pick} className="flex-1" />
135
+ <button
136
+ type="button"
137
+ aria-expanded={open}
138
+ aria-label={open ? "Hide the categories" : "Show the categories"}
139
+ data-tooltip={open ? "Hide the categories" : "Show the categories"}
140
+ onClick={() => setOpen(!open)}
141
+ className="grid size-6 shrink-0 cursor-pointer place-items-center rounded-[4px] text-fg-muted hover:bg-hover hover:text-fg focus-visible:outline focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-focus"
142
+ >
143
+ <Icon name={open ? "chevrons-down-up" : "chevrons-up-down"} size={14} strokeWidth={1.75} aria-hidden />
144
+ </button>
145
+ </div>
146
+ {/* the list scrolls inside its own area: the parts below keep most of the side bar */}
147
+ {open && <CategoryList model={model} onPick={pick} scroll className="max-h-[min(264px,38vh)] border-y border-line-subtle py-1" />}
148
+ <div className={cx(BODY, "flex items-center gap-2 text-[11.5px] text-fg-muted")}>
149
+ <span className="min-w-0 flex-1 truncate">
150
+ {model.loading ? "Searching…" : `${model.total} part${model.total === 1 ? "" : "s"}`}
151
+ {asked > 1 && ` · ${asked} libraries`}
152
+ </span>
153
+ {actions.pages && <LinkPagesToggle store={pager} compact />}
154
+ <SortMenu sort={model.sort} onSort={(sort) => actions.query({ sort })} compact />
155
+ </div>
156
+ <LibraryProblems model={model} onRetry={actions.retry} />
157
+ </div>
158
+ <ScrollArea className="min-h-0 flex-1" contentClassName="flex flex-col gap-2 px-3 pb-3">
159
+ <PagesProvider loader={actions.pages}>
160
+ <PagerProvider store={pager}>
161
+ {model.items.map((it, i) => (
162
+ <Windowed key={it.key} height={CARD_HEIGHT.row} eager={i < 10}>
163
+ <PartCard item={it} selected={it.key === selected} noProject={model.noProject} onOpen={actions.open} onAdd={actions.add} onUpdate={actions.update} onMore={actions.more} />
164
+ </Windowed>
165
+ ))}
166
+ </PagerProvider>
167
+ </PagesProvider>
168
+ {!model.items.length && !model.loading && !model.setupError && (
169
+ <div className="py-8">
170
+ <EmptyState icon={<Icon name="search-x" size={24} strokeWidth={1.25} />} title="No parts match">
171
+ {model.query.text || model.query.category ? "Try other words, or a broader category." : "The libraries list no parts."}
172
+ </EmptyState>
173
+ </div>
174
+ )}
175
+ {model.loading && !model.items.length && <Skeleton />}
176
+ <LoadMore model={model} onMore={actions.loadMore} />
177
+ </ScrollArea>
178
+ </div>
179
+ </PartsSurface>
180
+ );
181
+ }
182
+
183
+ function Skeleton() {
184
+ return (
185
+ <>
186
+ {[0, 1, 2].map((i) => (
187
+ <div key={i} aria-hidden className="h-[132px] animate-pulse rounded-[4px] border border-line bg-[var(--pb-card)]" />
188
+ ))}
189
+ </>
190
+ );
191
+ }
@@ -0,0 +1,168 @@
1
+ /**
2
+ * The full-page library (PB-902, design 02; PB-934): a McMaster-style grid
3
+ * with larger pictures and more per part. On the left the categories as a
4
+ * list that drills in and out (with counts), the libraries searched and the
5
+ * "show only" filters; on the right the search, the order and grid/list
6
+ * switch, the category's breadcrumb and heading, then the cards. Narrow: the
7
+ * left column folds away behind a button.
8
+ */
9
+ import { useEffect, useMemo, useState } from "react";
10
+ import { EmptyState, Icon, IconButton, ScrollArea, cx } from "@deltarobotics/uhd-viewer/components";
11
+ import { CategoryList } from "./CategoryList.js";
12
+ import { Crumbs, LibraryProblems, LoadMore, SortMenu } from "./LibraryBrowser.js";
13
+ import type { LibraryActions, LibraryModel, PartCardModel } from "./model.js";
14
+ import { CARD_HEIGHT, PartCard } from "./PartCard.js";
15
+ import { LinkPagesToggle, PagerProvider, PagesProvider, useNewPagerStore } from "./carousel.js";
16
+ import { Windowed } from "./Windowed.js";
17
+ import { PartsSurface } from "./Surface.js";
18
+ import { BODY, SearchField, SectionLabel, categoryIcon } from "./ui.js";
19
+
20
+ type ShowOnly = "model" | "audited" | "project";
21
+ const SHOW_ONLY: { id: ShowOnly; label: string; icon: string; test: (x: PartCardModel) => boolean | undefined; known: (x: PartCardModel) => boolean }[] = [
22
+ { id: "model", label: "Has 3D model", icon: "box", test: (x) => x.hasModel, known: (x) => x.hasModel !== undefined },
23
+ { id: "audited", label: "Independently audited", icon: "shield-check", test: (x) => x.audited, known: (x) => x.audited !== undefined },
24
+ { id: "project", label: "In this project", icon: "folder", test: (x) => !!x.used && !x.used.elsewhere, known: () => true },
25
+ ];
26
+
27
+ export interface LibraryPageProps {
28
+ model: LibraryModel;
29
+ actions: LibraryActions;
30
+ selected?: string;
31
+ }
32
+
33
+ function Check({ checked, label, onChange, disabled, title, icon }: { checked: boolean; label: string; onChange: (v: boolean) => void; disabled?: boolean; title?: string; icon?: string }) {
34
+ return (
35
+ <label title={title} className={cx(BODY, "flex h-6 cursor-pointer items-center gap-2 rounded-[3px] px-1 text-[12.5px] text-fg hover:bg-hover", disabled && "cursor-default opacity-50 hover:bg-transparent")}>
36
+ {icon ? (
37
+ <Icon name={icon} size={14} strokeWidth={1.75} className={checked ? "text-accent" : "text-fg-muted"} aria-hidden />
38
+ ) : (
39
+ <span aria-hidden className={cx("grid size-3.5 shrink-0 place-items-center rounded-[3px] border", checked ? "border-accent bg-accent text-on-accent" : "border-[var(--pb-control-border)]")}>
40
+ {checked && <Icon name="check" size={11} strokeWidth={3} />}
41
+ </span>
42
+ )}
43
+ <input type="checkbox" className="sr-only" checked={checked} disabled={disabled} onChange={(e) => onChange(e.target.checked)} />
44
+ <span className={cx("truncate", icon && checked && "font-medium")}>{label}</span>
45
+ </label>
46
+ );
47
+ }
48
+
49
+ export function LibraryPage({ model, actions, selected }: LibraryPageProps) {
50
+ const [layout, setLayout] = useState<"grid" | "list">("grid");
51
+ const [hidden, setHidden] = useState<Set<string>>(new Set());
52
+ const [only, setOnly] = useState<Set<ShowOnly>>(new Set());
53
+ const [rail, setRail] = useState(false);
54
+ const pager = useNewPagerStore();
55
+ const current = model.crumbs[model.crumbs.length - 1];
56
+ const heading = model.crumbs.length > 1 ? current.name : "All parts";
57
+ const items = useMemo(() => {
58
+ let list = model.items.filter((x) => !hidden.has(x.library));
59
+ for (const o of only) list = list.filter((x) => SHOW_ONLY.find((s) => s.id === o)!.test(x));
60
+ return list;
61
+ }, [model.items, hidden, only]);
62
+ // the catalog's size: the unfiltered search's total, remembered while a category or text narrows it (summing the categories counts a part once per category)
63
+ const [seenTotal, setSeenTotal] = useState<number>();
64
+ useEffect(() => {
65
+ if (model.catalogTotal !== undefined) setSeenTotal(model.catalogTotal);
66
+ }, [model.catalogTotal]);
67
+ const catalogSize = model.catalogTotal ?? seenTotal ?? 0;
68
+ const pick = (category: string) => actions.query({ category });
69
+ const toggle = <T,>(set: Set<T>, v: T, on: boolean) => {
70
+ const next = new Set(set);
71
+ if (on) next.add(v);
72
+ else next.delete(v);
73
+ return next;
74
+ };
75
+
76
+ const side = (
77
+ <div className="flex flex-col gap-4 px-4 pt-5 pb-4">
78
+ <section>
79
+ <SectionLabel>Categories</SectionLabel>
80
+ <CategoryList model={catalogSize ? { ...model, catalogTotal: catalogSize } : model} onPick={pick} className="mt-1" />
81
+ </section>
82
+ {model.libraries.length > 0 && (
83
+ <section>
84
+ <SectionLabel>Libraries</SectionLabel>
85
+ <div className="mt-1 flex flex-col">
86
+ {model.libraries.map((l) => (
87
+ <Check key={l.id} checked={!hidden.has(l.id)} label={l.name ?? l.id} title={l.error ? `${l.id}: ${l.error}` : l.total !== undefined ? `${l.total} matching` : undefined} onChange={(on) => setHidden(toggle(hidden, l.id, !on))} />
88
+ ))}
89
+ </div>
90
+ </section>
91
+ )}
92
+ <section>
93
+ <SectionLabel>Show only</SectionLabel>
94
+ <div className="mt-1 flex flex-col">
95
+ {SHOW_ONLY.map((s) => {
96
+ const known = model.items.some(s.known);
97
+ return (
98
+ <Check
99
+ key={s.id}
100
+ icon={s.icon}
101
+ checked={only.has(s.id)}
102
+ disabled={!known}
103
+ title={known ? undefined : "The libraries do not say this yet"}
104
+ label={s.label}
105
+ onChange={(on) => setOnly(toggle(only, s.id, on))}
106
+ />
107
+ );
108
+ })}
109
+ </div>
110
+ </section>
111
+ </div>
112
+ );
113
+
114
+ return (
115
+ <PartsSurface place="editor">
116
+ <div className="flex size-full min-w-[360px]">
117
+ <aside aria-label="Filters" className={cx("w-[260px] shrink-0 border-r border-line-subtle bg-[var(--pb-bg)] max-[760px]:absolute max-[760px]:inset-y-0 max-[760px]:left-0 max-[760px]:z-20 max-[760px]:shadow-float", !rail && "max-[760px]:hidden")}>
118
+ <ScrollArea className="size-full">{side}</ScrollArea>
119
+ </aside>
120
+ <main className="flex min-w-0 flex-1 flex-col">
121
+ <div className="flex shrink-0 items-center gap-2 px-6 pt-5 max-[760px]:px-4">
122
+ <span className="min-[761px]:hidden">
123
+ <IconButton icon="sliders-horizontal" label="Categories and filters" pressed={rail} onClick={() => setRail(!rail)} />
124
+ </span>
125
+ <SearchField className="min-w-0 flex-1" value={model.query.text} placeholder={`Search ${catalogSize ? `${catalogSize} ` : ""}parts by name, MPN or what it does…`} onChange={(text) => actions.query({ text })} />
126
+ <SortMenu sort={model.sort} onSort={(sort) => actions.query({ sort })} />
127
+ {actions.pages && <LinkPagesToggle store={pager} />}
128
+ <span className="flex items-center gap-1">
129
+ <IconButton icon="layout-grid" label="Grid" pressed={layout === "grid"} onClick={() => setLayout("grid")} />
130
+ <IconButton icon="list" label="List" pressed={layout === "list"} onClick={() => setLayout("list")} />
131
+ </span>
132
+ </div>
133
+ {model.crumbs.length > 1 && <Crumbs crumbs={model.crumbs} onPick={pick} className="shrink-0 px-6 pt-4 max-[760px]:px-4" />}
134
+ <div className={cx(BODY, "flex shrink-0 items-baseline gap-2 px-6 max-[760px]:px-4", model.crumbs.length > 1 ? "pt-1.5" : "pt-4")}>
135
+ <Icon name={model.crumbs.length > 1 ? categoryIcon(current.path) : "layout-grid"} size={16} strokeWidth={1.75} className="self-center text-fg" aria-hidden />
136
+ <h1 className="text-[17px] font-semibold text-fg">{heading}</h1>
137
+ <span className="text-[12.5px] text-fg-muted">{model.loading ? "searching…" : `${model.total} part${model.total === 1 ? "" : "s"}`}</span>
138
+ </div>
139
+ <div className="shrink-0 px-6 pt-2 empty:hidden max-[760px]:px-4">
140
+ <LibraryProblems model={model} onRetry={actions.retry} />
141
+ </div>
142
+ <ScrollArea className="min-h-0 flex-1" contentClassName="px-6 pt-3 pb-6 max-[760px]:px-4">
143
+ <div className={layout === "grid" ? "grid grid-cols-[repeat(auto-fill,minmax(220px,1fr))] gap-4" : "flex flex-col gap-2"}>
144
+ <PagesProvider loader={actions.pages}>
145
+ <PagerProvider store={pager}>
146
+ {items.map((it, i) => (
147
+ <Windowed key={it.key} height={CARD_HEIGHT[layout === "grid" ? "tile" : "row"]} eager={i < 24}>
148
+ <PartCard item={it} variant={layout === "grid" ? "tile" : "row"} selected={it.key === selected} noProject={model.noProject} onOpen={actions.open} onAdd={actions.add} onUpdate={actions.update} onMore={layout === "list" ? actions.more : undefined} />
149
+ </Windowed>
150
+ ))}
151
+ </PagerProvider>
152
+ </PagesProvider>
153
+ {model.loading && !items.length && [0, 1, 2, 3].map((i) => <div key={i} aria-hidden className="h-[300px] animate-pulse rounded-[6px] border border-line bg-[var(--pb-card)]" />)}
154
+ </div>
155
+ {!items.length && !model.loading && (
156
+ <div className="py-16">
157
+ <EmptyState icon={<Icon name="search-x" size={28} strokeWidth={1.25} />} title="No parts match">
158
+ {hidden.size || only.size ? "The filters on the left hide them." : "Try other words, or a broader category."}
159
+ </EmptyState>
160
+ </div>
161
+ )}
162
+ <LoadMore model={model} onMore={actions.loadMore} />
163
+ </ScrollArea>
164
+ </main>
165
+ </div>
166
+ </PartsSurface>
167
+ );
168
+ }