@messagevisor/catalog 0.0.1 → 0.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.
Files changed (58) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +7 -0
  3. package/dist/assets/index-CfGbXx4X.css +1 -0
  4. package/dist/assets/index-r8ugP5JL.js +73 -0
  5. package/dist/favicon.png +0 -0
  6. package/dist/index.html +14 -0
  7. package/dist/logo-text.png +0 -0
  8. package/lib/index.d.ts +1 -0
  9. package/lib/index.js +18 -0
  10. package/lib/index.js.map +1 -0
  11. package/lib/node/formatExamplePreview.d.ts +10 -0
  12. package/lib/node/formatExamplePreview.js +79 -0
  13. package/lib/node/formatExamplePreview.js.map +1 -0
  14. package/lib/node/index.d.ts +191 -0
  15. package/lib/node/index.js +1645 -0
  16. package/lib/node/index.js.map +1 -0
  17. package/package.json +59 -13
  18. package/src/App.tsx +73 -0
  19. package/src/api.spec.ts +42 -0
  20. package/src/api.ts +87 -0
  21. package/src/catalogBrandAssets.ts +8 -0
  22. package/src/components/details/ConditionTree.tsx +146 -0
  23. package/src/components/details/FieldGrid.tsx +16 -0
  24. package/src/components/details/GroupSegmentTree.tsx +73 -0
  25. package/src/components/details/MarkdownContent.tsx +23 -0
  26. package/src/components/details/TranslationsTable.tsx +263 -0
  27. package/src/components/details/UsageLinks.tsx +29 -0
  28. package/src/components/history/HistoryTimeline.tsx +122 -0
  29. package/src/components/layout/AppShell.tsx +338 -0
  30. package/src/components/layout/PageHeader.tsx +13 -0
  31. package/src/components/layout/Tabs.tsx +35 -0
  32. package/src/components/lists/EntityList.tsx +451 -0
  33. package/src/components/ui/Badge.tsx +21 -0
  34. package/src/components/ui/Button.tsx +12 -0
  35. package/src/components/ui/Card.tsx +9 -0
  36. package/src/components/ui/CodeBlock.tsx +7 -0
  37. package/src/components/ui/EmptyState.tsx +8 -0
  38. package/src/components/ui/Input.tsx +12 -0
  39. package/src/components/ui/LabelValueBadge.tsx +55 -0
  40. package/src/config.ts +2 -0
  41. package/src/context/CatalogContext.tsx +50 -0
  42. package/src/entityTypes.ts +49 -0
  43. package/src/index.ts +1 -0
  44. package/src/main.tsx +28 -0
  45. package/src/node/formatExamplePreview.ts +85 -0
  46. package/src/node/index.spec.ts +713 -0
  47. package/src/node/index.ts +2007 -0
  48. package/src/pages/EntityDetailPage.tsx +3345 -0
  49. package/src/pages/HistoryPage.tsx +26 -0
  50. package/src/pages/HomePage.tsx +21 -0
  51. package/src/pages/ListPage.tsx +59 -0
  52. package/src/styles.css +95 -0
  53. package/src/theme.ts +36 -0
  54. package/src/types.ts +127 -0
  55. package/src/utils/formatCatalogTimestamp.ts +77 -0
  56. package/src/utils/hashTranslationValue.spec.ts +20 -0
  57. package/src/utils/hashTranslationValue.ts +22 -0
  58. package/src/utils/searchQuery.ts +46 -0
@@ -0,0 +1,451 @@
1
+ import * as React from "react";
2
+ import { Link, useSearchParams } from "react-router-dom";
3
+
4
+ import type { TranslationShard } from "../../api";
5
+ import { fetchTranslationShard } from "../../api";
6
+ import type { EntitySummary, EntityType } from "../../types";
7
+ import { entityLabels, getEntityRoute } from "../../entityTypes";
8
+ import { Badge } from "../ui/Badge";
9
+ import { EmptyState } from "../ui/EmptyState";
10
+ import { Input } from "../ui/Input";
11
+ import { Button } from "../ui/Button";
12
+ import { CATALOG_LIST_INITIAL_LIMIT } from "../../config";
13
+ import type { ParsedQuery } from "../../utils/searchQuery";
14
+ import { parseQuery } from "../../utils/searchQuery";
15
+
16
+ function matchesQuery(
17
+ entity: EntitySummary,
18
+ parsed: ParsedQuery,
19
+ translationShard: TranslationShard | null,
20
+ ): boolean {
21
+ const { freeText, qualifiers } = parsed;
22
+
23
+ // Free text: key only
24
+ if (freeText.length > 0) {
25
+ const key = entity.key.toLowerCase();
26
+ if (!freeText.every((term) => key.includes(term))) return false;
27
+ }
28
+
29
+ const hasOverrides = qualifiers.some((q) => q.key === "has" && q.value === "overrides");
30
+ const localeQuals = qualifiers
31
+ .filter((q) => q.key === "locale")
32
+ .map((q) => q.value.toLowerCase());
33
+
34
+ for (const q of qualifiers) {
35
+ switch (q.key) {
36
+ case "description": {
37
+ const desc = (entity.description || "").toLowerCase();
38
+ if (!desc.includes(q.value.toLowerCase())) return false;
39
+ break;
40
+ }
41
+ case "target": {
42
+ const targets = (entity.targets || []).map((s) => s.toLowerCase());
43
+ if (!targets.includes(q.value.toLowerCase())) return false;
44
+ break;
45
+ }
46
+ case "is": {
47
+ if (q.value === "deprecated" && !entity.deprecated) return false;
48
+ if (q.value === "archived" && !entity.archived) return false;
49
+ break;
50
+ }
51
+ case "has": {
52
+ if (q.value === "overrides") {
53
+ if (localeQuals.length > 0) {
54
+ // has:overrides locale:X — overrides must cover that locale
55
+ const overrideLocales = (entity.overrideLocales || []).map((l) => l.toLowerCase());
56
+ if (!localeQuals.every((l) => overrideLocales.includes(l))) return false;
57
+ } else {
58
+ if (!entity.overrideLocales?.length) return false;
59
+ }
60
+ }
61
+ break;
62
+ }
63
+ case "locale": {
64
+ // Standalone locale: — direct translations only; handled by has:overrides when combined
65
+ if (!hasOverrides) {
66
+ const locales = (entity.locales || []).map((l) => l.toLowerCase());
67
+ if (!locales.includes(q.value.toLowerCase())) return false;
68
+ }
69
+ break;
70
+ }
71
+ case "translation": {
72
+ if (q.value.length < 3) return true; // require 3+ chars; don't filter otherwise
73
+ if (!translationShard) return true; // optimistically include while loading
74
+ const values = translationShard[entity.key];
75
+ if (!values || values.length === 0) return false;
76
+ if (q.value.length === 3) break; // shard presence is sufficient for exact 3-char terms
77
+ const term = q.value.toLowerCase();
78
+ if (!values.some((v) => v.includes(term))) return false; // values are pre-lowercased
79
+ break;
80
+ }
81
+ }
82
+ }
83
+
84
+ return true;
85
+ }
86
+
87
+ // ---- Query hints ----
88
+
89
+ function getQueryHints(
90
+ type: EntityType,
91
+ firstTargetKey: string | undefined,
92
+ firstLocaleKey: string | undefined,
93
+ ): string[] | null {
94
+ const target = firstTargetKey;
95
+ const locale = firstLocaleKey;
96
+
97
+ if (type === "message") {
98
+ return [
99
+ 'translation:"keyword"',
100
+ ...(target ? [`target:${target}`] : []),
101
+ ...(locale ? [`locale:${locale}`] : []),
102
+ 'description:"keyword"',
103
+ "has:overrides",
104
+ ...(locale ? [`has:overrides locale:${locale}`] : ["has:overrides"]),
105
+ "is:deprecated",
106
+ "is:archived",
107
+ ];
108
+ }
109
+
110
+ return null;
111
+ }
112
+
113
+ function QueryHints({
114
+ type,
115
+ query,
116
+ firstTargetKey,
117
+ firstLocaleKey,
118
+ onHintClick,
119
+ }: {
120
+ type: EntityType;
121
+ query: string;
122
+ firstTargetKey: string | undefined;
123
+ firstLocaleKey: string | undefined;
124
+ onHintClick: (hint: string) => void;
125
+ }) {
126
+ const hints = getQueryHints(type, firstTargetKey, firstLocaleKey);
127
+ if (!hints) return null;
128
+
129
+ return (
130
+ <div className="flex flex-wrap items-center gap-x-2 gap-y-1.5 pt-2 text-xs text-muted">
131
+ <span className="shrink-0">Try:</span>
132
+ {hints.map((hint) => {
133
+ const isActive = query
134
+ .trim()
135
+ .split(/\s+/)
136
+ .some((t) => t.toLowerCase() === hint.toLowerCase());
137
+ return (
138
+ <button
139
+ key={hint}
140
+ type="button"
141
+ onClick={() => onHintClick(hint)}
142
+ className={[
143
+ "cursor-pointer rounded px-1.5 py-0.5 font-mono transition-colors",
144
+ isActive ? "bg-primary/10 text-primary" : "bg-elevated text-muted hover:text-text",
145
+ ].join(" ")}
146
+ >
147
+ {hint}
148
+ </button>
149
+ );
150
+ })}
151
+ </div>
152
+ );
153
+ }
154
+
155
+ // ---- Helpers ----
156
+
157
+ function getStatusBadges(entity: EntitySummary) {
158
+ return (
159
+ <div className="flex flex-wrap gap-2">
160
+ {entity.archived && <Badge tone="danger">archived</Badge>}
161
+ {entity.deprecated && <Badge tone="warning">deprecated</Badge>}
162
+ </div>
163
+ );
164
+ }
165
+
166
+ function LastModified(props: { entity: EntitySummary }) {
167
+ if (!props.entity.lastModified) {
168
+ return <span>Last modified n/a</span>;
169
+ }
170
+
171
+ const date = new Date(props.entity.lastModified.timestamp);
172
+ const formattedDate = Number.isNaN(date.getTime())
173
+ ? props.entity.lastModified.timestamp
174
+ : new Intl.DateTimeFormat(undefined, {
175
+ month: "short",
176
+ day: "numeric",
177
+ year: "numeric",
178
+ }).format(date);
179
+
180
+ return (
181
+ <span>
182
+ Last modified by <span className="font-semibold">{props.entity.lastModified.author}</span> on{" "}
183
+ {formattedDate}
184
+ </span>
185
+ );
186
+ }
187
+
188
+ function getRelationshipBadges(type: EntityType, entity: EntitySummary) {
189
+ if (type === "target") {
190
+ return [`${entity.messageCount || 0} ${entity.messageCount === 1 ? "message" : "messages"}`];
191
+ }
192
+
193
+ return entity.targets || [];
194
+ }
195
+
196
+ function getSortDirection(sortValue: string | null) {
197
+ if (!sortValue || sortValue === "name" || sortValue === "name:asc" || sortValue === "asc") {
198
+ return "asc";
199
+ }
200
+
201
+ if (sortValue === "-name" || sortValue === "name:desc" || sortValue === "desc") {
202
+ return "desc";
203
+ }
204
+
205
+ return "asc";
206
+ }
207
+
208
+ function setSearchParam(searchParams: URLSearchParams, key: string, value?: string) {
209
+ const next = new URLSearchParams(searchParams);
210
+
211
+ if (!value) {
212
+ next.delete(key);
213
+ } else {
214
+ next.set(key, value);
215
+ }
216
+
217
+ return next;
218
+ }
219
+
220
+ // ---- Component ----
221
+
222
+ export function EntityList(props: {
223
+ type: EntityType;
224
+ entities: EntitySummary[];
225
+ setKey?: string;
226
+ allEntities?: Record<EntityType, EntitySummary[]>;
227
+ }) {
228
+ const [searchParams, setSearchParams] = useSearchParams();
229
+ const [showAll, setShowAll] = React.useState(false);
230
+ const [showHints, setShowHints] = React.useState(false);
231
+ const [translationShard, setTranslationShard] = React.useState<TranslationShard | null>(null);
232
+ const [loadedShardKey, setLoadedShardKey] = React.useState<string | null>(null);
233
+ const debounceRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
234
+ const query = searchParams.get("q") || "";
235
+ const [inputValue, setInputValue] = React.useState(query);
236
+
237
+ // Sync input display when the URL query changes externally (hint clicks, navigation)
238
+ React.useEffect(() => {
239
+ setInputValue(query);
240
+ }, [query]);
241
+
242
+ const firstTargetKey = props.allEntities?.target?.find((e) => !e.archived)?.key;
243
+ const firstLocaleKey = props.allEntities?.locale?.find((e) => !e.archived)?.key;
244
+ const hasHintsDefined = getQueryHints(props.type, firstTargetKey, firstLocaleKey) !== null;
245
+
246
+ // Compute the 3-char shard prefix needed for the current query
247
+ const _translationQual = parseQuery(query).qualifiers.find((q) => q.key === "translation");
248
+ const neededShardKey =
249
+ _translationQual && _translationQual.value.length >= 3
250
+ ? _translationQual.value.slice(0, 3).toLowerCase()
251
+ : null;
252
+
253
+ // Debounced fetch: only triggers when the 3-char prefix changes
254
+ React.useEffect(() => {
255
+ if (debounceRef.current) clearTimeout(debounceRef.current);
256
+
257
+ if (!neededShardKey) {
258
+ setTranslationShard(null);
259
+ setLoadedShardKey(null);
260
+ return;
261
+ }
262
+
263
+ if (neededShardKey === loadedShardKey) return;
264
+
265
+ debounceRef.current = setTimeout(() => {
266
+ debounceRef.current = null;
267
+ fetchTranslationShard(neededShardKey, props.setKey).then((data) => {
268
+ setTranslationShard(data);
269
+ setLoadedShardKey(neededShardKey);
270
+ });
271
+ }, 300);
272
+
273
+ return () => {
274
+ if (debounceRef.current) clearTimeout(debounceRef.current);
275
+ };
276
+ }, [neededShardKey, loadedShardKey, props.setKey]);
277
+ const sortDirection = getSortDirection(searchParams.get("sort"));
278
+
279
+ // Pass shard to matchesQuery only when the loaded shard matches what's needed
280
+ const activeShard = loadedShardKey === neededShardKey ? translationShard : null;
281
+
282
+ const filtered = React.useMemo(() => {
283
+ const parsed = parseQuery(query);
284
+ const hasQuery = query.trim().length > 0;
285
+
286
+ const matching = props.entities.filter((entity) => {
287
+ if (!hasQuery) return true;
288
+ return matchesQuery(entity, parsed, activeShard);
289
+ });
290
+
291
+ return matching.slice().sort((left, right) => {
292
+ const result = left.key.localeCompare(left.key === right.key ? "" : right.key);
293
+ return sortDirection === "desc" ? result * -1 : result;
294
+ });
295
+ }, [query, props.entities, sortDirection, activeShard]);
296
+
297
+ const visible = showAll ? filtered : filtered.slice(0, CATALOG_LIST_INITIAL_LIMIT);
298
+ const hasHiddenEntities = filtered.length > CATALOG_LIST_INITIAL_LIMIT && !showAll;
299
+
300
+ React.useEffect(() => {
301
+ setShowAll(false);
302
+ }, [query, sortDirection, props.type, props.setKey]);
303
+
304
+ function handleHintClick(hint: string) {
305
+ const current = query.trim();
306
+ // Toggle: if the exact hint token is already in the query, remove it; otherwise append
307
+ const tokens = current.split(/\s+/).filter(Boolean);
308
+ const idx = tokens.findIndex((t) => t.toLowerCase() === hint.toLowerCase());
309
+ const next =
310
+ idx !== -1
311
+ ? tokens.filter((_, i) => i !== idx).join(" ")
312
+ : current
313
+ ? `${current} ${hint}`
314
+ : hint;
315
+ setSearchParams(setSearchParam(searchParams, "q", next || undefined));
316
+ }
317
+
318
+ return (
319
+ <div className="space-y-4">
320
+ <div className="px-6 pt-1">
321
+ <div className="grid gap-3 md:grid-cols-[minmax(0,1fr)_auto]">
322
+ {/* Input + slide-down hints, confined to the first column */}
323
+ <div>
324
+ <div className="relative">
325
+ <Input
326
+ value={inputValue}
327
+ onChange={(event) => {
328
+ const val = event.target.value;
329
+ setInputValue(val);
330
+ setSearchParams(setSearchParam(searchParams, "q", val.trim() ? val : undefined));
331
+ }}
332
+ placeholder={`Search ${entityLabels[props.type].plural.toLowerCase()}...`}
333
+ className={hasHintsDefined ? "pr-10" : ""}
334
+ />
335
+ {hasHintsDefined && (
336
+ <button
337
+ type="button"
338
+ onClick={() => setShowHints((v) => !v)}
339
+ aria-label={
340
+ showHints ? "Hide advanced search hints" : "Show advanced search hints"
341
+ }
342
+ className={[
343
+ "absolute right-3 top-1/2 -translate-y-1/2 flex h-5 w-5 items-center justify-center rounded-full border text-xs font-bold transition-colors",
344
+ showHints
345
+ ? "border-primary bg-primary/10 text-primary"
346
+ : "border-border bg-surface text-muted hover:border-primary hover:text-primary",
347
+ ].join(" ")}
348
+ >
349
+ ?
350
+ </button>
351
+ )}
352
+ </div>
353
+
354
+ {/* Animated slide-down hints panel, aligned to input's inner text area */}
355
+ <div
356
+ className={[
357
+ "grid transition-all duration-200 ease-in-out",
358
+ showHints ? "grid-rows-[1fr]" : "grid-rows-[0fr]",
359
+ ].join(" ")}
360
+ >
361
+ <div className="overflow-hidden pl-5">
362
+ <QueryHints
363
+ type={props.type}
364
+ query={query}
365
+ firstTargetKey={firstTargetKey}
366
+ firstLocaleKey={firstLocaleKey}
367
+ onHintClick={handleHintClick}
368
+ />
369
+ </div>
370
+ </div>
371
+ </div>
372
+
373
+ <button
374
+ type="button"
375
+ className="inline-flex h-[46px] w-fit max-w-full cursor-pointer items-center gap-2 self-start rounded-full border border-border bg-surface px-3 py-2 text-left text-sm font-semibold text-muted outline-none focus-visible:ring-2 focus-visible:ring-ring md:justify-self-end"
376
+ onClick={() =>
377
+ setSearchParams(
378
+ setSearchParam(
379
+ searchParams,
380
+ "sort",
381
+ sortDirection === "desc" ? undefined : "-name",
382
+ ),
383
+ )
384
+ }
385
+ aria-label={
386
+ sortDirection === "desc"
387
+ ? "Sorted Z-A by name. Activate to sort A-Z."
388
+ : "Sorted A-Z by name. Activate to sort Z-A."
389
+ }
390
+ >
391
+ <span>Sort</span>
392
+ <span className="whitespace-nowrap font-bold text-text">
393
+ {sortDirection === "desc" ? "Z-A" : "A-Z"}
394
+ </span>
395
+ </button>
396
+ </div>
397
+ </div>
398
+
399
+ {filtered.length === 0 && <EmptyState title="No results found" />}
400
+
401
+ <div className="divide-y divide-border bg-surface">
402
+ {visible.map((entity) => (
403
+ <Link
404
+ key={entity.key}
405
+ to={getEntityRoute(props.type, entity.key, props.setKey)}
406
+ className="block px-6 py-3 hover:bg-elevated"
407
+ >
408
+ <div className="flex flex-col justify-between gap-3 md:flex-row md:items-start">
409
+ <div className="min-w-0 flex-1">
410
+ <div className="flex flex-col justify-between gap-2 md:flex-row md:items-start">
411
+ <div className="min-w-0">
412
+ <div className="font-bold text-primary">{entity.key}</div>
413
+ <div className="mt-1 truncate text-sm text-muted">
414
+ {entity.description || "No description"}
415
+ </div>
416
+ </div>
417
+ <div className="shrink-0">{getStatusBadges(entity)}</div>
418
+ </div>
419
+ <div className="mt-2 flex flex-col gap-2 text-xs text-muted md:flex-row md:items-center md:justify-between">
420
+ <div className="flex flex-wrap gap-2">
421
+ {getRelationshipBadges(props.type, entity).map((label) => (
422
+ <Badge key={label}>{label}</Badge>
423
+ ))}
424
+ </div>
425
+ <span className="shrink-0 md:text-right">
426
+ <LastModified entity={entity} />
427
+ </span>
428
+ </div>
429
+ </div>
430
+ </div>
431
+ </Link>
432
+ ))}
433
+ </div>
434
+
435
+ <div className="space-y-4 px-6 pb-6">
436
+ <p className="text-center text-sm text-muted">
437
+ {visible.length} of {filtered.length} {entityLabels[props.type].plural.toLowerCase()}
438
+ {filtered.length !== props.entities.length ? ` (${props.entities.length} total)` : ""}
439
+ </p>
440
+
441
+ {hasHiddenEntities && (
442
+ <div className="flex justify-center">
443
+ <Button onClick={() => setShowAll(true)}>
444
+ Load all {filtered.length} {entityLabels[props.type].plural.toLowerCase()}
445
+ </Button>
446
+ </div>
447
+ )}
448
+ </div>
449
+ </div>
450
+ );
451
+ }
@@ -0,0 +1,21 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ type BadgeTone = "neutral" | "success" | "warning" | "danger" | "primary";
4
+
5
+ const toneClasses: Record<BadgeTone, string> = {
6
+ neutral: "border-pill bg-pill text-text",
7
+ success: "border-success-outline bg-success-surface text-text",
8
+ warning: "border-warning-outline bg-warning-surface text-text",
9
+ danger: "border-danger-outline bg-danger-surface text-danger",
10
+ primary: "border-pill bg-pill text-text",
11
+ };
12
+
13
+ export function Badge(props: { children: ReactNode; tone?: BadgeTone }) {
14
+ return (
15
+ <span
16
+ className={`inline-flex rounded-full border px-2 py-0.5 text-xs font-medium ${toneClasses[props.tone || "neutral"]}`}
17
+ >
18
+ {props.children}
19
+ </span>
20
+ );
21
+ }
@@ -0,0 +1,12 @@
1
+ import * as React from "react";
2
+
3
+ type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement>;
4
+
5
+ export function Button({ className = "", ...props }: ButtonProps) {
6
+ return (
7
+ <button
8
+ className={`rounded border border-border bg-elevated px-4 py-2 text-sm font-bold text-muted shadow-sm hover:bg-background ${className}`}
9
+ {...props}
10
+ />
11
+ );
12
+ }
@@ -0,0 +1,9 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ import { themeClasses } from "../../theme";
4
+
5
+ export function Card(props: { children: ReactNode; className?: string }) {
6
+ return (
7
+ <section className={`${themeClasses.panel} ${props.className || ""}`}>{props.children}</section>
8
+ );
9
+ }
@@ -0,0 +1,7 @@
1
+ export function CodeBlock(props: { value: unknown }) {
2
+ return (
3
+ <pre className="max-w-full whitespace-pre-wrap rounded border border-border bg-elevated p-4 text-xs text-text [overflow-wrap:anywhere]">
4
+ {typeof props.value === "string" ? props.value : JSON.stringify(props.value, null, 2)}
5
+ </pre>
6
+ );
7
+ }
@@ -0,0 +1,8 @@
1
+ export function EmptyState(props: { title: string; description?: string }) {
2
+ return (
3
+ <div className="mx-6 rounded border-2 border-warning-outline bg-warning-surface p-4 text-center text-text">
4
+ <p className="font-medium">{props.title}</p>
5
+ {props.description && <p className="mt-1 text-sm text-muted">{props.description}</p>}
6
+ </div>
7
+ );
8
+ }
@@ -0,0 +1,12 @@
1
+ import * as React from "react";
2
+
3
+ type InputProps = React.InputHTMLAttributes<HTMLInputElement>;
4
+
5
+ export function Input({ className = "", ...props }: InputProps) {
6
+ return (
7
+ <input
8
+ className={`w-full rounded-full border border-border bg-surface px-5 py-2 text-xl text-text outline-none placeholder:text-placeholder focus:border-primary ${className}`}
9
+ {...props}
10
+ />
11
+ );
12
+ }
@@ -0,0 +1,55 @@
1
+ import type { ReactNode } from "react";
2
+ import { Link } from "react-router-dom";
3
+
4
+ type LabelValueBadgeTone = "neutral" | "inheritance" | "override";
5
+
6
+ const toneClasses: Record<LabelValueBadgeTone, { label: string; value: string; link: string }> = {
7
+ neutral: {
8
+ label: "bg-elevated text-muted",
9
+ value: "bg-surface text-text",
10
+ link: "text-primary hover:underline",
11
+ },
12
+ inheritance: {
13
+ label: "bg-elevated text-muted",
14
+ value: "bg-surface text-text",
15
+ link: "text-primary hover:underline",
16
+ },
17
+ override: {
18
+ label: "bg-pill text-text",
19
+ value: "bg-surface text-text",
20
+ link: "text-primary hover:underline",
21
+ },
22
+ };
23
+
24
+ export function LabelValueBadge(props: {
25
+ label: ReactNode;
26
+ value: ReactNode;
27
+ to?: string;
28
+ tone?: LabelValueBadgeTone;
29
+ compact?: boolean;
30
+ }) {
31
+ const tone = toneClasses[props.tone || "neutral"];
32
+ const valueContent = props.to ? (
33
+ <Link to={props.to} className={`font-medium ${tone.link}`}>
34
+ {props.value}
35
+ </Link>
36
+ ) : (
37
+ <span className="font-medium text-text">{props.value}</span>
38
+ );
39
+
40
+ return (
41
+ <span
42
+ className={[
43
+ "inline-flex overflow-hidden rounded-md border border-border shadow-sm",
44
+ props.compact ? "text-[10px] leading-4" : "text-xs",
45
+ ].join(" ")}
46
+ >
47
+ <span className={`${props.compact ? "px-1.5 py-px" : "px-2 py-1"} ${tone.label}`}>
48
+ {props.label}
49
+ </span>
50
+ <span className={`${props.compact ? "px-1.5 py-px" : "px-2 py-1"} ${tone.value}`}>
51
+ {valueContent}
52
+ </span>
53
+ </span>
54
+ );
55
+ }
package/src/config.ts ADDED
@@ -0,0 +1,2 @@
1
+ export const CATALOG_LIST_INITIAL_LIMIT = 1000;
2
+ export const CATALOG_HISTORY_VISIBLE_ENTITY_LIMIT = 10;
@@ -0,0 +1,50 @@
1
+ import * as React from "react";
2
+
3
+ import { fetchManifest } from "../api";
4
+ import type { CatalogManifest } from "../types";
5
+
6
+ interface CatalogContextValue {
7
+ manifest: CatalogManifest;
8
+ }
9
+
10
+ const CatalogContext = React.createContext<CatalogContextValue | null>(null);
11
+
12
+ export function CatalogProvider(props: {
13
+ children: React.ReactNode;
14
+ initialManifest?: CatalogManifest;
15
+ }) {
16
+ const [manifest, setManifest] = React.useState<CatalogManifest | null>(
17
+ props.initialManifest || null,
18
+ );
19
+ const [error, setError] = React.useState<string | null>(null);
20
+
21
+ React.useEffect(() => {
22
+ if (props.initialManifest) {
23
+ return;
24
+ }
25
+
26
+ fetchManifest()
27
+ .then(setManifest)
28
+ .catch((err: Error) => setError(err.message));
29
+ }, [props.initialManifest]);
30
+
31
+ if (error) {
32
+ return <div className="p-8 text-danger">{error}</div>;
33
+ }
34
+
35
+ if (!manifest) {
36
+ return <div className="p-8 text-muted">Loading catalog...</div>;
37
+ }
38
+
39
+ return <CatalogContext.Provider value={{ manifest }}>{props.children}</CatalogContext.Provider>;
40
+ }
41
+
42
+ export function useCatalog() {
43
+ const context = React.useContext(CatalogContext);
44
+
45
+ if (!context) {
46
+ throw new Error("useCatalog must be used inside CatalogProvider.");
47
+ }
48
+
49
+ return context;
50
+ }
@@ -0,0 +1,49 @@
1
+ import type { EntityPath, EntityType } from "./types";
2
+
3
+ export const entityPaths: EntityPath[] = [
4
+ "messages",
5
+ "locales",
6
+ "attributes",
7
+ "segments",
8
+ "targets",
9
+ ];
10
+
11
+ export const entityPathToType: Record<EntityPath, EntityType> = {
12
+ locales: "locale",
13
+ messages: "message",
14
+ attributes: "attribute",
15
+ segments: "segment",
16
+ targets: "target",
17
+ };
18
+
19
+ export const entityTypeToPath: Record<EntityType, EntityPath> = {
20
+ locale: "locales",
21
+ message: "messages",
22
+ attribute: "attributes",
23
+ segment: "segments",
24
+ target: "targets",
25
+ };
26
+
27
+ export const entityLabels: Record<EntityType, { singular: string; plural: string }> = {
28
+ locale: { singular: "Locale", plural: "Locales" },
29
+ message: { singular: "Message", plural: "Messages" },
30
+ attribute: { singular: "Attribute", plural: "Attributes" },
31
+ segment: { singular: "Segment", plural: "Segments" },
32
+ target: { singular: "Target", plural: "Targets" },
33
+ };
34
+
35
+ export function encodeRouteSegment(value: string) {
36
+ return encodeURIComponent(value);
37
+ }
38
+
39
+ export function getBasePath(setKey?: string) {
40
+ return setKey ? `/sets/${encodeRouteSegment(setKey)}` : "";
41
+ }
42
+
43
+ export function getEntityRoute(type: EntityType, key: string, setKey?: string) {
44
+ return `${getBasePath(setKey)}/${entityTypeToPath[type]}/${encodeRouteSegment(key)}`;
45
+ }
46
+
47
+ export function getDataBasePath(setKey?: string) {
48
+ return setKey ? `/data/sets/${encodeRouteSegment(setKey)}` : "/data/root";
49
+ }
package/src/index.ts ADDED
@@ -0,0 +1 @@
1
+ export * from "./node";