@featurevisor/catalog 0.0.1 → 3.1.0
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 +21 -0
- package/dist/assets/index-DB7fEbI6.js +25 -0
- package/dist/assets/index-DgA0Oqrg.css +2 -0
- package/dist/favicon.png +0 -0
- package/dist/index.html +14 -0
- package/dist/logo-text.png +0 -0
- package/lib/entityTypes.d.ts +16 -0
- package/lib/entityTypes.js +96 -0
- package/lib/entityTypes.js.map +1 -0
- package/lib/index.d.ts +1 -0
- package/lib/index.js +18 -0
- package/lib/index.js.map +1 -0
- package/lib/node/index.d.ts +74 -0
- package/lib/node/index.js +1368 -0
- package/lib/node/index.js.map +1 -0
- package/lib/types.d.ts +95 -0
- package/lib/types.js +3 -0
- package/lib/types.js.map +1 -0
- package/package.json +66 -13
- package/public/favicon.png +0 -0
- package/public/logo-text.png +0 -0
- package/src/App.tsx +62 -0
- package/src/api.spec.ts +14 -0
- package/src/api.ts +74 -0
- package/src/components/tests.spec.ts +134 -0
- package/src/components/tests.tsx +301 -0
- package/src/components/trees.tsx +202 -0
- package/src/components/ui.tsx +660 -0
- package/src/components/variables.tsx +700 -0
- package/src/components/variations.tsx +450 -0
- package/src/context/CatalogContext.tsx +30 -0
- package/src/entityTypes.spec.ts +13 -0
- package/src/entityTypes.ts +102 -0
- package/src/index.ts +1 -0
- package/src/main.tsx +26 -0
- package/src/node/index.spec.ts +380 -0
- package/src/node/index.ts +1906 -0
- package/src/pages/EntityDetailPage.tsx +1144 -0
- package/src/pages/HistoryPage.tsx +144 -0
- package/src/pages/HomePage.tsx +21 -0
- package/src/pages/ListPage.tsx +737 -0
- package/src/styles.css +59 -0
- package/src/testModel.ts +123 -0
- package/src/types.ts +112 -0
- package/src/vite-env.d.ts +1 -0
- package/index.js +0 -1
|
@@ -0,0 +1,660 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import ReactMarkdown from "react-markdown";
|
|
3
|
+
import { NavLink, Link, useLocation, useNavigate } from "react-router-dom";
|
|
4
|
+
|
|
5
|
+
import { fetchIndex } from "../api";
|
|
6
|
+
import type { CatalogEntityType, CatalogIndex, EntityPath } from "../types";
|
|
7
|
+
import {
|
|
8
|
+
decodeRouteSegment,
|
|
9
|
+
encodeRouteSegment,
|
|
10
|
+
entityLabels,
|
|
11
|
+
entityPaths,
|
|
12
|
+
entityPathToType,
|
|
13
|
+
getBasePath,
|
|
14
|
+
sortSetKeys,
|
|
15
|
+
} from "../entityTypes";
|
|
16
|
+
import { useCatalog } from "../context/CatalogContext";
|
|
17
|
+
|
|
18
|
+
type BadgeTone = "neutral" | "success" | "warning" | "danger" | "primary";
|
|
19
|
+
|
|
20
|
+
const toneClasses: Record<BadgeTone, string> = {
|
|
21
|
+
neutral: "border-pill bg-pill text-text",
|
|
22
|
+
success: "border-green-300 bg-success-surface text-text",
|
|
23
|
+
warning: "border-orange-300 bg-warning-surface text-text",
|
|
24
|
+
danger: "border-red-300 bg-danger-surface text-danger",
|
|
25
|
+
primary: "border-pill bg-pill text-text",
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export function Badge(props: { children: React.ReactNode; tone?: BadgeTone | "default" }) {
|
|
29
|
+
const tone = props.tone === "default" ? "neutral" : props.tone || "neutral";
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<span
|
|
33
|
+
className={`inline-flex rounded-full border px-2 py-0.5 text-xs font-medium ${toneClasses[tone]}`}
|
|
34
|
+
>
|
|
35
|
+
{props.children}
|
|
36
|
+
</span>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function Button(props: React.ButtonHTMLAttributes<HTMLButtonElement>) {
|
|
41
|
+
const { className = "", ...rest } = props;
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<button
|
|
45
|
+
className={`rounded border border-border bg-elevated px-4 py-2 text-sm font-bold text-muted shadow-sm hover:bg-background ${className}`}
|
|
46
|
+
{...rest}
|
|
47
|
+
/>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function CodeBlock(props: { value: unknown }) {
|
|
52
|
+
const value =
|
|
53
|
+
typeof props.value === "string" ? props.value : JSON.stringify(props.value, null, 2);
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<pre className="max-w-full whitespace-pre-wrap rounded border border-border bg-elevated p-4 text-xs text-text [overflow-wrap:anywhere]">
|
|
57
|
+
<code>{value}</code>
|
|
58
|
+
</pre>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function toMarkdownString(value: unknown): string | undefined {
|
|
63
|
+
if (value === undefined || value === null) {
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (typeof value === "string") {
|
|
68
|
+
return value;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (typeof value === "number" || typeof value === "boolean") {
|
|
72
|
+
return String(value);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (Array.isArray(value)) {
|
|
76
|
+
const lines = value
|
|
77
|
+
.map((item) => toMarkdownString(item))
|
|
78
|
+
.filter((item): item is string => Boolean(item?.trim()));
|
|
79
|
+
|
|
80
|
+
return lines.length > 0 ? lines.join("\n") : undefined;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function MarkdownContent(props: { value?: unknown }) {
|
|
87
|
+
const markdown = toMarkdownString(props.value);
|
|
88
|
+
|
|
89
|
+
if (!markdown?.trim()) {
|
|
90
|
+
return <span className="text-muted">n/a</span>;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
<div className="prose prose-sm prose-slate max-w-none text-text">
|
|
95
|
+
<ReactMarkdown>{markdown}</ReactMarkdown>
|
|
96
|
+
</div>
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function DescriptionField(props: { value?: unknown; showTopDivider?: boolean }) {
|
|
101
|
+
const showTopDivider = props.showTopDivider !== false;
|
|
102
|
+
|
|
103
|
+
return (
|
|
104
|
+
<div className={showTopDivider ? "border-t border-border pt-6" : undefined}>
|
|
105
|
+
<div className="text-[10px] font-semibold uppercase tracking-wider text-faint">
|
|
106
|
+
Description
|
|
107
|
+
</div>
|
|
108
|
+
<div className="mt-2 min-w-0 text-sm [overflow-wrap:anywhere]">
|
|
109
|
+
<MarkdownContent value={props.value} />
|
|
110
|
+
</div>
|
|
111
|
+
</div>
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function LabelValueBadge(props: {
|
|
116
|
+
label: React.ReactNode;
|
|
117
|
+
value: React.ReactNode;
|
|
118
|
+
to?: string;
|
|
119
|
+
compact?: boolean;
|
|
120
|
+
}) {
|
|
121
|
+
const valueContent = props.to ? (
|
|
122
|
+
<Link to={props.to} className="font-medium text-primary hover:underline">
|
|
123
|
+
{props.value}
|
|
124
|
+
</Link>
|
|
125
|
+
) : (
|
|
126
|
+
<span className="font-medium text-text">{props.value}</span>
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
if (props.compact) {
|
|
130
|
+
return (
|
|
131
|
+
<span className="inline-flex h-5 shrink-0 overflow-hidden rounded-md border border-border/70 text-[10px] leading-none">
|
|
132
|
+
<span className="flex items-center bg-elevated px-1.5 text-muted">{props.label}</span>
|
|
133
|
+
<span className="flex items-center bg-surface px-1.5 text-text">{valueContent}</span>
|
|
134
|
+
</span>
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return (
|
|
139
|
+
<span className="inline-flex shrink-0 overflow-hidden rounded-md border border-border text-xs shadow-sm">
|
|
140
|
+
<span className="bg-primary/10 px-2 py-1 font-medium text-primary">{props.label}</span>
|
|
141
|
+
<span className="bg-surface px-2 py-1">{valueContent}</span>
|
|
142
|
+
</span>
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function OverviewChip(props: { children: React.ReactNode; className?: string }) {
|
|
147
|
+
return (
|
|
148
|
+
<span
|
|
149
|
+
className={[
|
|
150
|
+
"inline-flex items-center rounded-full bg-slate-100 px-2.5 py-0.5 text-xs text-text",
|
|
151
|
+
props.className || "",
|
|
152
|
+
].join(" ")}
|
|
153
|
+
>
|
|
154
|
+
{props.children}
|
|
155
|
+
</span>
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function OverviewChipLink(props: { to: string; children: React.ReactNode }) {
|
|
160
|
+
return (
|
|
161
|
+
<Link
|
|
162
|
+
to={props.to}
|
|
163
|
+
className="inline-flex items-center rounded-full bg-slate-100 px-2.5 py-0.5 text-xs font-medium text-primary transition-colors hover:bg-slate-200"
|
|
164
|
+
>
|
|
165
|
+
{props.children}
|
|
166
|
+
</Link>
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function OverviewMetaPanel(props: { children: React.ReactNode }) {
|
|
171
|
+
return (
|
|
172
|
+
<div className="rounded-xl bg-elevated px-5 py-4">
|
|
173
|
+
<dl className="space-y-3.5">{props.children}</dl>
|
|
174
|
+
</div>
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function OverviewMetaRow(props: { label: string; children?: React.ReactNode }) {
|
|
179
|
+
if (!props.children) {
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return (
|
|
184
|
+
<div className="flex flex-col gap-1 sm:flex-row sm:items-baseline sm:gap-5">
|
|
185
|
+
<dt className="shrink-0 text-[10px] font-semibold uppercase tracking-wider text-faint sm:w-[4.75rem]">
|
|
186
|
+
{props.label}
|
|
187
|
+
</dt>
|
|
188
|
+
<dd className="flex min-w-0 flex-1 flex-wrap items-center gap-x-2 gap-y-1.5">
|
|
189
|
+
{props.children}
|
|
190
|
+
</dd>
|
|
191
|
+
</div>
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export function OverviewLabeledRow(props: { label: string; children?: React.ReactNode }) {
|
|
196
|
+
if (!props.children) {
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return (
|
|
201
|
+
<div className="flex flex-wrap items-center gap-x-3 gap-y-2">
|
|
202
|
+
<span className="shrink-0 text-xs font-semibold uppercase tracking-wide text-faint">
|
|
203
|
+
{props.label}
|
|
204
|
+
</span>
|
|
205
|
+
<div className="flex min-w-0 flex-wrap items-center gap-2">{props.children}</div>
|
|
206
|
+
</div>
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export function OverviewSection(props: { title: string; children?: React.ReactNode }) {
|
|
211
|
+
if (!props.children) {
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return (
|
|
216
|
+
<section>
|
|
217
|
+
<h2 className="mb-2 text-xs font-semibold uppercase tracking-wide text-faint">
|
|
218
|
+
{props.title}
|
|
219
|
+
</h2>
|
|
220
|
+
{props.children}
|
|
221
|
+
</section>
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export function EmptyState(props: { title: string; description?: string }) {
|
|
226
|
+
return (
|
|
227
|
+
<div className="rounded-lg border border-border bg-elevated px-6 py-10 text-center">
|
|
228
|
+
<p className="text-sm text-muted">{props.title}</p>
|
|
229
|
+
{props.description && (
|
|
230
|
+
<p className="mt-2 max-w-md text-sm text-faint [overflow-wrap:anywhere]">
|
|
231
|
+
{props.description}
|
|
232
|
+
</p>
|
|
233
|
+
)}
|
|
234
|
+
</div>
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export function PageHeader(props: {
|
|
239
|
+
title: React.ReactNode;
|
|
240
|
+
titleAction?: React.ReactNode;
|
|
241
|
+
description?: React.ReactNode;
|
|
242
|
+
actions?: React.ReactNode;
|
|
243
|
+
}) {
|
|
244
|
+
return (
|
|
245
|
+
<div className="mb-6 flex flex-col justify-between gap-4 border-b border-border px-6 pb-4 pt-8 md:flex-row md:items-start">
|
|
246
|
+
<div className="min-w-0 flex-1">
|
|
247
|
+
<div className="group flex min-w-0 items-center gap-2">
|
|
248
|
+
<h1 className="min-w-0 text-3xl font-black text-text [overflow-wrap:anywhere]">
|
|
249
|
+
{props.title}
|
|
250
|
+
</h1>
|
|
251
|
+
{props.titleAction ? <div className="shrink-0">{props.titleAction}</div> : null}
|
|
252
|
+
</div>
|
|
253
|
+
{props.description && (
|
|
254
|
+
<div className="mt-2 min-w-0 text-sm text-muted [overflow-wrap:anywhere]">
|
|
255
|
+
{props.description}
|
|
256
|
+
</div>
|
|
257
|
+
)}
|
|
258
|
+
</div>
|
|
259
|
+
{props.actions ? <div className="shrink-0">{props.actions}</div> : null}
|
|
260
|
+
</div>
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function sidebarClass({ isActive }: { isActive: boolean }) {
|
|
265
|
+
return [
|
|
266
|
+
"flex items-center justify-between rounded-lg px-3 py-2 text-sm font-bold",
|
|
267
|
+
isActive
|
|
268
|
+
? "bg-header-active !text-header-text"
|
|
269
|
+
: "text-muted hover:bg-elevated hover:text-text",
|
|
270
|
+
].join(" ");
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function Sidebar(props: { setKey?: string }) {
|
|
274
|
+
const [index, setIndex] = React.useState<CatalogIndex | null>(null);
|
|
275
|
+
const basePath = getBasePath(props.setKey);
|
|
276
|
+
|
|
277
|
+
React.useEffect(() => {
|
|
278
|
+
setIndex(null);
|
|
279
|
+
fetchIndex(props.setKey)
|
|
280
|
+
.then(setIndex)
|
|
281
|
+
.catch(() => setIndex(null));
|
|
282
|
+
}, [props.setKey]);
|
|
283
|
+
|
|
284
|
+
return (
|
|
285
|
+
<aside className="rounded-lg bg-surface p-4 shadow-md ring-1 ring-black/5 md:w-56">
|
|
286
|
+
<div className="mb-3 text-xs font-black uppercase tracking-wide text-muted">
|
|
287
|
+
<span className="block px-3">{props.setKey ? "Set" : "Project"}</span>
|
|
288
|
+
</div>
|
|
289
|
+
<nav className="space-y-1">
|
|
290
|
+
{entityPaths.map((entityPath) => {
|
|
291
|
+
const type = entityPathToType[entityPath];
|
|
292
|
+
|
|
293
|
+
return (
|
|
294
|
+
<NavLink key={entityPath} to={`${basePath}/${entityPath}`} className={sidebarClass}>
|
|
295
|
+
<span>{entityLabels[type].plural}</span>
|
|
296
|
+
<span className="rounded-full bg-pill px-2 py-0.5 text-xs font-black text-header">
|
|
297
|
+
{index?.counts[type] ?? "-"}
|
|
298
|
+
</span>
|
|
299
|
+
</NavLink>
|
|
300
|
+
);
|
|
301
|
+
})}
|
|
302
|
+
<NavLink to={`${basePath}/history`} className={sidebarClass}>
|
|
303
|
+
History
|
|
304
|
+
</NavLink>
|
|
305
|
+
</nav>
|
|
306
|
+
</aside>
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function isEntityPath(value: string): value is EntityPath {
|
|
311
|
+
return entityPaths.indexOf(value as EntityPath) !== -1;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function hasEntity(index: CatalogIndex, entityPath: EntityPath, entityKey: string) {
|
|
315
|
+
const type = entityPathToType[entityPath];
|
|
316
|
+
const entities = index.entities[type] || [];
|
|
317
|
+
|
|
318
|
+
return entities.some((entity) => entity.key === entityKey);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
async function getSetSwitchPath(pathname: string, nextSetKey: string) {
|
|
322
|
+
const encodedSetKey = encodeRouteSegment(nextSetKey);
|
|
323
|
+
const listMatch = pathname.match(/^\/sets\/[^/]+\/([^/]+)$/);
|
|
324
|
+
|
|
325
|
+
if (listMatch && isEntityPath(listMatch[1])) {
|
|
326
|
+
return `/sets/${encodedSetKey}/${listMatch[1]}`;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
if (pathname.match(/^\/sets\/[^/]+\/history$/)) {
|
|
330
|
+
return `/sets/${encodedSetKey}/history`;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const detailMatch = pathname.match(/^\/sets\/[^/]+\/([^/]+)\/([^/]+)(\/.*)?$/);
|
|
334
|
+
|
|
335
|
+
if (detailMatch && isEntityPath(detailMatch[1])) {
|
|
336
|
+
const entityPath = detailMatch[1];
|
|
337
|
+
const entityKey = decodeRouteSegment(detailMatch[2]);
|
|
338
|
+
const suffix = detailMatch[3] || "";
|
|
339
|
+
|
|
340
|
+
try {
|
|
341
|
+
const index = await fetchIndex(nextSetKey);
|
|
342
|
+
|
|
343
|
+
if (hasEntity(index, entityPath, entityKey)) {
|
|
344
|
+
return `/sets/${encodedSetKey}/${entityPath}/${encodeRouteSegment(entityKey)}${suffix}`;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
return `/sets/${encodedSetKey}/${entityPath}`;
|
|
348
|
+
} catch {
|
|
349
|
+
return `/sets/${encodedSetKey}/${entityPath}`;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
return `/sets/${encodedSetKey}/features`;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function SetSwitcher(props: { currentSetKey?: string }) {
|
|
357
|
+
const { manifest } = useCatalog();
|
|
358
|
+
const location = useLocation();
|
|
359
|
+
const navigate = useNavigate();
|
|
360
|
+
const setSelectId = React.useId();
|
|
361
|
+
const setSelectRef = React.useRef<HTMLSelectElement | null>(null);
|
|
362
|
+
const setKeys = sortSetKeys(manifest.setKeys);
|
|
363
|
+
const selectedSetKey = props.currentSetKey || setKeys[0] || "";
|
|
364
|
+
|
|
365
|
+
if (!manifest.sets || manifest.setKeys.length === 0) {
|
|
366
|
+
return null;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function openSetPicker() {
|
|
370
|
+
const select = setSelectRef.current;
|
|
371
|
+
|
|
372
|
+
if (!select) {
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
select.focus();
|
|
377
|
+
|
|
378
|
+
if (
|
|
379
|
+
typeof (select as HTMLSelectElement & { showPicker?: () => void }).showPicker === "function"
|
|
380
|
+
) {
|
|
381
|
+
(select as HTMLSelectElement & { showPicker: () => void }).showPicker();
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
return (
|
|
386
|
+
<label
|
|
387
|
+
htmlFor={setSelectId}
|
|
388
|
+
className="relative inline-flex cursor-pointer items-center gap-2 rounded-lg bg-header-active px-3 py-1.5 text-sm font-semibold text-header-text"
|
|
389
|
+
onClick={(event) => {
|
|
390
|
+
if (event.target instanceof HTMLSelectElement) {
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
openSetPicker();
|
|
395
|
+
}}
|
|
396
|
+
>
|
|
397
|
+
<span className="text-xs font-black uppercase tracking-wide text-pill">Set</span>
|
|
398
|
+
<select
|
|
399
|
+
id={setSelectId}
|
|
400
|
+
ref={setSelectRef}
|
|
401
|
+
value={selectedSetKey}
|
|
402
|
+
onChange={async (event) => {
|
|
403
|
+
navigate(await getSetSwitchPath(location.pathname, event.target.value));
|
|
404
|
+
}}
|
|
405
|
+
className="max-w-44 appearance-none bg-transparent pr-7 font-black text-header-text outline-none"
|
|
406
|
+
aria-label="Switch catalog set"
|
|
407
|
+
>
|
|
408
|
+
{setKeys.map((setKey) => (
|
|
409
|
+
<option key={setKey} value={setKey}>
|
|
410
|
+
{setKey}
|
|
411
|
+
</option>
|
|
412
|
+
))}
|
|
413
|
+
</select>
|
|
414
|
+
<svg
|
|
415
|
+
aria-hidden="true"
|
|
416
|
+
viewBox="0 0 20 20"
|
|
417
|
+
fill="none"
|
|
418
|
+
className="pointer-events-none absolute right-3 h-4 w-4 text-pill"
|
|
419
|
+
>
|
|
420
|
+
<path
|
|
421
|
+
d="M6 8l4 4 4-4"
|
|
422
|
+
stroke="currentColor"
|
|
423
|
+
strokeWidth="2"
|
|
424
|
+
strokeLinecap="round"
|
|
425
|
+
strokeLinejoin="round"
|
|
426
|
+
/>
|
|
427
|
+
</svg>
|
|
428
|
+
</label>
|
|
429
|
+
);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function formatGeneratedAt(value: string) {
|
|
433
|
+
const date = new Date(value);
|
|
434
|
+
|
|
435
|
+
if (Number.isNaN(date.getTime())) {
|
|
436
|
+
return value;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
return date.toLocaleString();
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function RepositoryIcon(props: { provider?: string; className?: string }) {
|
|
443
|
+
const iconClassName = [
|
|
444
|
+
"h-5 w-5 shrink-0 transition-colors",
|
|
445
|
+
props.className || "fill-white/80",
|
|
446
|
+
].join(" ");
|
|
447
|
+
|
|
448
|
+
if (props.provider === "github") {
|
|
449
|
+
return (
|
|
450
|
+
<svg aria-hidden="true" className={iconClassName} viewBox="0 0 16 16">
|
|
451
|
+
<path d="M8 0C3.58 0 0 3.69 0 8.24c0 3.64 2.29 6.72 5.47 7.81.4.08.55-.18.55-.4 0-.2-.01-.86-.01-1.56-2.01.38-2.53-.5-2.69-.96-.09-.24-.48-.96-.82-1.15-.28-.16-.68-.55-.01-.56.63-.01 1.08.6 1.23.85.72 1.25 1.87.9 2.33.69.07-.54.28-.9.51-1.11-1.78-.21-3.64-.92-3.64-4.07 0-.9.31-1.64.82-2.22-.08-.21-.36-1.05.08-2.19 0 0 .67-.22 2.2.85A7.43 7.43 0 0 1 8 3.94c.68 0 1.36.09 2 .28 1.52-1.07 2.19-.85 2.19-.85.44 1.14.16 1.98.08 2.19.51.58.82 1.32.82 2.22 0 3.16-1.87 3.86-3.65 4.07.29.26.54.76.54 1.54 0 1.11-.01 2.01-.01 2.28 0 .22.15.48.55.4A8.13 8.13 0 0 0 16 8.24C16 3.69 12.42 0 8 0Z" />
|
|
452
|
+
</svg>
|
|
453
|
+
);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
if (props.provider === "gitlab") {
|
|
457
|
+
return (
|
|
458
|
+
<svg aria-hidden="true" className={iconClassName} viewBox="0 0 24 24">
|
|
459
|
+
<path d="m22.75 9.77-.03-.08-2.17-6.69a.57.57 0 0 0-.55-.39.58.58 0 0 0-.52.35l-1.47 4.48H5.99L4.52 2.96A.58.58 0 0 0 4 2.61a.57.57 0 0 0-.55.39L1.28 9.69l-.03.08a1.54 1.54 0 0 0 .51 1.73l.01.01 10.22 7.43 10.24-7.44.01-.01a1.54 1.54 0 0 0 .51-1.72Z" />
|
|
460
|
+
</svg>
|
|
461
|
+
);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
if (props.provider === "bitbucket") {
|
|
465
|
+
return (
|
|
466
|
+
<svg aria-hidden="true" className={iconClassName} viewBox="0 0 24 24">
|
|
467
|
+
<path d="M2.19 3.25a.77.77 0 0 0-.76.89l2.7 16.42a1.02 1.02 0 0 0 1 .86H18.9a1.02 1.02 0 0 0 1-.82l2.69-16.46a.77.77 0 0 0-.76-.89H2.19Zm13.36 10.71H9.46l-1.1-5.83h8.25l-1.06 5.83Z" />
|
|
468
|
+
</svg>
|
|
469
|
+
);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
return null;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function isKnownRepositoryProvider(provider?: string) {
|
|
476
|
+
return provider === "github" || provider === "gitlab" || provider === "bitbucket";
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
export function AppShell(props: { children: React.ReactNode }) {
|
|
480
|
+
const { manifest } = useCatalog();
|
|
481
|
+
const location = useLocation();
|
|
482
|
+
const setKeyMatch = location.pathname.match(/^\/sets\/([^/]+)/);
|
|
483
|
+
const setKey = setKeyMatch ? decodeRouteSegment(setKeyMatch[1]) : undefined;
|
|
484
|
+
const showSidebar = !manifest.sets || Boolean(setKey);
|
|
485
|
+
|
|
486
|
+
return (
|
|
487
|
+
<div className="min-h-screen bg-background text-text">
|
|
488
|
+
<header className="bg-header">
|
|
489
|
+
<nav className="mx-auto flex max-w-5xl items-center justify-between gap-4 px-3 py-3 sm:px-4">
|
|
490
|
+
<div className="flex min-w-0 flex-1 items-center">
|
|
491
|
+
<NavLink
|
|
492
|
+
to="/"
|
|
493
|
+
className={[
|
|
494
|
+
"flex min-w-0 max-w-full items-center gap-2.5 rounded-lg py-1 pr-2 outline-none",
|
|
495
|
+
"ring-offset-2 ring-offset-header focus-visible:ring-2 focus-visible:ring-header-text",
|
|
496
|
+
].join(" ")}
|
|
497
|
+
aria-label="Featurevisor Catalog home"
|
|
498
|
+
>
|
|
499
|
+
<img
|
|
500
|
+
src="/favicon.png"
|
|
501
|
+
alt=""
|
|
502
|
+
width={32}
|
|
503
|
+
height={32}
|
|
504
|
+
className="h-8 w-8 shrink-0 object-contain"
|
|
505
|
+
decoding="async"
|
|
506
|
+
/>
|
|
507
|
+
<img
|
|
508
|
+
src="/logo-text.png"
|
|
509
|
+
alt=""
|
|
510
|
+
className="h-auto max-h-4 min-w-0 shrink object-contain object-left pl-2"
|
|
511
|
+
decoding="async"
|
|
512
|
+
/>
|
|
513
|
+
</NavLink>
|
|
514
|
+
</div>
|
|
515
|
+
|
|
516
|
+
<div className="flex items-center gap-3">
|
|
517
|
+
<SetSwitcher currentSetKey={setKey} />
|
|
518
|
+
{manifest.links?.repository && isKnownRepositoryProvider(manifest.links.provider) && (
|
|
519
|
+
<a
|
|
520
|
+
href={manifest.links.repository}
|
|
521
|
+
target="_blank"
|
|
522
|
+
rel="noreferrer"
|
|
523
|
+
className="group inline-flex shrink-0"
|
|
524
|
+
aria-label={`Open ${manifest.links.provider || "repository"}`}
|
|
525
|
+
>
|
|
526
|
+
<RepositoryIcon
|
|
527
|
+
provider={manifest.links.provider}
|
|
528
|
+
className="fill-white/80 group-hover:fill-white"
|
|
529
|
+
/>
|
|
530
|
+
</a>
|
|
531
|
+
)}
|
|
532
|
+
</div>
|
|
533
|
+
</nav>
|
|
534
|
+
</header>
|
|
535
|
+
|
|
536
|
+
<main className="m-8 mx-auto max-w-5xl">
|
|
537
|
+
<div className={showSidebar ? "items-start gap-6 md:flex" : ""}>
|
|
538
|
+
{showSidebar && <Sidebar setKey={setKey} />}
|
|
539
|
+
<div className={["min-w-0", showSidebar ? "flex-1" : "w-full"].filter(Boolean).join(" ")}>
|
|
540
|
+
<section className="overflow-hidden rounded-lg bg-surface shadow">
|
|
541
|
+
{props.children}
|
|
542
|
+
</section>
|
|
543
|
+
<footer className="mt-4 pt-3 text-center">
|
|
544
|
+
<p className="pb-2 text-xs leading-5 text-faint">
|
|
545
|
+
Generated at {formatGeneratedAt(manifest.generatedAt)}
|
|
546
|
+
</p>
|
|
547
|
+
<p className="pb-5 text-xs font-medium leading-5 text-muted">
|
|
548
|
+
Built using{" "}
|
|
549
|
+
<a
|
|
550
|
+
target="_blank"
|
|
551
|
+
rel="noreferrer"
|
|
552
|
+
href="https://featurevisor.com"
|
|
553
|
+
className="font-semibold hover:underline"
|
|
554
|
+
>
|
|
555
|
+
Featurevisor
|
|
556
|
+
</a>
|
|
557
|
+
</p>
|
|
558
|
+
</footer>
|
|
559
|
+
</div>
|
|
560
|
+
</div>
|
|
561
|
+
</main>
|
|
562
|
+
</div>
|
|
563
|
+
);
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
export function Tabs(props: {
|
|
567
|
+
items: { to: string; label: string }[];
|
|
568
|
+
children?: React.ReactNode;
|
|
569
|
+
}) {
|
|
570
|
+
return (
|
|
571
|
+
<div>
|
|
572
|
+
<nav className="flex overflow-x-auto border-b border-border">
|
|
573
|
+
{props.items.map((item) => (
|
|
574
|
+
<NavLink
|
|
575
|
+
key={item.to}
|
|
576
|
+
to={item.to}
|
|
577
|
+
end={item.to === "."}
|
|
578
|
+
className={({ isActive }) =>
|
|
579
|
+
[
|
|
580
|
+
"inline-block min-w-24 shrink-0 border-b-2 px-3 pb-4 pt-2 text-center text-sm font-medium",
|
|
581
|
+
isActive
|
|
582
|
+
? "border-primary text-primary"
|
|
583
|
+
: "border-transparent text-muted hover:border-border hover:text-text",
|
|
584
|
+
].join(" ")
|
|
585
|
+
}
|
|
586
|
+
>
|
|
587
|
+
{item.label}
|
|
588
|
+
</NavLink>
|
|
589
|
+
))}
|
|
590
|
+
</nav>
|
|
591
|
+
{props.children ? <div className="px-6 py-6">{props.children}</div> : null}
|
|
592
|
+
</div>
|
|
593
|
+
);
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
export function Card(props: { children: React.ReactNode; className?: string }) {
|
|
597
|
+
return (
|
|
598
|
+
<section className={`rounded border border-border bg-surface shadow ${props.className || ""}`}>
|
|
599
|
+
{props.children}
|
|
600
|
+
</section>
|
|
601
|
+
);
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
export function EntityKey(props: { value: string; className?: string }) {
|
|
605
|
+
return (
|
|
606
|
+
<span
|
|
607
|
+
className={["inline leading-snug [overflow-wrap:anywhere]", props.className || ""].join(" ")}
|
|
608
|
+
>
|
|
609
|
+
{props.value.split(".").flatMap((part, index, parts) => {
|
|
610
|
+
const nodes: React.ReactNode[] = [part];
|
|
611
|
+
if (index < parts.length - 1) {
|
|
612
|
+
nodes.push(
|
|
613
|
+
<React.Fragment key={`dot-${index}`}>
|
|
614
|
+
.<wbr />
|
|
615
|
+
</React.Fragment>,
|
|
616
|
+
);
|
|
617
|
+
}
|
|
618
|
+
return nodes;
|
|
619
|
+
})}
|
|
620
|
+
</span>
|
|
621
|
+
);
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
export function FieldGrid(props: {
|
|
625
|
+
fields: { label: string; value: React.ReactNode; fullWidth?: boolean }[];
|
|
626
|
+
}) {
|
|
627
|
+
return (
|
|
628
|
+
<dl className="grid grid-cols-1 gap-x-4 gap-y-8 md:grid-cols-2">
|
|
629
|
+
{props.fields.map((field) => (
|
|
630
|
+
<div key={field.label} className={field.fullWidth ? "md:col-span-2" : ""}>
|
|
631
|
+
<dt className="text-sm font-medium text-muted">{field.label}</dt>
|
|
632
|
+
<dd className="mt-1 min-w-0 text-sm [overflow-wrap:anywhere]">{field.value || "n/a"}</dd>
|
|
633
|
+
</div>
|
|
634
|
+
))}
|
|
635
|
+
</dl>
|
|
636
|
+
);
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
export function formatList(values?: string[]) {
|
|
640
|
+
if (!values || values.length === 0) {
|
|
641
|
+
return <span className="text-muted">none</span>;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
return (
|
|
645
|
+
<div className="flex flex-wrap gap-2">
|
|
646
|
+
{values.map((value) => (
|
|
647
|
+
<Badge key={value}>{value}</Badge>
|
|
648
|
+
))}
|
|
649
|
+
</div>
|
|
650
|
+
);
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
export function getEntityTone(
|
|
654
|
+
type: CatalogEntityType,
|
|
655
|
+
): "neutral" | "success" | "warning" | "danger" {
|
|
656
|
+
if (type === "feature") return "success";
|
|
657
|
+
if (type === "segment") return "warning";
|
|
658
|
+
if (type === "test") return "danger";
|
|
659
|
+
return "neutral";
|
|
660
|
+
}
|