@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.
- package/LICENSE +202 -0
- package/NOTICE +13 -0
- package/README.md +21 -2
- package/package.json +57 -4
- package/src/applies.ts +54 -0
- package/src/builtins.tsx +89 -0
- package/src/drawing/BoardDrawingView.tsx +515 -0
- package/src/drawing/CircuitPcbView.tsx +494 -0
- package/src/drawing/DrawingControls.tsx +39 -0
- package/src/drawing/boards.ts +82 -0
- package/src/drawing/circuit.ts +413 -0
- package/src/drawing/kicadSchematic.ts +277 -0
- package/src/drawing/palettes.ts +150 -0
- package/src/drawing/svg.ts +445 -0
- package/src/drawing/usePanZoom.ts +400 -0
- package/src/icons.tsx +69 -0
- package/src/index.ts +33 -0
- package/src/inspector/DrcPanel.tsx +302 -0
- package/src/inspector/SelectionInspector.tsx +392 -0
- package/src/inspector/index.ts +9 -0
- package/src/inspector/model.ts +224 -0
- package/src/pair/PairView.tsx +314 -0
- package/src/pair/model.ts +206 -0
- package/src/parts/BomEditor.tsx +564 -0
- package/src/parts/CardPages.tsx +317 -0
- package/src/parts/CategoryList.tsx +72 -0
- package/src/parts/CloudPanel.tsx +160 -0
- package/src/parts/GlbPreview.tsx +116 -0
- package/src/parts/LibrariesManager.tsx +251 -0
- package/src/parts/LibraryBrowser.tsx +191 -0
- package/src/parts/LibraryPage.tsx +168 -0
- package/src/parts/PartCard.tsx +274 -0
- package/src/parts/PartDetail.tsx +613 -0
- package/src/parts/PartStatus.tsx +54 -0
- package/src/parts/Prices.tsx +241 -0
- package/src/parts/ProjectParts.tsx +174 -0
- package/src/parts/ProjectPartsPage.tsx +264 -0
- package/src/parts/SettingsPage.tsx +268 -0
- package/src/parts/Surface.tsx +51 -0
- package/src/parts/Windowed.tsx +70 -0
- package/src/parts/carousel.tsx +344 -0
- package/src/parts/index.ts +32 -0
- package/src/parts/library-data.ts +726 -0
- package/src/parts/matte.ts +69 -0
- package/src/parts/model.ts +590 -0
- package/src/parts/part-pages.ts +379 -0
- package/src/parts/part-status.ts +88 -0
- package/src/parts/revision.ts +11 -0
- package/src/parts/three.d.ts +5 -0
- package/src/parts/ui.tsx +335 -0
- package/src/parts/views.tsx +433 -0
- package/src/registry.ts +107 -0
- package/src/tabs.ts +118 -0
- package/src/theme.ts +225 -0
- package/src/types.ts +93 -0
- package/src/ui/Select.tsx +99 -0
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A part's status as the views draw it (PB-932): a dot in the state's colour
|
|
3
|
+
* with a tooltip saying what the state means, and optionally its name. The
|
|
4
|
+
* colours come from part-status.ts through `PartStatusStyles`, which a shell
|
|
5
|
+
* renders once inside its themed root.
|
|
6
|
+
*/
|
|
7
|
+
import { cx } from "@deltarobotics/uhd-viewer/components";
|
|
8
|
+
import { BLANK_BLOCK_TEXT, PART_STATUS_CSS, PART_TRUST_STATES, PART_TRUST_TEXT, partStatusVar, partTrustTooltip, type PartTrust } from "./part-status.js";
|
|
9
|
+
|
|
10
|
+
/** The status colours' stylesheet: once per page, anywhere inside the themed root. */
|
|
11
|
+
export function PartStatusStyles() {
|
|
12
|
+
return <style data-pb="part-status">{PART_STATUS_CSS}</style>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface PartStatusDotProps {
|
|
16
|
+
trust: PartTrust;
|
|
17
|
+
/** Why the part has this state ("independent audit recorded"), for the tooltip. */
|
|
18
|
+
basis?: string;
|
|
19
|
+
/** A blank block (no part chosen yet): the state's colour as a dashed ring, named "Blank block". */
|
|
20
|
+
blank?: boolean;
|
|
21
|
+
/** Write the state's name beside the dot. */
|
|
22
|
+
label?: boolean;
|
|
23
|
+
size?: number;
|
|
24
|
+
className?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The dot (and, with `label`, the state's name), naming itself in a tooltip. */
|
|
28
|
+
export function PartStatusDot({ trust, basis, blank, label, size = 8, className }: PartStatusDotProps) {
|
|
29
|
+
const tip = partTrustTooltip(trust, basis, blank);
|
|
30
|
+
return (
|
|
31
|
+
<span role="img" aria-label={tip} data-tooltip={tip} data-tooltip-clamp="4" data-status={trust} data-blank={blank ? "" : undefined} className={cx("inline-flex shrink-0 items-center gap-1.5 align-middle", className)}>
|
|
32
|
+
{blank ? (
|
|
33
|
+
// no part chosen: the same gray, as a dashed ring one pixel larger so it reads at this size
|
|
34
|
+
<span aria-hidden className="box-border shrink-0 rounded-full border-[1.5px] border-dashed" style={{ width: size + 2, height: size + 2, borderColor: `var(${partStatusVar(trust)})` }} />
|
|
35
|
+
) : (
|
|
36
|
+
<span aria-hidden className="shrink-0 rounded-full outline outline-1 outline-offset-1 outline-[var(--pb-status-ring,transparent)]" style={{ width: size, height: size, background: `var(${partStatusVar(trust)})` }} />
|
|
37
|
+
)}
|
|
38
|
+
{label && <span className="text-[11.5px] leading-none whitespace-nowrap text-fg-muted">{blank ? BLANK_BLOCK_TEXT.label : PART_TRUST_TEXT[trust].label}</span>}
|
|
39
|
+
</span>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The four states with their meanings: the key under a list of parts. */
|
|
44
|
+
export function PartStatusLegend({ className }: { className?: string }) {
|
|
45
|
+
return (
|
|
46
|
+
<ul aria-label="Part status" className={cx("flex flex-wrap gap-x-4 gap-y-1.5", className)}>
|
|
47
|
+
{PART_TRUST_STATES.map((t) => (
|
|
48
|
+
<li key={t} className="flex items-center">
|
|
49
|
+
<PartStatusDot trust={t} label />
|
|
50
|
+
</li>
|
|
51
|
+
))}
|
|
52
|
+
</ul>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A part's prices by source (the owners' decision of 2026-10-07 on PB-942):
|
|
3
|
+
* the offers its library serves for it, one block per supplier: the price
|
|
4
|
+
* breaks, the pack and the minimum order, the stock, when the price was
|
|
5
|
+
* observed, and the link to the supplier's page. Suppliers that price in US
|
|
6
|
+
* dollars come first; prices are shown in the currency they were given in,
|
|
7
|
+
* never converted. Only what the library's API returns is shown.
|
|
8
|
+
*
|
|
9
|
+
* The prices are asked for when the section is first opened. A library that
|
|
10
|
+
* refuses (no sign-in, an account that is not invited) or has no price
|
|
11
|
+
* service is said so, with what to do about it.
|
|
12
|
+
*/
|
|
13
|
+
import { useEffect, useState } from "react";
|
|
14
|
+
import { EmptyState, Icon, cx } from "@deltarobotics/uhd-viewer/components";
|
|
15
|
+
import type { PartPriceOffer, PartPriceSource, PartPricesModel } from "./model.js";
|
|
16
|
+
import { BODY, Button, Pill } from "./ui.js";
|
|
17
|
+
|
|
18
|
+
export type PricesState = { status: "loading" } | { status: "ready"; prices: PartPricesModel } | { status: "error"; error: string };
|
|
19
|
+
|
|
20
|
+
/** An amount in its own currency: at least two decimals, up to four (price breaks go below a cent). */
|
|
21
|
+
export function money(amount: number, currency: string | undefined): string {
|
|
22
|
+
if (!currency) return String(amount);
|
|
23
|
+
try {
|
|
24
|
+
return new Intl.NumberFormat("en-US", { style: "currency", currency, minimumFractionDigits: 2, maximumFractionDigits: 4 }).format(amount);
|
|
25
|
+
} catch {
|
|
26
|
+
return `${amount} ${currency}`;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Suppliers in the order the section lists them: a current price in US
|
|
32
|
+
* dollars first, then a current price in another currency, then those with
|
|
33
|
+
* only an old price (dollars first again), then those with none; the
|
|
34
|
+
* library's order within each.
|
|
35
|
+
*/
|
|
36
|
+
export function usdFirst(sources: readonly PartPriceSource[]): PartPriceSource[] {
|
|
37
|
+
const rank = (s: PartPriceSource) => {
|
|
38
|
+
if (s.from) return s.from.currency === "USD" ? 0 : 1;
|
|
39
|
+
const priced = s.offers.filter((o) => o.unitPrice !== undefined);
|
|
40
|
+
return priced.some((o) => o.currency === "USD") ? 2 : priced.length ? 3 : 4;
|
|
41
|
+
};
|
|
42
|
+
return sources.map((s, i) => ({ s, i })).sort((a, b) => rank(a.s) - rank(b.s) || a.i - b.i).map((x) => x.s);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The day a price was observed, as written (ISO dates sort and read the same everywhere). */
|
|
46
|
+
const day = (iso: string) => iso.slice(0, 10);
|
|
47
|
+
|
|
48
|
+
/** The price breaks of an offer: its own list, or its one price from its minimum order. */
|
|
49
|
+
export function breaksOf(o: PartPriceOffer): { quantity: number; unitPrice: number }[] {
|
|
50
|
+
if (o.priceBreaks?.length) return [...o.priceBreaks].sort((a, b) => a.quantity - b.quantity);
|
|
51
|
+
return o.unitPrice !== undefined ? [{ quantity: o.minQuantity ?? 1, unitPrice: o.unitPrice }] : [];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function Field({ label, children, wide }: { label: string; children: React.ReactNode; wide?: boolean }) {
|
|
55
|
+
return (
|
|
56
|
+
<div className={cx("flex min-w-0 flex-col gap-0.5", wide && "col-span-full min-[520px]:col-span-2")}>
|
|
57
|
+
<dt className="font-ui text-[10px] tracking-[0.08em] text-fg-muted uppercase">{label}</dt>
|
|
58
|
+
<dd className="m-0 min-w-0 text-[12.5px] break-words text-fg">{children}</dd>
|
|
59
|
+
</div>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function Offer({ offer, supplier, onOpen }: { offer: PartPriceOffer; supplier: string; onOpen: (url: string) => void }) {
|
|
64
|
+
const breaks = breaksOf(offer);
|
|
65
|
+
const stock = offer.stock === undefined ? "not stated" : offer.stock === 0 ? "none shown" : offer.stock.toLocaleString("en-US");
|
|
66
|
+
return (
|
|
67
|
+
<div className="flex min-w-0 items-start gap-3 py-3">
|
|
68
|
+
<dl className="grid min-w-0 flex-1 grid-cols-[repeat(auto-fill,minmax(118px,1fr))] gap-x-4 gap-y-2.5">
|
|
69
|
+
<Field label="Price per piece" wide>
|
|
70
|
+
{breaks.length ? (
|
|
71
|
+
<span className="flex flex-wrap gap-x-3 gap-y-0.5 font-ui text-[12.5px]">
|
|
72
|
+
{breaks.map((b) => (
|
|
73
|
+
<span key={b.quantity} className="whitespace-nowrap">
|
|
74
|
+
<span className="text-fg-muted">{b.quantity}+</span> {money(b.unitPrice, offer.currency)}
|
|
75
|
+
</span>
|
|
76
|
+
))}
|
|
77
|
+
</span>
|
|
78
|
+
) : (
|
|
79
|
+
<span className="text-fg-muted">no price listed</span>
|
|
80
|
+
)}
|
|
81
|
+
</Field>
|
|
82
|
+
<Field label="Currency">{offer.currency ?? "not stated"}</Field>
|
|
83
|
+
<Field label="Pack">{offer.packaging ?? "single pieces"}</Field>
|
|
84
|
+
<Field label="Minimum order">{offer.minQuantity !== undefined ? `${offer.minQuantity.toLocaleString("en-US")} pc` : "not stated"}</Field>
|
|
85
|
+
<Field label="Stock">
|
|
86
|
+
{stock}
|
|
87
|
+
{offer.leadTimeWeeks !== undefined && <span className="text-fg-muted"> · {offer.leadTimeWeeks} wk lead</span>}
|
|
88
|
+
</Field>
|
|
89
|
+
<Field label="Ordering code">
|
|
90
|
+
<span className="font-ui text-[12px]">{offer.mpn}</span>
|
|
91
|
+
{offer.sku && <span className="block font-ui text-[11px] text-fg-muted">{supplier} {offer.sku}</span>}
|
|
92
|
+
</Field>
|
|
93
|
+
<Field label="Observed">
|
|
94
|
+
<span className="font-ui text-[12px]">{day(offer.observedAt)}</span>
|
|
95
|
+
{offer.stale && (
|
|
96
|
+
<span className="ml-1.5 inline-flex align-middle">
|
|
97
|
+
<Pill tone="warning" title="Too old, or the product is discontinued, to quote as a current price: history, not a quote">
|
|
98
|
+
old price
|
|
99
|
+
</Pill>
|
|
100
|
+
</span>
|
|
101
|
+
)}
|
|
102
|
+
</Field>
|
|
103
|
+
</dl>
|
|
104
|
+
{offer.url && /^https?:\/\//.test(offer.url) && (
|
|
105
|
+
<Button icon="external-link" aria-label={`Open ${supplier}'s page for ${offer.mpn}`} data-tooltip={`Open at ${supplier}`} className="w-8 !px-0" onClick={() => onOpen(offer.url!)} />
|
|
106
|
+
)}
|
|
107
|
+
</div>
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function Source({ source, onOpen }: { source: PartPriceSource; onOpen: (url: string) => void }) {
|
|
112
|
+
const market = source.offers.find((o) => o.market)?.market;
|
|
113
|
+
// within a supplier too, US dollars first
|
|
114
|
+
const offers = source.offers.map((o, i) => ({ o, i })).sort((a, b) => Number(a.o.currency !== "USD") - Number(b.o.currency !== "USD") || a.i - b.i).map((x) => x.o);
|
|
115
|
+
return (
|
|
116
|
+
<section aria-label={source.supplier} className="rounded-[4px] bg-[var(--pb-card)] px-3 outline-1 -outline-offset-1 outline-contrast">
|
|
117
|
+
<header className="flex min-w-0 flex-wrap items-center gap-x-2.5 gap-y-1 border-b border-line-subtle py-2.5">
|
|
118
|
+
<Icon name="store" size={14} strokeWidth={1.75} className="shrink-0 text-fg-muted" aria-hidden />
|
|
119
|
+
<h3 className="min-w-0 truncate text-[13px] font-semibold text-fg">{source.supplier}</h3>
|
|
120
|
+
{source.authorized && <Pill tone="ok" title="A franchised distributor, or the manufacturer's own store">authorized</Pill>}
|
|
121
|
+
{market && <Pill title="The storefront or market the price is from">{market}</Pill>}
|
|
122
|
+
<span className="ml-auto flex shrink-0 items-baseline gap-2">
|
|
123
|
+
{source.from ? (
|
|
124
|
+
<span className="font-ui text-[13px] text-fg">
|
|
125
|
+
<span className="text-[11px] text-fg-muted">from </span>
|
|
126
|
+
{money(source.from.unitPrice, source.from.currency)}
|
|
127
|
+
</span>
|
|
128
|
+
) : (
|
|
129
|
+
<span className="text-[12px] text-fg-muted">{source.offers.some((o) => o.unitPrice !== undefined) ? "old price only" : "no price listed"}</span>
|
|
130
|
+
)}
|
|
131
|
+
</span>
|
|
132
|
+
</header>
|
|
133
|
+
<div className="divide-y divide-line-subtle">
|
|
134
|
+
{offers.map((o, i) => (
|
|
135
|
+
<Offer key={`${o.mpn}-${o.sku ?? ""}-${i}`} offer={o} supplier={source.supplier} onOpen={onOpen} />
|
|
136
|
+
))}
|
|
137
|
+
</div>
|
|
138
|
+
</section>
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const SIGNED_OUT = new Set(["AUTH_REQUIRED", "FORBIDDEN"]);
|
|
143
|
+
|
|
144
|
+
/** Why there are no prices, and what to do. */
|
|
145
|
+
function Unavailable({ why, onSignIn }: { why: NonNullable<PartPricesModel["unavailable"]>; onSignIn?: () => void }) {
|
|
146
|
+
const signedOut = SIGNED_OUT.has(why.code);
|
|
147
|
+
const title = signedOut ? "Sign in to see prices" : why.code === "ACCOUNT_NOT_ALLOWED" ? "This account cannot see prices yet" : why.code === "NO_PRICES" ? "This library has no prices" : "The prices did not load";
|
|
148
|
+
const what = why.code === "NO_PRICES" ? "This part's library has no prices." : why.message;
|
|
149
|
+
return (
|
|
150
|
+
<div className="py-6">
|
|
151
|
+
<EmptyState icon={<Icon name={signedOut ? "log-in" : why.code === "ACCOUNT_NOT_ALLOWED" ? "user-x" : "circle-dollar-sign"} size={24} strokeWidth={1.25} />} title={title}>
|
|
152
|
+
<span className="block max-w-[64ch]">{what}</span>
|
|
153
|
+
{signedOut && onSignIn && (
|
|
154
|
+
<span className="mt-3 block">
|
|
155
|
+
<Button kind="primary" icon="log-in" onClick={onSignIn}>
|
|
156
|
+
Sign in
|
|
157
|
+
</Button>
|
|
158
|
+
</span>
|
|
159
|
+
)}
|
|
160
|
+
</EmptyState>
|
|
161
|
+
</div>
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export interface PricesPanelProps {
|
|
166
|
+
/** The prices, once asked for. */
|
|
167
|
+
state: PricesState | undefined;
|
|
168
|
+
/** Ask for them (the section opened, or "Try again"). */
|
|
169
|
+
onLoad(): void;
|
|
170
|
+
onOpenLink(url: string): void;
|
|
171
|
+
onSignIn?: () => void;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function PricesPanel({ state, onLoad, onOpenLink, onSignIn }: PricesPanelProps) {
|
|
175
|
+
useEffect(() => {
|
|
176
|
+
if (!state) onLoad();
|
|
177
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
178
|
+
}, [state]);
|
|
179
|
+
if (!state || state.status === "loading")
|
|
180
|
+
return (
|
|
181
|
+
<div role="status" aria-label="Loading the prices" className="flex flex-col gap-3">
|
|
182
|
+
{[0, 1].map((i) => (
|
|
183
|
+
<div key={i} aria-hidden className="h-[92px] animate-pulse rounded-[4px] bg-[var(--pb-card)] outline-1 -outline-offset-1 outline-contrast motion-reduce:animate-none" />
|
|
184
|
+
))}
|
|
185
|
+
</div>
|
|
186
|
+
);
|
|
187
|
+
if (state.status === "error")
|
|
188
|
+
return (
|
|
189
|
+
<div className="py-6">
|
|
190
|
+
<EmptyState icon={<Icon name="triangle-alert" size={24} strokeWidth={1.25} />} title="The prices did not load">
|
|
191
|
+
<span className="block max-w-[64ch]">{state.error}</span>
|
|
192
|
+
<span className="mt-3 block">
|
|
193
|
+
<Button icon="refresh-cw" onClick={onLoad}>
|
|
194
|
+
Try again
|
|
195
|
+
</Button>
|
|
196
|
+
</span>
|
|
197
|
+
</EmptyState>
|
|
198
|
+
</div>
|
|
199
|
+
);
|
|
200
|
+
const p = state.prices;
|
|
201
|
+
if (p.unavailable) return <Unavailable why={p.unavailable} {...(onSignIn ? { onSignIn } : {})} />;
|
|
202
|
+
if (!p.sources.length)
|
|
203
|
+
return (
|
|
204
|
+
<div className="py-6">
|
|
205
|
+
<EmptyState icon={<Icon name="circle-dollar-sign" size={24} strokeWidth={1.25} />} title="No prices for this part">
|
|
206
|
+
The library holds no supplier offers for it yet.
|
|
207
|
+
</EmptyState>
|
|
208
|
+
</div>
|
|
209
|
+
);
|
|
210
|
+
const sources = usdFirst(p.sources);
|
|
211
|
+
return (
|
|
212
|
+
<div className={cx(BODY, "flex flex-col gap-3")}>
|
|
213
|
+
{sources.map((s) => (
|
|
214
|
+
<Source key={s.supplier} source={s} onOpen={onOpenLink} />
|
|
215
|
+
))}
|
|
216
|
+
<p className="flex items-start gap-1.5 text-[12px] leading-snug text-fg-muted">
|
|
217
|
+
<Icon name="info" size={13} strokeWidth={1.75} className="mt-px shrink-0" aria-hidden />
|
|
218
|
+
<span>
|
|
219
|
+
{sources.length} supplier{sources.length === 1 ? "" : "s"}. Each price is per piece, in the currency the supplier lists it in (none is converted), as of the day shown. Check the supplier's page before ordering.
|
|
220
|
+
</span>
|
|
221
|
+
</p>
|
|
222
|
+
</div>
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** The prices of the part shown: asked for once per part and revision, kept while the detail is open. */
|
|
227
|
+
export function usePrices(key: string, load: (() => Promise<PartPricesModel>) | undefined): { state: PricesState | undefined; load(): void } {
|
|
228
|
+
const [held, setHeld] = useState<{ key: string; state: PricesState }>();
|
|
229
|
+
const state = held?.key === key ? held.state : undefined;
|
|
230
|
+
return {
|
|
231
|
+
state,
|
|
232
|
+
load: () => {
|
|
233
|
+
if (!load) return;
|
|
234
|
+
setHeld({ key, state: { status: "loading" } });
|
|
235
|
+
load().then(
|
|
236
|
+
(prices) => setHeld((h) => (h?.key === key ? { key, state: { status: "ready", prices } } : h)),
|
|
237
|
+
(e: unknown) => setHeld((h) => (h?.key === key ? { key, state: { status: "error", error: e instanceof Error ? e.message : String(e) } } : h)),
|
|
238
|
+
);
|
|
239
|
+
},
|
|
240
|
+
};
|
|
241
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The project's parts in the side bar (PB-903, design 03; PB-945), the way
|
|
3
|
+
* Source Control shows files: updates first, then each library's parts with
|
|
4
|
+
* their pinned revision, then the parts researched and designed in the
|
|
5
|
+
* project. Each row says two things: the part's status (PB-932: a dot, gray
|
|
6
|
+
* local, yellow unverified, green verified, blue official) and its sync with
|
|
7
|
+
* the library (in sync, an update with its revision, diverged, local only).
|
|
8
|
+
* Colours are the theme's; rows and groups have room between them. The
|
|
9
|
+
* heading opens the Project Parts page, the same parts as cards.
|
|
10
|
+
*/
|
|
11
|
+
import { useMemo } from "react";
|
|
12
|
+
import { Icon, ScrollArea, cx } from "@deltarobotics/uhd-viewer/components";
|
|
13
|
+
import { partGroups, partNote, partSync, type PartSync } from "./library-data.js";
|
|
14
|
+
import type { ProjectPartModel, ProjectPartsActions, ProjectPartsModel } from "./model.js";
|
|
15
|
+
import { PartStatusDot, PartStatusLegend } from "./PartStatus.js";
|
|
16
|
+
import { rev } from "./revision.js";
|
|
17
|
+
import { PartsSurface } from "./Surface.js";
|
|
18
|
+
import { BODY, FoldHeading, RowAction, useFolded, type FoldedGroups } from "./ui.js";
|
|
19
|
+
|
|
20
|
+
export { partGroups, partNote, partSync, tierGroups, type PartGroup, type PartSync } from "./library-data.js";
|
|
21
|
+
|
|
22
|
+
const SYNC_TEXT: Record<PartSync["tone"], string> = { ok: "text-ok", info: "text-info", warning: "text-warning", neutral: "text-fg-muted" };
|
|
23
|
+
|
|
24
|
+
/** What an update is, said where updates are listed (beta test 1, F16). */
|
|
25
|
+
export const UPDATE_EXPLAINED = "A newer revision the library recommends. Compare before you update.";
|
|
26
|
+
|
|
27
|
+
export const partIcon = (part: Pick<ProjectPartModel, "tier" | "capability">): string =>
|
|
28
|
+
part.tier === "project-researched" ? "flask-conical" : part.tier === "project-specific" ? (part.capability?.capability === "circuit" ? "circuit-board" : "box") : "package";
|
|
29
|
+
|
|
30
|
+
function Row({ part, actions, selected }: { part: ProjectPartModel; actions: ProjectPartsActions; selected: boolean }) {
|
|
31
|
+
const sync = partSync(part);
|
|
32
|
+
return (
|
|
33
|
+
<li
|
|
34
|
+
role="treeitem"
|
|
35
|
+
aria-selected={selected}
|
|
36
|
+
tabIndex={0}
|
|
37
|
+
onClick={() => actions.open(part.id)}
|
|
38
|
+
onKeyDown={(e) => e.key === "Enter" && actions.open(part.id)}
|
|
39
|
+
className={cx(
|
|
40
|
+
BODY,
|
|
41
|
+
"group flex min-h-8 cursor-pointer items-center gap-2 rounded-[4px] py-1 pr-1.5 pl-2 text-[13px] text-fg",
|
|
42
|
+
selected ? "bg-active outline outline-1 -outline-offset-1 outline-contrast-active" : "hover:bg-hover",
|
|
43
|
+
"focus-visible:outline focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-focus",
|
|
44
|
+
)}
|
|
45
|
+
>
|
|
46
|
+
{part.trust ? <PartStatusDot trust={part.trust} basis={part.trustBasis} /> : <span className="w-2 shrink-0" />}
|
|
47
|
+
<Icon name={partIcon(part)} size={14} strokeWidth={1.75} className="shrink-0 text-fg-muted" aria-hidden />
|
|
48
|
+
<span className="flex min-w-0 flex-1 flex-col">
|
|
49
|
+
<span className="truncate leading-[1.25]" title={[part.name, part.id, part.file].join("\n")}>
|
|
50
|
+
{part.name}
|
|
51
|
+
{part.instances > 1 && <span className="text-fg-muted"> ×{part.instances}</span>}
|
|
52
|
+
</span>
|
|
53
|
+
<span className={cx("truncate font-ui text-[11px] leading-[1.3]", sync.kind === "update" || sync.kind === "diverged" ? SYNC_TEXT[sync.tone] : "text-fg-muted")}>{part.busy ? `${part.busy}…` : partNote(part)}</span>
|
|
54
|
+
</span>
|
|
55
|
+
<span className="hidden shrink-0 items-center group-focus-within:flex group-hover:flex">
|
|
56
|
+
{part.status === "update-available" && actions.update && <RowAction icon="download" label={`Update to ${rev(part.recommended)}`} onClick={() => actions.update!(part.id)} />}
|
|
57
|
+
{part.status === "diverged" && actions.restore && <RowAction icon="undo-2" label={`Restore ${rev(part.revision)}`} onClick={() => actions.restore!(part.id)} />}
|
|
58
|
+
{part.tier === "catalog" && actions.compare && <RowAction icon="git-compare-arrows" label="Compare with the library" onClick={() => actions.compare!(part.id)} />}
|
|
59
|
+
{part.tier === "project-researched" && actions.publish && <RowAction icon="upload" label="Submit for verification" onClick={() => actions.publish!(part.id)} />}
|
|
60
|
+
{(actions.openSource || part.tier !== "catalog") && <RowAction icon="file-code" label="Open source" onClick={() => (actions.openSource ?? actions.open)(part.id)} />}
|
|
61
|
+
</span>
|
|
62
|
+
<span role="img" aria-label={`${sync.label}. ${sync.meaning}`} data-tooltip={`${sync.label}. ${sync.meaning}`} className={cx("grid size-5 shrink-0 place-items-center", SYNC_TEXT[sync.tone])}>
|
|
63
|
+
<Icon name={sync.icon} size={14} strokeWidth={sync.kind === "in-sync" ? 2.25 : 1.75} aria-hidden />
|
|
64
|
+
</span>
|
|
65
|
+
</li>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function ProjectParts({
|
|
70
|
+
model,
|
|
71
|
+
actions,
|
|
72
|
+
selected,
|
|
73
|
+
folded,
|
|
74
|
+
}: {
|
|
75
|
+
model: ProjectPartsModel;
|
|
76
|
+
actions: ProjectPartsActions;
|
|
77
|
+
/** The part shown in the Inspector or in a detail tab. */ selected?: string;
|
|
78
|
+
/** The groups folded away, kept by the host (PB-1039). */
|
|
79
|
+
folded?: FoldedGroups;
|
|
80
|
+
}) {
|
|
81
|
+
const groups = useMemo(() => partGroups(model.parts), [model.parts]);
|
|
82
|
+
const { closed, toggle, setAll } = useFolded(folded);
|
|
83
|
+
const allClosed = groups.length > 0 && groups.every((g) => closed.has(g.id));
|
|
84
|
+
const c = model.counts;
|
|
85
|
+
return (
|
|
86
|
+
<PartsSurface place="sidebar">
|
|
87
|
+
<div className="flex size-full min-w-[220px] flex-col">
|
|
88
|
+
<div className={cx(BODY, "flex shrink-0 flex-col gap-2 border-b border-line-subtle px-3 pt-2.5 pb-2.5 text-[12px]")}>
|
|
89
|
+
<div className="flex min-w-0 items-center gap-2">
|
|
90
|
+
{actions.openPage ? (
|
|
91
|
+
<button
|
|
92
|
+
type="button"
|
|
93
|
+
onClick={actions.openPage}
|
|
94
|
+
data-tooltip="Open the Project Parts page"
|
|
95
|
+
className="group/title flex min-w-0 flex-1 cursor-pointer items-center gap-1.5 rounded-[4px] text-left text-[13px] font-semibold text-fg hover:text-link focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-focus"
|
|
96
|
+
>
|
|
97
|
+
<Icon name="package" size={14} strokeWidth={1.75} className="shrink-0" aria-hidden />
|
|
98
|
+
<span className="truncate">
|
|
99
|
+
{model.waiting ? "Parts" : `${c.total} part${c.total === 1 ? "" : "s"}`}
|
|
100
|
+
</span>
|
|
101
|
+
<Icon name="arrow-up-right" size={13} className="shrink-0 text-fg-muted group-hover/title:text-link" aria-hidden />
|
|
102
|
+
</button>
|
|
103
|
+
) : (
|
|
104
|
+
<span className="flex min-w-0 flex-1 items-center gap-1.5 text-[13px] font-semibold text-fg">
|
|
105
|
+
<Icon name="package" size={14} strokeWidth={1.75} className="shrink-0" aria-hidden />
|
|
106
|
+
<span className="truncate">
|
|
107
|
+
{model.waiting ? "Parts" : `${c.total} part${c.total === 1 ? "" : "s"}`}
|
|
108
|
+
</span>
|
|
109
|
+
</span>
|
|
110
|
+
)}
|
|
111
|
+
{groups.length > 1 && <RowAction icon={allClosed ? "chevrons-up-down" : "chevrons-down-up"} label={allClosed ? "Expand all groups" : "Collapse all groups"} onClick={() => setAll(allClosed ? [] : groups.map((g) => g.id))} />}
|
|
112
|
+
{actions.check && <RowAction icon={model.loading ? "loader" : "refresh-cw"} label={model.checked ? "Check the libraries for updates again" : "Check the libraries for updates"} disabled={model.loading} onClick={actions.check} />}
|
|
113
|
+
</div>
|
|
114
|
+
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
|
|
115
|
+
{model.waiting ? (
|
|
116
|
+
<span className="text-fg-muted">{model.waiting}</span>
|
|
117
|
+
) : model.checked ? (
|
|
118
|
+
<span className={cx("flex items-center gap-1", c.updates ? "text-info" : "text-fg-muted")} data-tooltip={UPDATE_EXPLAINED}>
|
|
119
|
+
<Icon name="download" size={13} strokeWidth={1.75} aria-hidden /> {c.updates} update{c.updates === 1 ? "" : "s"}
|
|
120
|
+
</span>
|
|
121
|
+
) : (
|
|
122
|
+
<span className="text-fg-muted">{model.loading ? "Listing…" : "Not checked for updates yet"}</span>
|
|
123
|
+
)}
|
|
124
|
+
{c.diverged > 0 && (
|
|
125
|
+
<span className="flex items-center gap-1 text-warning">
|
|
126
|
+
<Icon name="git-compare-arrows" size={13} strokeWidth={1.75} aria-hidden /> {c.diverged} diverged
|
|
127
|
+
</span>
|
|
128
|
+
)}
|
|
129
|
+
</div>
|
|
130
|
+
</div>
|
|
131
|
+
<ScrollArea className="min-h-0 flex-1" contentClassName="px-1.5 pt-1 pb-4">
|
|
132
|
+
<ul role="tree" aria-label="Project parts" className="flex flex-col">
|
|
133
|
+
{groups.map((g) => {
|
|
134
|
+
const open = !closed.has(g.id);
|
|
135
|
+
return (
|
|
136
|
+
<li key={g.id} role="treeitem" aria-expanded={open} data-group={g.id} className="mt-3 first:mt-1.5">
|
|
137
|
+
<FoldHeading
|
|
138
|
+
open={open}
|
|
139
|
+
onToggle={() => toggle(g.id)}
|
|
140
|
+
className="h-7 px-1.5"
|
|
141
|
+
titleClassName={cx("text-[10.5px]", g.id === "updates" ? "text-info" : "text-fg-muted")}
|
|
142
|
+
right={<span className="grid h-4 min-w-4 shrink-0 place-items-center rounded-full bg-muted px-1 font-ui text-[10px] text-fg outline-1 -outline-offset-1 outline-contrast">{g.parts.length}</span>}
|
|
143
|
+
>
|
|
144
|
+
{g.title}
|
|
145
|
+
</FoldHeading>
|
|
146
|
+
{open && (
|
|
147
|
+
<ul role="group" className="mt-1 flex flex-col gap-0.5">
|
|
148
|
+
{g.parts.map((p) => (
|
|
149
|
+
<Row key={p.id} part={p} actions={actions} selected={p.id === selected} />
|
|
150
|
+
))}
|
|
151
|
+
</ul>
|
|
152
|
+
)}
|
|
153
|
+
</li>
|
|
154
|
+
);
|
|
155
|
+
})}
|
|
156
|
+
</ul>
|
|
157
|
+
{!groups.length && !model.loading && !model.error && !model.waiting && <p className={cx(BODY, "px-2 pt-4 text-[12.5px] text-fg-muted")}>The project has no parts yet. Add some from the library.</p>}
|
|
158
|
+
{model.error && <p className={cx(BODY, "px-2 pt-3 text-[12px] text-error")}>{model.error}</p>}
|
|
159
|
+
{(model.checked?.problems ?? []).map((m) => (
|
|
160
|
+
<p key={m} className={cx(BODY, "px-2 pt-2 text-[12px] text-warning")}>
|
|
161
|
+
Not checked: {m}
|
|
162
|
+
</p>
|
|
163
|
+
))}
|
|
164
|
+
{groups.length > 0 && (
|
|
165
|
+
<div className={cx(BODY, "mt-5 flex flex-col gap-2 border-t border-line-subtle px-2 pt-3 text-[11.5px] leading-snug text-fg-muted")}>
|
|
166
|
+
<PartStatusLegend />
|
|
167
|
+
<p>{UPDATE_EXPLAINED}</p>
|
|
168
|
+
</div>
|
|
169
|
+
)}
|
|
170
|
+
</ScrollArea>
|
|
171
|
+
</div>
|
|
172
|
+
</PartsSurface>
|
|
173
|
+
);
|
|
174
|
+
}
|