@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,3345 @@
1
+ import * as React from "react";
2
+ import {
3
+ Link,
4
+ Navigate,
5
+ Outlet,
6
+ useOutletContext,
7
+ useParams,
8
+ useSearchParams,
9
+ } from "react-router-dom";
10
+
11
+ import { fetchEntityDetail, fetchLocaleDuplicates } from "../api";
12
+ import {
13
+ encodeRouteSegment,
14
+ entityLabels,
15
+ entityPathToType,
16
+ entityTypeToPath,
17
+ getBasePath,
18
+ getEntityRoute,
19
+ } from "../entityTypes";
20
+ import type {
21
+ DevEditor,
22
+ DuplicateTranslationValue,
23
+ EntityDetail,
24
+ EntityPath,
25
+ FormatRow,
26
+ LocaleDuplicates,
27
+ TranslationRow,
28
+ } from "../types";
29
+ import { PageHeader } from "../components/layout/PageHeader";
30
+ import { Tabs } from "../components/layout/Tabs";
31
+ import { Badge } from "../components/ui/Badge";
32
+ import { CodeBlock } from "../components/ui/CodeBlock";
33
+ import { LabelValueBadge } from "../components/ui/LabelValueBadge";
34
+ import { EmptyState } from "../components/ui/EmptyState";
35
+ import { Input } from "../components/ui/Input";
36
+ import { FieldGrid } from "../components/details/FieldGrid";
37
+ import { ConditionTree } from "../components/details/ConditionTree";
38
+ import { GroupSegmentTree } from "../components/details/GroupSegmentTree";
39
+ import { MarkdownContent } from "../components/details/MarkdownContent";
40
+ import { TranslationsTable } from "../components/details/TranslationsTable";
41
+ import { UsageLinks } from "../components/details/UsageLinks";
42
+ import { HistoryTimeline } from "../components/history/HistoryTimeline";
43
+ import { useCatalog } from "../context/CatalogContext";
44
+ import { hashTranslationValue } from "../utils/hashTranslationValue";
45
+ import type { ParsedQuery } from "../utils/searchQuery";
46
+ import { parseQuery } from "../utils/searchQuery";
47
+
48
+ interface EvaluatedMessageExample {
49
+ locale: string;
50
+ exampleIndex: number;
51
+ matrixIndex?: number;
52
+ description?: string;
53
+ values?: Record<string, unknown>;
54
+ context?: Record<string, unknown>;
55
+ formats?: Record<string, unknown>;
56
+ currency?: string;
57
+ timeZone?: string;
58
+ evaluatedTranslation: unknown;
59
+ }
60
+
61
+ interface EvaluatedLocaleExample {
62
+ locale: string;
63
+ sourceLocale: string;
64
+ exampleIndex: number;
65
+ matrixIndex?: number;
66
+ description?: string;
67
+ rawMessage?: string;
68
+ message?: string;
69
+ originalTranslation?: string;
70
+ values?: Record<string, unknown>;
71
+ context?: Record<string, unknown>;
72
+ formats?: Record<string, unknown>;
73
+ currency?: string;
74
+ timeZone?: string;
75
+ evaluatedTranslation: unknown;
76
+ }
77
+
78
+ function getLocaleDirection(
79
+ localeKey: string | undefined,
80
+ localeDirections?: Record<string, string | undefined>,
81
+ ) {
82
+ if (!localeKey) {
83
+ return undefined;
84
+ }
85
+
86
+ return localeDirections?.[localeKey];
87
+ }
88
+
89
+ function slugifyFragment(value: string) {
90
+ return value
91
+ .toLowerCase()
92
+ .replace(/[^a-z0-9]+/g, "-")
93
+ .replace(/^-+|-+$/g, "");
94
+ }
95
+
96
+ function escapeRegExp(value: string) {
97
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
98
+ }
99
+
100
+ function ExamplesSearchHighlight(props: { text: string; query: string }) {
101
+ const q = props.query.trim();
102
+ if (!q) {
103
+ return <>{props.text}</>;
104
+ }
105
+
106
+ const escaped = escapeRegExp(q);
107
+ const regex = new RegExp(escaped, "gi");
108
+ const parts: React.ReactNode[] = [];
109
+ let lastIndex = 0;
110
+ let key = 0;
111
+
112
+ for (const match of props.text.matchAll(regex)) {
113
+ if (match.index !== undefined && match.index > lastIndex) {
114
+ parts.push(props.text.slice(lastIndex, match.index));
115
+ }
116
+
117
+ if (match.index !== undefined) {
118
+ parts.push(
119
+ <mark
120
+ key={`hm-${match.index}-${key++}`}
121
+ className={[
122
+ "rounded-[3px] bg-amber-100 px-0.5 py-px text-inherit",
123
+ "shadow-[inset_0_-2px_0_0_rgba(251,191,36,0.35)] ring-1 ring-amber-400/25 ring-inset",
124
+ "transition-[background-color,box-shadow] duration-150",
125
+ ].join(" ")}
126
+ >
127
+ {match[0]}
128
+ </mark>,
129
+ );
130
+ lastIndex = match.index + match[0].length;
131
+ }
132
+ }
133
+
134
+ if (lastIndex < props.text.length) {
135
+ parts.push(props.text.slice(lastIndex));
136
+ }
137
+
138
+ return <>{parts}</>;
139
+ }
140
+
141
+ function isEntityPath(value: string | undefined): value is EntityPath {
142
+ return (
143
+ value === "locales" ||
144
+ value === "messages" ||
145
+ value === "attributes" ||
146
+ value === "segments" ||
147
+ value === "targets"
148
+ );
149
+ }
150
+
151
+ export function useEntityDetail() {
152
+ return useOutletContext<{ detail: EntityDetail; setKey?: string }>();
153
+ }
154
+
155
+ function formatValue(value: unknown) {
156
+ if (typeof value === "undefined" || value === null || value === "") {
157
+ return "n/a";
158
+ }
159
+
160
+ if (Array.isArray(value)) {
161
+ return value.length > 0 ? value.join(", ") : "none";
162
+ }
163
+
164
+ if (typeof value === "object") {
165
+ const keys = Object.keys(value as Record<string, unknown>);
166
+ return keys.length > 0 ? keys.join(", ") : "none";
167
+ }
168
+
169
+ return String(value);
170
+ }
171
+
172
+ function rowMatchesFormatSearch(row: FormatRow, parsed: ParsedQuery): boolean {
173
+ const parts = splitFormatPath(row.path);
174
+ const valueStr = formatValue(row.value);
175
+ const preview = row.examplePreview ?? "";
176
+ const haystack =
177
+ `${row.path} ${valueStr} ${preview} ${row.source} ${row.from ?? ""}`.toLowerCase();
178
+
179
+ for (const term of parsed.freeText) {
180
+ if (!haystack.includes(term)) {
181
+ return false;
182
+ }
183
+ }
184
+
185
+ for (const q of parsed.qualifiers) {
186
+ const v = q.value.toLowerCase();
187
+ switch (q.key) {
188
+ case "type":
189
+ if (!parts.type.toLowerCase().includes(v)) {
190
+ return false;
191
+ }
192
+ break;
193
+ case "style":
194
+ if (!parts.style.toLowerCase().includes(v)) {
195
+ return false;
196
+ }
197
+ break;
198
+ case "param":
199
+ if (!parts.param.toLowerCase().includes(v)) {
200
+ return false;
201
+ }
202
+ break;
203
+ case "value":
204
+ if (!valueStr.toLowerCase().includes(v)) {
205
+ return false;
206
+ }
207
+ break;
208
+ case "from":
209
+ if (!(row.from || "").toLowerCase().includes(v)) {
210
+ return false;
211
+ }
212
+ break;
213
+ case "source":
214
+ if (!row.source.toLowerCase().includes(v)) {
215
+ return false;
216
+ }
217
+ break;
218
+ default:
219
+ if (!haystack.includes(v)) {
220
+ return false;
221
+ }
222
+ }
223
+ }
224
+
225
+ return true;
226
+ }
227
+
228
+ function filterFormatRowsBySearch(rows: FormatRow[], query: string): FormatRow[] {
229
+ const trimmed = query.trim();
230
+ if (!trimmed) {
231
+ return rows;
232
+ }
233
+
234
+ const parsed = parseQuery(trimmed);
235
+ if (parsed.freeText.length === 0 && parsed.qualifiers.length === 0) {
236
+ return rows;
237
+ }
238
+
239
+ return rows.filter((row) => rowMatchesFormatSearch(row, parsed));
240
+ }
241
+
242
+ /** Single substring for amber highlight (matches longest searchable term). */
243
+ function formatSearchHighlightNeedle(raw: string): string {
244
+ const trimmed = raw.trim();
245
+ if (!trimmed) {
246
+ return "";
247
+ }
248
+
249
+ const parsed = parseQuery(trimmed);
250
+ const parts: string[] = [...parsed.freeText];
251
+ const scopedKeys = new Set(["type", "style", "param", "value", "from", "source"]);
252
+ for (const q of parsed.qualifiers) {
253
+ if (scopedKeys.has(q.key)) {
254
+ parts.push(q.value.toLowerCase());
255
+ }
256
+ }
257
+
258
+ if (parts.length === 0) {
259
+ return trimmed;
260
+ }
261
+
262
+ return parts.reduce((longest, next) => (next.length > longest.length ? next : longest));
263
+ }
264
+
265
+ /** Splits a flattened format path into catalog columns: type → style → remainder (param). */
266
+ function splitFormatPath(path: string): { type: string; style: string; param: string } {
267
+ const segments = path.split(".").filter(Boolean);
268
+ if (segments.length === 0) {
269
+ return { type: "", style: "", param: "" };
270
+ }
271
+
272
+ if (segments.length === 1) {
273
+ return { type: segments[0], style: "", param: "" };
274
+ }
275
+
276
+ if (segments.length === 2) {
277
+ return { type: segments[0], style: segments[1], param: "" };
278
+ }
279
+
280
+ return {
281
+ type: segments[0],
282
+ style: segments[1],
283
+ param: segments.slice(2).join("."),
284
+ };
285
+ }
286
+
287
+ interface FormatSplitRowPlan {
288
+ row: FormatRow;
289
+ parts: { type: string; style: string; param: string };
290
+ showTypeCell: boolean;
291
+ typeRowSpan: number;
292
+ showStyleCell: boolean;
293
+ styleRowSpan: number;
294
+ /** Alternates by format *type* group for subtle banding. */
295
+ typeBand: number;
296
+ }
297
+
298
+ /** Sorts by path, then yields rowspan plans so repeated type/style cells are not duplicated. */
299
+ function buildFormatSplitRowPlans(rows: FormatRow[]): FormatSplitRowPlan[] {
300
+ const sorted = [...rows].sort((a, b) => a.path.localeCompare(b.path));
301
+ const withParts = sorted.map((row) => ({
302
+ row,
303
+ parts: splitFormatPath(row.path),
304
+ }));
305
+
306
+ const plans: FormatSplitRowPlan[] = [];
307
+ let typeBand = 0;
308
+ let i = 0;
309
+
310
+ while (i < withParts.length) {
311
+ const type = withParts[i].parts.type;
312
+ let j = i;
313
+ while (j < withParts.length && withParts[j].parts.type === type) {
314
+ j++;
315
+ }
316
+ const typeRowSpan = j - i;
317
+
318
+ let k = i;
319
+ while (k < j) {
320
+ const style = withParts[k].parts.style;
321
+ let m = k;
322
+ while (m < j && withParts[m].parts.style === style) {
323
+ m++;
324
+ }
325
+ const styleRowSpan = m - k;
326
+
327
+ for (let n = k; n < m; n++) {
328
+ plans.push({
329
+ row: withParts[n].row,
330
+ parts: withParts[n].parts,
331
+ showTypeCell: n === i,
332
+ typeRowSpan,
333
+ showStyleCell: n === k,
334
+ styleRowSpan,
335
+ typeBand,
336
+ });
337
+ }
338
+ k = m;
339
+ }
340
+
341
+ typeBand += 1;
342
+ i = j;
343
+ }
344
+
345
+ return plans;
346
+ }
347
+
348
+ function collectSortedFormatTypes(rows: FormatRow[]): string[] {
349
+ const seen = new Set<string>();
350
+ for (const row of rows) {
351
+ const t = splitFormatPath(row.path).type;
352
+ if (t) {
353
+ seen.add(t);
354
+ }
355
+ }
356
+ return [...seen].sort((a, b) => a.localeCompare(b));
357
+ }
358
+
359
+ /** Pills after "All types": always `number`, `date`, `time`, then other types from data (sorted). */
360
+ const FORMAT_TYPE_PRIMARY_PILLS: readonly string[] = ["number", "date", "time"];
361
+
362
+ /** Set true to show the Example column; `examplePreview` is still generated at catalog build. */
363
+ const SHOW_FORMAT_EXAMPLE_COLUMN_IN_UI = false;
364
+
365
+ function showFormatExampleColumn(selectedFormatType: string | null | undefined): boolean {
366
+ if (!SHOW_FORMAT_EXAMPLE_COLUMN_IN_UI) {
367
+ return false;
368
+ }
369
+ return (
370
+ selectedFormatType === "number" ||
371
+ selectedFormatType === "date" ||
372
+ selectedFormatType === "time"
373
+ );
374
+ }
375
+
376
+ function orderedFormatTypePillKeys(typesFromData: string[]): string[] {
377
+ const primarySet = new Set(FORMAT_TYPE_PRIMARY_PILLS);
378
+ const rest = typesFromData.filter((t) => !primarySet.has(t)).sort((a, b) => a.localeCompare(b));
379
+ return [...FORMAT_TYPE_PRIMARY_PILLS, ...rest];
380
+ }
381
+
382
+ function setSearchParam(searchParams: URLSearchParams, key: string, value?: string) {
383
+ const next = new URLSearchParams(searchParams);
384
+
385
+ if (!value) {
386
+ next.delete(key);
387
+ } else {
388
+ next.set(key, value);
389
+ }
390
+
391
+ return next;
392
+ }
393
+
394
+ function ExamplesCompactViewIcon() {
395
+ return (
396
+ <svg
397
+ className="h-3.5 w-3.5 shrink-0"
398
+ viewBox="0 0 24 24"
399
+ fill="none"
400
+ xmlns="http://www.w3.org/2000/svg"
401
+ aria-hidden={true}
402
+ >
403
+ <path
404
+ d="M4 6h16M4 12h16M4 18h16"
405
+ stroke="currentColor"
406
+ strokeWidth="2"
407
+ strokeLinecap="round"
408
+ strokeLinejoin="round"
409
+ />
410
+ </svg>
411
+ );
412
+ }
413
+
414
+ function ExamplesExpandedViewIcon() {
415
+ return (
416
+ <svg
417
+ className="h-3.5 w-3.5 shrink-0"
418
+ viewBox="0 0 24 24"
419
+ fill="none"
420
+ xmlns="http://www.w3.org/2000/svg"
421
+ aria-hidden={true}
422
+ >
423
+ <rect x="3" y="3" width="18" height="6" rx="1.5" stroke="currentColor" strokeWidth="2" />
424
+ <rect x="3" y="15" width="18" height="6" rx="1.5" stroke="currentColor" strokeWidth="2" />
425
+ </svg>
426
+ );
427
+ }
428
+
429
+ const EXAMPLES_TOOLBAR_CONTROL_HEIGHT_CLASS = "h-7";
430
+
431
+ function ExamplesViewModeSwitch(props: {
432
+ activeView: "compact" | "expanded";
433
+ onViewChange: (view: "compact" | "expanded") => void;
434
+ }) {
435
+ return (
436
+ <div
437
+ className={[
438
+ "inline-flex shrink-0 items-stretch rounded-lg border border-border bg-elevated p-px",
439
+ EXAMPLES_TOOLBAR_CONTROL_HEIGHT_CLASS,
440
+ ].join(" ")}
441
+ role="tablist"
442
+ aria-label="Example layout"
443
+ >
444
+ <button
445
+ type="button"
446
+ role="tab"
447
+ aria-selected={props.activeView === "compact"}
448
+ aria-label="Compact"
449
+ title="Compact"
450
+ className={[
451
+ "inline-flex h-full min-h-0 items-center justify-center rounded-md px-2 py-0 text-xs transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
452
+ props.activeView === "compact"
453
+ ? "bg-header-active text-header-text shadow-sm"
454
+ : "text-muted hover:text-text",
455
+ ].join(" ")}
456
+ onClick={() => props.onViewChange("compact")}
457
+ >
458
+ <ExamplesCompactViewIcon />
459
+ </button>
460
+ <button
461
+ type="button"
462
+ role="tab"
463
+ aria-selected={props.activeView === "expanded"}
464
+ aria-label="Expanded"
465
+ title="Expanded"
466
+ className={[
467
+ "inline-flex h-full min-h-0 items-center justify-center rounded-md px-2 py-0 text-xs transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
468
+ props.activeView === "expanded"
469
+ ? "bg-header-active text-header-text shadow-sm"
470
+ : "text-muted hover:text-text",
471
+ ].join(" ")}
472
+ onClick={() => props.onViewChange("expanded")}
473
+ >
474
+ <ExamplesExpandedViewIcon />
475
+ </button>
476
+ </div>
477
+ );
478
+ }
479
+
480
+ function setWindowHash(targetId?: string) {
481
+ if (typeof window === "undefined") {
482
+ return;
483
+ }
484
+
485
+ const url = new URL(window.location.href);
486
+
487
+ if (!targetId) {
488
+ url.hash = "";
489
+ } else {
490
+ url.hash = targetId;
491
+ }
492
+
493
+ window.history.replaceState(null, "", url.toString());
494
+ }
495
+
496
+ function JsonValueBlock(props: { value: unknown }) {
497
+ if (
498
+ props.value &&
499
+ typeof props.value === "object" &&
500
+ !Array.isArray(props.value) &&
501
+ Object.keys(props.value as Record<string, unknown>).length === 1
502
+ ) {
503
+ const [key, value] = Object.entries(props.value as Record<string, unknown>)[0];
504
+ const isPrimitive =
505
+ value === null ||
506
+ typeof value === "string" ||
507
+ typeof value === "number" ||
508
+ typeof value === "boolean";
509
+
510
+ if (isPrimitive) {
511
+ return (
512
+ <pre className="max-w-full whitespace-pre-wrap rounded border border-border bg-elevated p-4 text-xs text-text [overflow-wrap:anywhere]">
513
+ {`{${JSON.stringify(key)}: ${JSON.stringify(value)}}`}
514
+ </pre>
515
+ );
516
+ }
517
+ }
518
+
519
+ return <CodeBlock value={props.value} />;
520
+ }
521
+
522
+ function TranslationValueBlock(props: { value: unknown; direction?: string }) {
523
+ return (
524
+ <div
525
+ dir={props.direction}
526
+ className={["min-w-0 max-w-full", props.direction === "rtl" ? "text-right" : ""]
527
+ .filter(Boolean)
528
+ .join(" ")}
529
+ style={props.direction ? { unicodeBidi: "plaintext" } : undefined}
530
+ >
531
+ <CodeBlock value={props.value} />
532
+ </div>
533
+ );
534
+ }
535
+
536
+ function ExamplePermalink(props: { targetId: string }) {
537
+ return (
538
+ <a
539
+ href={`#${props.targetId}`}
540
+ aria-label="Link to this example"
541
+ className="inline-flex rounded p-1 text-muted opacity-0 transition-opacity hover:text-primary focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring group-hover:opacity-100"
542
+ >
543
+ <svg
544
+ aria-hidden="true"
545
+ viewBox="0 0 24 24"
546
+ className="h-4 w-4"
547
+ fill="none"
548
+ stroke="currentColor"
549
+ strokeWidth="2"
550
+ strokeLinecap="round"
551
+ strokeLinejoin="round"
552
+ >
553
+ <path d="M10 13a5 5 0 0 0 7.07 0l2.83-2.83a5 5 0 0 0-7.07-7.07L11 4" />
554
+ <path d="M14 11a5 5 0 0 0-7.07 0L4.1 13.83a5 5 0 1 0 7.07 7.07L13 20" />
555
+ </svg>
556
+ </a>
557
+ );
558
+ }
559
+
560
+ function useScrollToHash(dependencies: React.DependencyList) {
561
+ React.useEffect(() => {
562
+ if (typeof window === "undefined" || !window.location.hash) {
563
+ return;
564
+ }
565
+
566
+ const targetId = decodeURIComponent(window.location.hash.slice(1));
567
+
568
+ if (!targetId) {
569
+ return;
570
+ }
571
+
572
+ const frame = window.requestAnimationFrame(() => {
573
+ const targetElement = document.getElementById(targetId);
574
+
575
+ if (!targetElement) {
576
+ return;
577
+ }
578
+
579
+ targetElement.scrollIntoView({ block: "start" });
580
+ });
581
+
582
+ return () => {
583
+ window.cancelAnimationFrame(frame);
584
+ };
585
+ }, dependencies);
586
+ }
587
+
588
+ function CaretIcon() {
589
+ return (
590
+ <svg aria-hidden="true" viewBox="0 0 12 12" fill="none" className="h-3 w-3">
591
+ <path
592
+ d="M3.25 4.5 6 7.25 8.75 4.5"
593
+ stroke="currentColor"
594
+ strokeWidth="1.5"
595
+ strokeLinecap="round"
596
+ strokeLinejoin="round"
597
+ />
598
+ </svg>
599
+ );
600
+ }
601
+
602
+ function EditorIcon(props: { icon: DevEditor["icon"] }) {
603
+ if (props.icon === "cursor") {
604
+ return (
605
+ <svg aria-hidden="true" viewBox="0 0 24 24" fill="none" className="h-4 w-4">
606
+ <path d="M4 3l16 9-7 2-3 7L4 3Z" fill="#111827" />
607
+ <path d="M8.2 8.6 15 12.6" stroke="#ffffff" strokeWidth="1.4" strokeLinecap="round" />
608
+ <path d="M10.2 12.9 12.6 14.3 10.5 18.8Z" fill="#ffffff" opacity=".92" />
609
+ </svg>
610
+ );
611
+ }
612
+
613
+ return (
614
+ <svg aria-hidden="true" viewBox="0 0 24 24" fill="none" className="h-4 w-4">
615
+ <path
616
+ d="M17.2 3.2 9.4 10 5.1 6.7 3 7.8v8.4l2.1 1.1 4.3-3.3 7.8 6.8L21 19V5l-3.8-1.8Z"
617
+ fill="#007ACC"
618
+ />
619
+ <path d="M17.2 8.2v7.6L12.5 12l4.7-3.8Z" fill="#ffffff" opacity=".35" />
620
+ </svg>
621
+ );
622
+ }
623
+
624
+ function EditLink(props: { sourcePath?: string; editLinks?: EntityDetail["editLinks"] }) {
625
+ const { manifest } = useCatalog();
626
+ const [open, setOpen] = React.useState(false);
627
+ const containerRef = React.useRef<HTMLDivElement | null>(null);
628
+
629
+ React.useEffect(() => {
630
+ if (!open) {
631
+ return;
632
+ }
633
+
634
+ function handlePointerDown(event: PointerEvent) {
635
+ if (!containerRef.current?.contains(event.target as Node)) {
636
+ setOpen(false);
637
+ }
638
+ }
639
+
640
+ function handleKeyDown(event: KeyboardEvent) {
641
+ if (event.key === "Escape") {
642
+ setOpen(false);
643
+ }
644
+ }
645
+
646
+ document.addEventListener("pointerdown", handlePointerDown);
647
+ document.addEventListener("keydown", handleKeyDown);
648
+
649
+ return () => {
650
+ document.removeEventListener("pointerdown", handlePointerDown);
651
+ document.removeEventListener("keydown", handleKeyDown);
652
+ };
653
+ }, [open]);
654
+
655
+ const sourceHref =
656
+ props.sourcePath && manifest.links?.source
657
+ ? manifest.links.source.replace("{{path}}", props.sourcePath)
658
+ : undefined;
659
+ const editors = (manifest.dev?.editors || []).filter((editor) => props.editLinks?.[editor.id]);
660
+ const hasEditorLinks = editors.length > 0;
661
+
662
+ if (!sourceHref && !hasEditorLinks) {
663
+ return null;
664
+ }
665
+
666
+ const buttonClass =
667
+ "rounded border border-border bg-elevated px-4 py-2 text-sm font-bold text-muted shadow-sm hover:bg-background";
668
+ const splitButtonClass =
669
+ "border border-border bg-elevated px-4 py-2 text-sm font-bold text-muted shadow-sm hover:bg-background";
670
+ const menuButtonClass =
671
+ "border border-border bg-elevated py-2 text-sm font-bold text-muted shadow-sm hover:bg-background";
672
+
673
+ const dropdown = hasEditorLinks ? (
674
+ <div
675
+ role="menu"
676
+ className="absolute right-0 top-full z-20 mt-px min-w-48 overflow-hidden rounded border border-border bg-surface py-1 shadow-lg"
677
+ >
678
+ {editors.map((editor) => (
679
+ <a
680
+ key={editor.id}
681
+ role="menuitem"
682
+ href={props.editLinks?.[editor.id]}
683
+ className="flex items-center gap-2 px-3 py-2 text-sm font-semibold text-muted hover:bg-elevated hover:text-text"
684
+ onClick={() => setOpen(false)}
685
+ >
686
+ <EditorIcon icon={editor.icon} />
687
+ <span>Open in {editor.label}</span>
688
+ </a>
689
+ ))}
690
+ </div>
691
+ ) : null;
692
+
693
+ if (!hasEditorLinks) {
694
+ return sourceHref ? (
695
+ <a href={sourceHref} target="_blank" rel="noreferrer" className={buttonClass}>
696
+ Edit
697
+ </a>
698
+ ) : null;
699
+ }
700
+
701
+ return (
702
+ <div ref={containerRef} className="relative inline-flex">
703
+ {sourceHref ? (
704
+ <a
705
+ href={sourceHref}
706
+ target="_blank"
707
+ rel="noreferrer"
708
+ className={`${splitButtonClass} rounded-l`}
709
+ >
710
+ Edit
711
+ </a>
712
+ ) : (
713
+ <button
714
+ type="button"
715
+ className={`${splitButtonClass} inline-flex items-center gap-2 rounded`}
716
+ aria-haspopup="menu"
717
+ aria-expanded={open}
718
+ onClick={() => setOpen((value) => !value)}
719
+ >
720
+ Edit
721
+ <CaretIcon />
722
+ </button>
723
+ )}
724
+ {sourceHref && (
725
+ <button
726
+ type="button"
727
+ className={`${menuButtonClass} -ml-px rounded-r px-1.5`}
728
+ aria-label="Open edit menu"
729
+ aria-haspopup="menu"
730
+ aria-expanded={open}
731
+ onClick={() => setOpen((value) => !value)}
732
+ >
733
+ <CaretIcon />
734
+ </button>
735
+ )}
736
+ {open && dropdown}
737
+ </div>
738
+ );
739
+ }
740
+
741
+ function FormatRowsTable(props: {
742
+ rows?: FormatRow[];
743
+ searchQuery?: string;
744
+ /** Locale Formats tab: split path into Type / Style / Param; targets keep a single Format column. */
745
+ formatPathLayout?: "flat" | "split";
746
+ /** When set with split layout, only rows whose path starts with this type (first segment). */
747
+ selectedFormatType?: string | null;
748
+ /** Omit the Type column (types are shown as pills elsewhere). */
749
+ hideTypeColumn?: boolean;
750
+ /** Last column: sample Intl output; only for number / date / time pill selection. */
751
+ showExampleColumn?: boolean;
752
+ }) {
753
+ const { setKey } = useEntityDetail();
754
+ const rows = props.rows || [];
755
+ const q = props.searchQuery ?? "";
756
+ const highlightNeedle = formatSearchHighlightNeedle(q);
757
+ const highlight = Boolean(highlightNeedle.trim());
758
+ const splitPath = props.formatPathLayout === "split";
759
+ const hideTypeColumn = Boolean(props.hideTypeColumn && splitPath);
760
+ const showExampleColumn = Boolean(props.showExampleColumn);
761
+
762
+ let visibleRows = q.trim() ? filterFormatRowsBySearch(rows, q) : rows;
763
+ if (splitPath && props.selectedFormatType) {
764
+ visibleRows = visibleRows.filter(
765
+ (row) => splitFormatPath(row.path).type === props.selectedFormatType,
766
+ );
767
+ }
768
+
769
+ if (rows.length === 0) {
770
+ return <p className="text-sm text-muted">No formats found.</p>;
771
+ }
772
+
773
+ if (visibleRows.length === 0) {
774
+ const emptyMsg = q.trim()
775
+ ? "No formats match your search."
776
+ : props.selectedFormatType
777
+ ? "No formats for this type."
778
+ : "No formats match your search.";
779
+ return <p className="text-sm text-muted">{emptyMsg}</p>;
780
+ }
781
+
782
+ const splitPlans = splitPath ? buildFormatSplitRowPlans(visibleRows) : null;
783
+
784
+ function segmentBody(segment: string) {
785
+ return segment ? (
786
+ highlight ? (
787
+ <ExamplesSearchHighlight text={segment} query={highlightNeedle} />
788
+ ) : (
789
+ segment
790
+ )
791
+ ) : (
792
+ <span className="font-normal text-muted">—</span>
793
+ );
794
+ }
795
+
796
+ /** Plain text in the cell. Param stays monospace for paths. */
797
+ function renderSplitSegment(segment: string, role: "type" | "style" | "param") {
798
+ const body = segmentBody(segment);
799
+ if (role === "param") {
800
+ return (
801
+ <div className="overflow-x-auto whitespace-nowrap font-mono text-[11px] leading-snug text-muted">
802
+ {body}
803
+ </div>
804
+ );
805
+ }
806
+ if (role === "style") {
807
+ return <div className="overflow-x-auto whitespace-nowrap leading-snug text-text">{body}</div>;
808
+ }
809
+ return (
810
+ <div className="whitespace-pre-wrap leading-snug text-text [overflow-wrap:anywhere]">
811
+ {body}
812
+ </div>
813
+ );
814
+ }
815
+
816
+ function bandSurfaceClass(band: number) {
817
+ return band % 2 === 0 ? "bg-surface" : "bg-elevated/[0.2]";
818
+ }
819
+
820
+ function formatSplitCellBorderClass(
821
+ column: "type" | "example" | "style" | "param" | "value",
822
+ ): string {
823
+ switch (column) {
824
+ case "type":
825
+ return "border-b border-border border-r border-border/50";
826
+ case "example":
827
+ return "border-b border-border border-r border-border/50";
828
+ case "style":
829
+ return "border-b border-border border-r border-border/50";
830
+ case "param":
831
+ return "border-b border-border border-r border-border/40";
832
+ case "value":
833
+ return "border-b border-border";
834
+ default:
835
+ return "";
836
+ }
837
+ }
838
+
839
+ function renderValueColumn(row: FormatRow, bandAndPaddingClass: string) {
840
+ const valueText = formatValue(row.value);
841
+ const showInheritedBadge = row.source === "inherited" && Boolean(row.from);
842
+ const showTargetBadge = row.source === "target";
843
+
844
+ return (
845
+ <td className={[bandAndPaddingClass, formatSplitCellBorderClass("value")].join(" ")}>
846
+ <div className="flex min-w-0 items-center gap-2">
847
+ <div
848
+ className={[
849
+ "min-w-0 flex-1 whitespace-pre-wrap [overflow-wrap:anywhere]",
850
+ row.source === "inherited" ? "text-muted" : "",
851
+ ].join(" ")}
852
+ >
853
+ {highlight ? (
854
+ <ExamplesSearchHighlight text={valueText} query={highlightNeedle} />
855
+ ) : (
856
+ valueText
857
+ )}
858
+ </div>
859
+ {(showInheritedBadge || showTargetBadge) && (
860
+ <div className="flex shrink-0 flex-col items-end justify-center gap-1">
861
+ {showInheritedBadge && row.from ? (
862
+ <LabelValueBadge
863
+ label="inherited from"
864
+ value={row.from}
865
+ to={getEntityRoute("locale", row.from, setKey)}
866
+ tone="inheritance"
867
+ compact
868
+ />
869
+ ) : null}
870
+ {showTargetBadge ? (
871
+ <LabelValueBadge label="from" value="target" tone="neutral" compact />
872
+ ) : null}
873
+ </div>
874
+ )}
875
+ </div>
876
+ </td>
877
+ );
878
+ }
879
+
880
+ function renderExampleCellContent(preview: string | undefined) {
881
+ return preview ? (
882
+ highlight ? (
883
+ <ExamplesSearchHighlight text={preview} query={highlightNeedle} />
884
+ ) : (
885
+ preview
886
+ )
887
+ ) : (
888
+ <span className="font-normal text-muted">—</span>
889
+ );
890
+ }
891
+
892
+ /** Split layout: merged per style group (same rowSpan as Style column). */
893
+ function renderSplitExampleColumn(plan: FormatSplitRowPlan, bandClass: string) {
894
+ if (!showExampleColumn || !plan.showStyleCell) {
895
+ return null;
896
+ }
897
+
898
+ return (
899
+ <td
900
+ rowSpan={plan.styleRowSpan}
901
+ className={[
902
+ "align-middle min-w-0 px-3 py-2 whitespace-pre-wrap [overflow-wrap:anywhere]",
903
+ bandClass,
904
+ formatSplitCellBorderClass("example"),
905
+ ].join(" ")}
906
+ >
907
+ {renderExampleCellContent(plan.row.examplePreview)}
908
+ </td>
909
+ );
910
+ }
911
+
912
+ function renderFlatExampleColumn(row: FormatRow) {
913
+ return (
914
+ <td
915
+ className={[
916
+ "align-middle min-w-0 px-3 py-2 whitespace-pre-wrap [overflow-wrap:anywhere]",
917
+ "border-b border-border border-r border-border/40",
918
+ ].join(" ")}
919
+ >
920
+ {renderExampleCellContent(row.examplePreview)}
921
+ </td>
922
+ );
923
+ }
924
+
925
+ return (
926
+ <div className="min-w-0 overflow-x-auto rounded-xl border border-border">
927
+ <table className="w-full min-w-[40rem] table-fixed border-collapse bg-surface text-xs">
928
+ {splitPath ? (
929
+ <colgroup>
930
+ {hideTypeColumn ? (
931
+ showExampleColumn ? (
932
+ <>
933
+ <col className="min-w-0 w-[18%]" />
934
+ <col className="min-w-0 w-[14%]" />
935
+ <col className="min-w-0 w-[36%]" />
936
+ <col className="min-w-0 w-[32%]" />
937
+ </>
938
+ ) : (
939
+ <>
940
+ <col className="min-w-0 w-[22%]" />
941
+ <col className="min-w-0 w-[36%]" />
942
+ <col className="min-w-0 w-[42%]" />
943
+ </>
944
+ )
945
+ ) : showExampleColumn ? (
946
+ <>
947
+ <col className="min-w-0 w-[10%]" />
948
+ <col className="min-w-0 w-[16%]" />
949
+ <col className="min-w-0 w-[12%]" />
950
+ <col className="min-w-0 w-[34%]" />
951
+ <col className="min-w-0 w-[28%]" />
952
+ </>
953
+ ) : (
954
+ <>
955
+ <col className="min-w-0 w-[12%]" />
956
+ <col className="min-w-0 w-[18%]" />
957
+ <col className="min-w-0 w-[32%]" />
958
+ <col className="min-w-0 w-[38%]" />
959
+ </>
960
+ )}
961
+ </colgroup>
962
+ ) : (
963
+ <colgroup>
964
+ {showExampleColumn ? (
965
+ <>
966
+ <col className="min-w-0 w-[40%]" />
967
+ <col className="min-w-0 w-[20%]" />
968
+ <col className="min-w-0 w-[40%]" />
969
+ </>
970
+ ) : (
971
+ <>
972
+ <col className="min-w-0 w-1/2" />
973
+ <col className="min-w-0 w-1/2" />
974
+ </>
975
+ )}
976
+ </colgroup>
977
+ )}
978
+ <thead className="bg-elevated text-left text-[11px] uppercase tracking-wide text-muted">
979
+ <tr>
980
+ {splitPath ? (
981
+ <>
982
+ {hideTypeColumn ? null : (
983
+ <th className="align-middle border-b border-r border-border/50 px-3 py-2 font-semibold">
984
+ Type
985
+ </th>
986
+ )}
987
+ <th className="align-middle border-b border-r border-border/50 px-3 py-2 font-semibold">
988
+ Style
989
+ </th>
990
+ {showExampleColumn ? (
991
+ <th className="align-middle border-b border-r border-border/50 px-3 py-2 font-semibold">
992
+ Example
993
+ </th>
994
+ ) : null}
995
+ <th className="align-middle border-b border-r border-border/40 px-3 py-2 font-semibold">
996
+ Param
997
+ </th>
998
+ <th className="align-middle border-b border-border px-3 py-2 font-semibold">
999
+ Value
1000
+ </th>
1001
+ </>
1002
+ ) : (
1003
+ <>
1004
+ <th className="align-middle border-b border-border px-3 py-2 font-semibold">
1005
+ Format
1006
+ </th>
1007
+ {showExampleColumn ? (
1008
+ <th className="align-middle border-b border-border px-3 py-2 font-semibold">
1009
+ Example
1010
+ </th>
1011
+ ) : null}
1012
+ <th className="align-middle border-b border-border px-3 py-2 font-semibold">
1013
+ Value
1014
+ </th>
1015
+ </>
1016
+ )}
1017
+ </tr>
1018
+ </thead>
1019
+ <tbody>
1020
+ {splitPath && splitPlans
1021
+ ? splitPlans.map((plan) => {
1022
+ const bandClass = bandSurfaceClass(plan.typeBand);
1023
+
1024
+ return (
1025
+ <tr key={plan.row.path}>
1026
+ {!hideTypeColumn && plan.showTypeCell ? (
1027
+ <td
1028
+ rowSpan={plan.typeRowSpan}
1029
+ className={[
1030
+ "align-middle min-w-0 px-3 py-2 font-medium",
1031
+ bandClass,
1032
+ formatSplitCellBorderClass("type"),
1033
+ ].join(" ")}
1034
+ >
1035
+ {renderSplitSegment(plan.parts.type, "type")}
1036
+ </td>
1037
+ ) : null}
1038
+ {plan.showStyleCell ? (
1039
+ <td
1040
+ rowSpan={plan.styleRowSpan}
1041
+ className={[
1042
+ "align-middle min-w-0 px-3 py-2 font-medium",
1043
+ bandClass,
1044
+ formatSplitCellBorderClass("style"),
1045
+ ].join(" ")}
1046
+ >
1047
+ {renderSplitSegment(plan.parts.style, "style")}
1048
+ </td>
1049
+ ) : null}
1050
+ {renderSplitExampleColumn(plan, bandClass)}
1051
+ <td
1052
+ className={[
1053
+ "align-middle min-w-0 px-3 py-2 font-medium text-muted",
1054
+ bandClass,
1055
+ formatSplitCellBorderClass("param"),
1056
+ ].join(" ")}
1057
+ >
1058
+ {renderSplitSegment(plan.parts.param, "param")}
1059
+ </td>
1060
+ {renderValueColumn(
1061
+ plan.row,
1062
+ ["align-middle min-w-0 px-3 py-2", bandClass].join(" "),
1063
+ )}
1064
+ </tr>
1065
+ );
1066
+ })
1067
+ : visibleRows.map((row) => {
1068
+ const flatFormatClass = [
1069
+ "align-middle min-w-0 px-3 py-2 font-medium text-muted",
1070
+ showExampleColumn
1071
+ ? "border-b border-border border-r border-border/40"
1072
+ : "border-b border-border",
1073
+ ].join(" ");
1074
+
1075
+ return (
1076
+ <tr key={row.path}>
1077
+ <td className={flatFormatClass}>
1078
+ <div className="whitespace-pre-wrap [overflow-wrap:anywhere]">
1079
+ {highlight ? (
1080
+ <ExamplesSearchHighlight text={row.path} query={highlightNeedle} />
1081
+ ) : (
1082
+ row.path
1083
+ )}
1084
+ </div>
1085
+ </td>
1086
+ {showExampleColumn ? renderFlatExampleColumn(row) : null}
1087
+ {renderValueColumn(
1088
+ row,
1089
+ "align-middle min-w-0 px-3 py-2 font-medium text-muted",
1090
+ )}
1091
+ </tr>
1092
+ );
1093
+ })}
1094
+ </tbody>
1095
+ </table>
1096
+ </div>
1097
+ );
1098
+ }
1099
+
1100
+ function SourceLocaleLink(props: { localeKey: string }) {
1101
+ const { setKey } = useEntityDetail();
1102
+
1103
+ return (
1104
+ <Link
1105
+ to={getEntityRoute("locale", props.localeKey, setKey)}
1106
+ className="font-medium text-primary hover:underline"
1107
+ >
1108
+ {props.localeKey}
1109
+ </Link>
1110
+ );
1111
+ }
1112
+
1113
+ function LinkedLocaleList(props: { localeKeys?: string[]; setKey?: string }) {
1114
+ const localeKeys = props.localeKeys || [];
1115
+
1116
+ if (localeKeys.length === 0) {
1117
+ return <>n/a</>;
1118
+ }
1119
+
1120
+ return (
1121
+ <>
1122
+ {localeKeys.map((localeKey, index) => (
1123
+ <React.Fragment key={localeKey}>
1124
+ {index > 0 ? ", " : null}
1125
+ <Link
1126
+ className="font-medium text-primary hover:underline"
1127
+ to={getEntityRoute("locale", localeKey, props.setKey)}
1128
+ >
1129
+ {localeKey}
1130
+ </Link>
1131
+ </React.Fragment>
1132
+ ))}
1133
+ </>
1134
+ );
1135
+ }
1136
+
1137
+ function LinkedTargetBadges(props: { targetKeys?: string[]; setKey?: string }) {
1138
+ const targetKeys = props.targetKeys || [];
1139
+
1140
+ if (targetKeys.length === 0) {
1141
+ return <>none</>;
1142
+ }
1143
+
1144
+ return (
1145
+ <div className="flex flex-wrap gap-2">
1146
+ {targetKeys.map((targetKey) => (
1147
+ <Link
1148
+ key={targetKey}
1149
+ className="inline-flex"
1150
+ to={getEntityRoute("target", targetKey, props.setKey)}
1151
+ >
1152
+ <Badge>{targetKey}</Badge>
1153
+ </Link>
1154
+ ))}
1155
+ </div>
1156
+ );
1157
+ }
1158
+
1159
+ export function EntityDetailPage() {
1160
+ const { entityPath, entityKey, setKey } = useParams();
1161
+ const [detail, setDetail] = React.useState<EntityDetail | null>(null);
1162
+ const [error, setError] = React.useState<string | null>(null);
1163
+
1164
+ React.useEffect(() => {
1165
+ if (!isEntityPath(entityPath) || !entityKey) {
1166
+ return;
1167
+ }
1168
+
1169
+ const type = entityPathToType[entityPath];
1170
+ setDetail(null);
1171
+ setError(null);
1172
+ fetchEntityDetail(type, entityKey, setKey)
1173
+ .then(setDetail)
1174
+ .catch((err: Error) => setError(err.message));
1175
+ }, [entityPath, entityKey, setKey]);
1176
+
1177
+ if (!isEntityPath(entityPath) || !entityKey) {
1178
+ return <Navigate to={getBasePath(setKey) || "/"} replace />;
1179
+ }
1180
+
1181
+ const type = entityPathToType[entityPath];
1182
+ const baseRoute = `${getBasePath(setKey)}/${entityTypeToPath[type]}/${encodeRouteSegment(entityKey)}`;
1183
+
1184
+ if (error) {
1185
+ return <EmptyState title="Unable to load entity" description={error} />;
1186
+ }
1187
+
1188
+ if (!detail) {
1189
+ return <div className="text-muted">Loading {entityLabels[type].singular.toLowerCase()}...</div>;
1190
+ }
1191
+
1192
+ const entity = detail.entity as Record<string, any>;
1193
+ const tabs = getTabs(type, baseRoute);
1194
+
1195
+ return (
1196
+ <div>
1197
+ <PageHeader
1198
+ title={`${entityLabels[type].singular}: ${detail.key}`}
1199
+ description={
1200
+ <div className="flex flex-wrap items-center gap-2">
1201
+ {entity.archived && <Badge tone="danger">archived</Badge>}
1202
+ {entity.deprecated && <Badge tone="warning">deprecated</Badge>}
1203
+ </div>
1204
+ }
1205
+ actions={<EditLink sourcePath={detail.sourcePath} editLinks={detail.editLinks} />}
1206
+ />
1207
+ <Tabs tabs={tabs}>
1208
+ <Outlet context={{ detail, setKey }} />
1209
+ </Tabs>
1210
+ </div>
1211
+ );
1212
+ }
1213
+
1214
+ function getTabs(type: string, baseRoute: string) {
1215
+ const shared = [
1216
+ { label: "Overview", to: baseRoute, end: true },
1217
+ { label: "History", to: `${baseRoute}/history` },
1218
+ ];
1219
+
1220
+ if (type === "locale") {
1221
+ return [
1222
+ shared[0],
1223
+ { label: "Formats", to: `${baseRoute}/formats` },
1224
+ { label: "Examples", to: `${baseRoute}/examples` },
1225
+ { label: "Duplicates", to: `${baseRoute}/duplicates` },
1226
+ shared[1],
1227
+ ];
1228
+ }
1229
+
1230
+ if (type === "message") {
1231
+ return [
1232
+ shared[0],
1233
+ { label: "Translations", to: `${baseRoute}/translations` },
1234
+ { label: "Overrides", to: `${baseRoute}/overrides` },
1235
+ { label: "Examples", to: `${baseRoute}/examples` },
1236
+ shared[1],
1237
+ ];
1238
+ }
1239
+
1240
+ if (type === "target") {
1241
+ return [
1242
+ shared[0],
1243
+ { label: "Formats", to: `${baseRoute}/formats`, end: false },
1244
+ { label: "Messages", to: `${baseRoute}/messages` },
1245
+ shared[1],
1246
+ ];
1247
+ }
1248
+
1249
+ if (type === "segment") {
1250
+ return [
1251
+ shared[0],
1252
+ { label: "Conditions", to: `${baseRoute}/conditions` },
1253
+ { label: "Usage", to: `${baseRoute}/usage` },
1254
+ shared[1],
1255
+ ];
1256
+ }
1257
+
1258
+ return [shared[0], { label: "Usage", to: `${baseRoute}/usage` }, shared[1]];
1259
+ }
1260
+
1261
+ export function EntityOverviewTab() {
1262
+ const { detail, setKey } = useEntityDetail();
1263
+ const { manifest } = useCatalog();
1264
+ const entity = detail.entity as Record<string, any>;
1265
+ const fields = getOverviewFields(detail, entity, setKey, manifest.sets);
1266
+
1267
+ return (
1268
+ <div className="space-y-5">
1269
+ <FieldGrid fields={fields} />
1270
+ </div>
1271
+ );
1272
+ }
1273
+
1274
+ function getOverviewFields(
1275
+ detail: EntityDetail,
1276
+ entity: Record<string, any>,
1277
+ setKey?: string,
1278
+ showPromotable?: boolean,
1279
+ ) {
1280
+ const promotableField = showPromotable
1281
+ ? { label: "Promotable", value: entity.promotable === false ? "No" : "Yes" }
1282
+ : undefined;
1283
+ const compact = (
1284
+ fields: Array<{ label: string; value: React.ReactNode; fullWidth?: boolean } | undefined>,
1285
+ ) =>
1286
+ fields.filter(
1287
+ (
1288
+ field,
1289
+ ): field is {
1290
+ label: string;
1291
+ value: React.ReactNode;
1292
+ fullWidth?: boolean;
1293
+ } => Boolean(field?.value),
1294
+ );
1295
+
1296
+ if (detail.type === "locale") {
1297
+ const fields = [
1298
+ { label: "Direction", value: entity.direction },
1299
+ { label: "Inherits formats from", value: entity.inheritFormatsFrom },
1300
+ { label: "Inherits translations from", value: entity.inheritTranslationsFrom },
1301
+ promotableField,
1302
+ {
1303
+ label: "Description",
1304
+ value: <MarkdownContent value={entity.description} />,
1305
+ fullWidth: true,
1306
+ },
1307
+ ];
1308
+
1309
+ return compact(fields);
1310
+ }
1311
+
1312
+ if (detail.type === "message") {
1313
+ const fields = [
1314
+ promotableField,
1315
+ { label: "Deprecated", value: entity.deprecated ? "Yes" : "No" },
1316
+ { label: "Deprecation warning", value: entity.deprecationWarning },
1317
+ {
1318
+ label: "Targets",
1319
+ value: (
1320
+ <LinkedTargetBadges targetKeys={detail.targets as string[] | undefined} setKey={setKey} />
1321
+ ),
1322
+ },
1323
+ {
1324
+ label: "Summary",
1325
+ value: entity.summary,
1326
+ fullWidth: true,
1327
+ },
1328
+ {
1329
+ label: "Description",
1330
+ value: <MarkdownContent value={entity.description} />,
1331
+ fullWidth: true,
1332
+ },
1333
+ {
1334
+ label: "Meta",
1335
+ value: entity.meta ? <CodeBlock value={entity.meta} /> : undefined,
1336
+ fullWidth: true,
1337
+ },
1338
+ ];
1339
+
1340
+ return compact(fields);
1341
+ }
1342
+
1343
+ if (detail.type === "attribute") {
1344
+ const hasAllowedValues = Array.isArray(entity.enum)
1345
+ ? entity.enum.length > 0
1346
+ : Boolean(entity.enum);
1347
+ const hasRequiredFields = Array.isArray(entity.required)
1348
+ ? entity.required.length > 0
1349
+ : Boolean(entity.required);
1350
+ const hasRange = typeof entity.minimum !== "undefined" || typeof entity.maximum !== "undefined";
1351
+ const fields = [
1352
+ { label: "Type", value: entity.type },
1353
+ promotableField,
1354
+ { label: "Allowed values", value: hasAllowedValues ? formatValue(entity.enum) : undefined },
1355
+ {
1356
+ label: "Required fields",
1357
+ value: hasRequiredFields ? formatValue(entity.required) : undefined,
1358
+ },
1359
+ { label: "Pattern", value: entity.pattern },
1360
+ {
1361
+ label: "Range",
1362
+ value: hasRange
1363
+ ? `${typeof entity.minimum !== "undefined" ? entity.minimum : "-∞"} to ${typeof entity.maximum !== "undefined" ? entity.maximum : "∞"}`
1364
+ : undefined,
1365
+ },
1366
+ {
1367
+ label: "Description",
1368
+ value: <MarkdownContent value={entity.description} />,
1369
+ fullWidth: true,
1370
+ },
1371
+ ];
1372
+
1373
+ return compact(fields);
1374
+ }
1375
+
1376
+ if (detail.type === "segment") {
1377
+ return compact([
1378
+ { label: "Archived", value: entity.archived ? "Yes" : "No" },
1379
+ promotableField,
1380
+ {
1381
+ label: "Description",
1382
+ value: <MarkdownContent value={entity.description} />,
1383
+ fullWidth: true,
1384
+ },
1385
+ ]);
1386
+ }
1387
+
1388
+ const fields = [
1389
+ promotableField,
1390
+ { label: "Included message patterns", value: formatValue(entity.includeMessages) },
1391
+ { label: "Excluded message patterns", value: formatValue(entity.excludeMessages) },
1392
+ {
1393
+ label: "Locales",
1394
+ value: (
1395
+ <LinkedLocaleList localeKeys={detail.locales as string[] | undefined} setKey={setKey} />
1396
+ ),
1397
+ },
1398
+ {
1399
+ label: "Description",
1400
+ value: <MarkdownContent value={entity.description} />,
1401
+ fullWidth: true,
1402
+ },
1403
+ ];
1404
+
1405
+ return compact(fields);
1406
+ }
1407
+
1408
+ const FORMAT_SEARCH_HINTS = [
1409
+ "type:number",
1410
+ "style:decimal",
1411
+ "type:date",
1412
+ "param:maximumFractionDigits",
1413
+ "value:2",
1414
+ "from:en",
1415
+ "source:direct",
1416
+ ];
1417
+
1418
+ function FormatSearchHints(props: { query: string; onHintClick: (hint: string) => void }) {
1419
+ return (
1420
+ <div className="flex flex-wrap items-center gap-x-2 gap-y-1.5 pt-2 text-xs text-muted">
1421
+ <span className="shrink-0">Try:</span>
1422
+ {FORMAT_SEARCH_HINTS.map((hint) => {
1423
+ const isActive = props.query
1424
+ .trim()
1425
+ .split(/\s+/)
1426
+ .some((t) => t.toLowerCase() === hint.toLowerCase());
1427
+ return (
1428
+ <button
1429
+ key={hint}
1430
+ type="button"
1431
+ onClick={() => props.onHintClick(hint)}
1432
+ className={[
1433
+ "cursor-pointer rounded px-1.5 py-0.5 font-mono transition-colors",
1434
+ isActive ? "bg-primary/10 text-primary" : "bg-elevated text-muted hover:text-text",
1435
+ ].join(" ")}
1436
+ >
1437
+ {hint}
1438
+ </button>
1439
+ );
1440
+ })}
1441
+ </div>
1442
+ );
1443
+ }
1444
+
1445
+ /** Formats tab: filter by path type (first segment); syncs `formatType` search param. */
1446
+ function FormatsTypePills(props: { typeKeys: string[] }) {
1447
+ const [searchParams] = useSearchParams();
1448
+ const formatTypeParam = searchParams.get("formatType") ?? "";
1449
+ const selectedType =
1450
+ formatTypeParam && props.typeKeys.includes(formatTypeParam) ? formatTypeParam : null;
1451
+
1452
+ function typeLinkSearch(nextType: string | null): string {
1453
+ const next = setSearchParam(
1454
+ searchParams,
1455
+ "formatType",
1456
+ nextType === null ? undefined : nextType,
1457
+ );
1458
+ const qs = next.toString();
1459
+ return qs ? `?${qs}` : "";
1460
+ }
1461
+
1462
+ const pillClass = (isActive: boolean) =>
1463
+ [
1464
+ "inline-flex rounded-full border px-3 py-1 text-xs font-semibold transition-colors",
1465
+ isActive
1466
+ ? "border-primary bg-header-active text-header-text"
1467
+ : "border-pill bg-transparent text-text hover:bg-elevated",
1468
+ ].join(" ");
1469
+
1470
+ return (
1471
+ <nav className="flex min-w-0 flex-wrap gap-2" aria-label="Format types">
1472
+ <Link
1473
+ replace
1474
+ to={{ search: typeLinkSearch(null) }}
1475
+ className={pillClass(selectedType === null)}
1476
+ >
1477
+ All types
1478
+ </Link>
1479
+ {props.typeKeys.map((typeKey) => {
1480
+ const isActive = typeKey === selectedType;
1481
+ return (
1482
+ <Link
1483
+ key={typeKey}
1484
+ replace
1485
+ to={{ search: typeLinkSearch(typeKey) }}
1486
+ className={pillClass(isActive)}
1487
+ >
1488
+ {typeKey}
1489
+ </Link>
1490
+ );
1491
+ })}
1492
+ </nav>
1493
+ );
1494
+ }
1495
+
1496
+ function useFormatsTypePillSelection(formatRows: FormatRow[]) {
1497
+ const [searchParams] = useSearchParams();
1498
+ const formatTypeParam = searchParams.get("formatType") ?? "";
1499
+ const formatTypes = React.useMemo(() => collectSortedFormatTypes(formatRows), [formatRows]);
1500
+ const formatTypePillKeys = React.useMemo(
1501
+ () => orderedFormatTypePillKeys(formatTypes),
1502
+ [formatTypes],
1503
+ );
1504
+ const selectedFormatType =
1505
+ formatTypeParam && formatTypePillKeys.includes(formatTypeParam) ? formatTypeParam : null;
1506
+ return { formatTypes, formatTypePillKeys, selectedFormatType };
1507
+ }
1508
+
1509
+ /** Shared by Locale and Target entity Format tabs (below type pills; on Target, below locale pills). */
1510
+ function FormatsSearchToolbar() {
1511
+ const [searchParams, setSearchParams] = useSearchParams();
1512
+ const [showHints, setShowHints] = React.useState(false);
1513
+ const searchQuery = searchParams.get("q") ?? "";
1514
+
1515
+ function handleFormatSearchHintClick(hint: string) {
1516
+ const current = searchQuery.trim();
1517
+ const tokens = current.split(/\s+/).filter(Boolean);
1518
+ const idx = tokens.findIndex((t) => t.toLowerCase() === hint.toLowerCase());
1519
+ const next =
1520
+ idx !== -1
1521
+ ? tokens.filter((_, i) => i !== idx).join(" ")
1522
+ : current
1523
+ ? `${current} ${hint}`
1524
+ : hint;
1525
+ setSearchParams(setSearchParam(searchParams, "q", next.trim() ? next.trim() : undefined), {
1526
+ replace: true,
1527
+ });
1528
+ }
1529
+
1530
+ return (
1531
+ <div className="flex flex-wrap items-center gap-x-4 gap-y-3">
1532
+ <div className="flex min-h-0 min-w-0 flex-1 basis-[min(100%,22rem)] flex-col gap-0">
1533
+ <div className="relative">
1534
+ <Input
1535
+ type="search"
1536
+ value={searchQuery}
1537
+ onChange={(event) => {
1538
+ const val = event.target.value;
1539
+ setSearchParams(setSearchParam(searchParams, "q", val.trim() ? val : undefined), {
1540
+ replace: true,
1541
+ });
1542
+ }}
1543
+ placeholder="Search formats…"
1544
+ aria-label="Search formats"
1545
+ className={[
1546
+ EXAMPLES_TOOLBAR_CONTROL_HEIGHT_CLASS,
1547
+ "box-border rounded-lg border border-border bg-elevated py-0 pl-2 pr-10",
1548
+ "text-xs leading-snug text-text",
1549
+ "placeholder:text-xs placeholder:text-muted placeholder:leading-snug",
1550
+ ].join(" ")}
1551
+ />
1552
+ <button
1553
+ type="button"
1554
+ onClick={() => setShowHints((v) => !v)}
1555
+ aria-label={showHints ? "Hide advanced search hints" : "Show advanced search hints"}
1556
+ className={[
1557
+ "absolute right-2 top-1/2 flex h-5 w-5 -translate-y-1/2 items-center justify-center rounded-full border text-xs font-bold transition-colors",
1558
+ showHints
1559
+ ? "border-primary bg-primary/10 text-primary"
1560
+ : "border-border bg-surface text-muted hover:border-primary hover:text-primary",
1561
+ ].join(" ")}
1562
+ >
1563
+ ?
1564
+ </button>
1565
+ </div>
1566
+
1567
+ <div
1568
+ className={[
1569
+ "grid transition-all duration-200 ease-in-out",
1570
+ showHints ? "grid-rows-[1fr]" : "grid-rows-[0fr]",
1571
+ ].join(" ")}
1572
+ >
1573
+ <div className="overflow-hidden pl-1">
1574
+ <FormatSearchHints query={searchQuery} onHintClick={handleFormatSearchHintClick} />
1575
+ </div>
1576
+ </div>
1577
+ </div>
1578
+ </div>
1579
+ );
1580
+ }
1581
+
1582
+ export function LocaleFormatsTab() {
1583
+ const { detail } = useEntityDetail();
1584
+ const [searchParams] = useSearchParams();
1585
+ const searchQuery = searchParams.get("q") ?? "";
1586
+ const rows = detail.formatRows as FormatRow[] | undefined;
1587
+ const allRows = rows ?? [];
1588
+ const { formatTypes, formatTypePillKeys, selectedFormatType } =
1589
+ useFormatsTypePillSelection(allRows);
1590
+ const showExampleColumn = showFormatExampleColumn(selectedFormatType);
1591
+
1592
+ return (
1593
+ <div className="space-y-6">
1594
+ {formatTypes.length > 0 ? <FormatsTypePills typeKeys={formatTypePillKeys} /> : null}
1595
+
1596
+ <FormatsSearchToolbar />
1597
+
1598
+ <FormatRowsTable
1599
+ rows={rows}
1600
+ searchQuery={searchQuery}
1601
+ formatPathLayout="split"
1602
+ hideTypeColumn={selectedFormatType != null}
1603
+ selectedFormatType={selectedFormatType}
1604
+ showExampleColumn={showExampleColumn}
1605
+ />
1606
+ </div>
1607
+ );
1608
+ }
1609
+
1610
+ export function FormatsTab() {
1611
+ const { detail } = useEntityDetail();
1612
+
1613
+ if (detail.type === "target") {
1614
+ return <TargetFormatsTab />;
1615
+ }
1616
+
1617
+ return <LocaleFormatsTab />;
1618
+ }
1619
+
1620
+ function MessageTranslationOverridesDetails(props: {
1621
+ messageKey: string;
1622
+ overrides: Array<{ key: string; row: TranslationRow; override: Record<string, any> }>;
1623
+ localeDirections?: Record<string, string | undefined>;
1624
+ setKey?: string;
1625
+ }) {
1626
+ if (props.overrides.length === 0) {
1627
+ return <p className="text-sm text-muted">No overrides.</p>;
1628
+ }
1629
+
1630
+ return (
1631
+ <div className="space-y-3">
1632
+ <p className="text-sm font-medium text-muted">Overrides</p>
1633
+ <div className="space-y-3">
1634
+ {props.overrides.map(({ key, row, override }) => (
1635
+ <div key={key} className="rounded border border-border bg-surface p-4">
1636
+ <div className="flex flex-wrap items-center justify-between gap-2">
1637
+ <Link
1638
+ to={`${getEntityRoute("message", props.messageKey, props.setKey)}/overrides#${key}`}
1639
+ className="text-sm font-semibold text-primary hover:underline"
1640
+ >
1641
+ {key}
1642
+ </Link>
1643
+ {row.source === "inherited" && row.from && (
1644
+ <LabelValueBadge
1645
+ label="inherited from"
1646
+ value={row.from}
1647
+ to={getEntityRoute("locale", row.from, props.setKey)}
1648
+ tone="inheritance"
1649
+ compact
1650
+ />
1651
+ )}
1652
+ </div>
1653
+ {(override.segments || override.conditions) && (
1654
+ <div className="mt-4 space-y-4">
1655
+ {override.segments && (
1656
+ <div className="space-y-2">
1657
+ <h4 className="text-xs font-semibold uppercase tracking-wide text-muted">
1658
+ Segments
1659
+ </h4>
1660
+ <GroupSegmentTree segments={override.segments} setKey={props.setKey} />
1661
+ </div>
1662
+ )}
1663
+ {override.conditions && (
1664
+ <div className="space-y-2">
1665
+ <h4 className="text-xs font-semibold uppercase tracking-wide text-muted">
1666
+ Conditions
1667
+ </h4>
1668
+ <ConditionTree conditions={override.conditions} setKey={props.setKey} />
1669
+ </div>
1670
+ )}
1671
+ </div>
1672
+ )}
1673
+ <div className="mt-4 space-y-2">
1674
+ <h4 className="text-xs font-semibold uppercase tracking-wide text-muted">
1675
+ Overridden translation
1676
+ </h4>
1677
+ <TranslationValueBlock
1678
+ value={row.value || "—"}
1679
+ direction={getLocaleDirection(row.locale, props.localeDirections)}
1680
+ />
1681
+ </div>
1682
+ </div>
1683
+ ))}
1684
+ </div>
1685
+ </div>
1686
+ );
1687
+ }
1688
+
1689
+ export function MessageTranslationsTab() {
1690
+ const { detail, setKey } = useEntityDetail();
1691
+ const entity = detail.entity as Record<string, any>;
1692
+ const localeDirections = (detail.localeDirections || {}) as Record<string, string | undefined>;
1693
+ const overrideTranslations = (detail.overrideTranslations || []) as Array<{
1694
+ key: string;
1695
+ rows: TranslationRow[];
1696
+ }>;
1697
+ const overrides = (entity.overrides || []) as Record<string, any>[];
1698
+ const overridesByLocale = Object.fromEntries(
1699
+ ((detail.translations as TranslationRow[] | undefined) || []).map((translationRow) => [
1700
+ translationRow.locale,
1701
+ overrideTranslations
1702
+ .map((override) => ({
1703
+ key: override.key,
1704
+ row: (override.rows || []).find((row) => row.locale === translationRow.locale),
1705
+ override: overrides.find((item) => item.key === override.key),
1706
+ }))
1707
+ .filter(
1708
+ (
1709
+ value,
1710
+ ): value is {
1711
+ key: string;
1712
+ row: TranslationRow;
1713
+ override: Record<string, any>;
1714
+ } => Boolean(value.row && value.row.source !== "missing" && value.override),
1715
+ ),
1716
+ ]),
1717
+ ) as Record<string, Array<{ key: string; row: TranslationRow; override: Record<string, any> }>>;
1718
+ const overriddenLocales = new Set(
1719
+ Object.entries(overridesByLocale)
1720
+ .filter(([, rows]) => rows.length > 0)
1721
+ .map(([locale]) => locale),
1722
+ );
1723
+
1724
+ return (
1725
+ <TranslationsTable
1726
+ rows={detail.translations as TranslationRow[] | undefined}
1727
+ linkLocales
1728
+ showSource={false}
1729
+ localeDirections={localeDirections}
1730
+ getRowFragmentId={(entry) => slugifyFragment(`translation-${entry.locale}`)}
1731
+ renderExpandedRow={(entry) => (
1732
+ <MessageTranslationOverridesDetails
1733
+ messageKey={detail.key}
1734
+ overrides={overridesByLocale[entry.locale] || []}
1735
+ localeDirections={localeDirections}
1736
+ setKey={setKey}
1737
+ />
1738
+ )}
1739
+ renderMetaCell={(entry) => {
1740
+ const badges: React.ReactNode[] = [];
1741
+
1742
+ if (entry.source === "inherited" && entry.from) {
1743
+ badges.push(
1744
+ <LabelValueBadge
1745
+ key={`inherited-${entry.locale}`}
1746
+ label="inherited from"
1747
+ value={entry.from}
1748
+ to={getEntityRoute("locale", entry.from, setKey)}
1749
+ tone="inheritance"
1750
+ compact
1751
+ />,
1752
+ );
1753
+ }
1754
+
1755
+ if (overriddenLocales.has(entry.locale)) {
1756
+ badges.push(
1757
+ <LabelValueBadge
1758
+ key={`override-${entry.locale}`}
1759
+ label="overrides"
1760
+ value="yes"
1761
+ tone="override"
1762
+ compact
1763
+ />,
1764
+ );
1765
+ }
1766
+
1767
+ if (badges.length === 0) {
1768
+ return null;
1769
+ }
1770
+
1771
+ return <div className="flex flex-wrap justify-end gap-1.5">{badges}</div>;
1772
+ }}
1773
+ />
1774
+ );
1775
+ }
1776
+
1777
+ export function MessageOverridesTab() {
1778
+ const { detail, setKey } = useEntityDetail();
1779
+ const entity = detail.entity as Record<string, any>;
1780
+ const localeDirections = (detail.localeDirections || {}) as Record<string, string | undefined>;
1781
+ const baseTranslationsByLocale = Object.fromEntries(
1782
+ ((detail.translations as TranslationRow[] | undefined) || []).map((row) => [
1783
+ row.locale,
1784
+ row.value,
1785
+ ]),
1786
+ );
1787
+
1788
+ const overrides = entity.overrides || [];
1789
+
1790
+ useScrollToHash([overrides.length]);
1791
+
1792
+ if (overrides.length === 0) {
1793
+ return <p className="text-sm text-muted">No overrides found.</p>;
1794
+ }
1795
+
1796
+ return (
1797
+ <div className="space-y-6">
1798
+ {overrides.map((override: Record<string, any>) => {
1799
+ return (
1800
+ <section key={override.key} className="space-y-4">
1801
+ <div className="space-y-3">
1802
+ <div className="group flex items-center gap-2">
1803
+ <h2 id={override.key} className="font-semibold">
1804
+ {override.key}
1805
+ </h2>
1806
+ <ExamplePermalink targetId={override.key} />
1807
+ </div>
1808
+ {override.summary && <p className="mt-1 text-sm text-muted">{override.summary}</p>}
1809
+ {override.description && (
1810
+ <div className="mt-3">
1811
+ <MarkdownContent value={override.description} />
1812
+ </div>
1813
+ )}
1814
+ </div>
1815
+
1816
+ <div className="space-y-4">
1817
+ {override.segments && (
1818
+ <div className="space-y-2 rounded-xl border border-border bg-elevated p-4">
1819
+ <h3 className="text-sm font-semibold text-muted">Segments</h3>
1820
+ <GroupSegmentTree segments={override.segments} setKey={setKey} />
1821
+ </div>
1822
+ )}
1823
+
1824
+ {override.conditions && (
1825
+ <div className="space-y-2 rounded-xl border border-border bg-elevated p-4">
1826
+ <h3 className="text-sm font-semibold text-muted">Conditions</h3>
1827
+ <ConditionTree conditions={override.conditions} setKey={setKey} />
1828
+ </div>
1829
+ )}
1830
+
1831
+ <TranslationsTable
1832
+ translations={override.translations}
1833
+ linkLocales
1834
+ showSource={false}
1835
+ translationLabel="Overridden translation"
1836
+ comparisonLabel="Original translation"
1837
+ comparisonValues={baseTranslationsByLocale}
1838
+ localeDirections={localeDirections}
1839
+ />
1840
+ </div>
1841
+ </section>
1842
+ );
1843
+ })}
1844
+ </div>
1845
+ );
1846
+ }
1847
+
1848
+ export function MessageExamplesTab() {
1849
+ const { detail } = useEntityDetail();
1850
+ const [searchParams, setSearchParams] = useSearchParams();
1851
+ const examples = (detail.evaluatedExamples || []) as EvaluatedMessageExample[];
1852
+ const localeDirections = (detail.localeDirections || {}) as Record<string, string | undefined>;
1853
+ const originalTranslationsByLocale = Object.fromEntries(
1854
+ ((detail.translations as TranslationRow[] | undefined) || []).map((row) => [
1855
+ row.locale,
1856
+ row.value,
1857
+ ]),
1858
+ );
1859
+ const view = searchParams.get("view");
1860
+ const activeView = view === "expanded" ? "expanded" : "compact";
1861
+ const localeKeys = Array.from(new Set(examples.map((example) => example.locale))).sort();
1862
+ const localeFilter = searchParams.get("locale");
1863
+ const activeLocaleFilter = localeFilter && localeKeys.includes(localeFilter) ? localeFilter : "";
1864
+ const visibleExamples = activeLocaleFilter
1865
+ ? examples.filter((example) => example.locale === activeLocaleFilter)
1866
+ : examples;
1867
+
1868
+ useScrollToHash([visibleExamples.length, activeLocaleFilter, activeView]);
1869
+
1870
+ if (examples.length === 0) {
1871
+ return <p className="text-sm text-muted">No examples found.</p>;
1872
+ }
1873
+
1874
+ return (
1875
+ <div className="space-y-6">
1876
+ <div className="flex flex-wrap items-center gap-x-4 gap-y-3">
1877
+ <nav className="flex min-w-0 flex-wrap gap-2">
1878
+ <button
1879
+ type="button"
1880
+ className={[
1881
+ "inline-flex rounded-full border px-3 py-1 text-xs font-semibold transition-colors",
1882
+ activeLocaleFilter
1883
+ ? "border-pill bg-transparent text-text hover:bg-elevated"
1884
+ : "border-primary bg-header-active text-header-text",
1885
+ ].join(" ")}
1886
+ onClick={() => setSearchParams(setSearchParam(searchParams, "locale", undefined))}
1887
+ >
1888
+ All locales
1889
+ </button>
1890
+
1891
+ {localeKeys.map((localeKey) => {
1892
+ const isActive = activeLocaleFilter === localeKey;
1893
+
1894
+ return (
1895
+ <button
1896
+ key={localeKey}
1897
+ type="button"
1898
+ className={[
1899
+ "inline-flex rounded-full border px-3 py-1 text-xs font-semibold transition-colors",
1900
+ isActive
1901
+ ? "border-primary bg-header-active text-header-text"
1902
+ : "border-pill bg-transparent text-text hover:bg-elevated",
1903
+ ].join(" ")}
1904
+ onClick={() =>
1905
+ setSearchParams(
1906
+ setSearchParam(searchParams, "locale", isActive ? undefined : localeKey),
1907
+ )
1908
+ }
1909
+ >
1910
+ {localeKey}
1911
+ </button>
1912
+ );
1913
+ })}
1914
+ </nav>
1915
+
1916
+ <div className="ml-auto shrink-0">
1917
+ <ExamplesViewModeSwitch
1918
+ activeView={activeView}
1919
+ onViewChange={(view) =>
1920
+ setSearchParams(
1921
+ setSearchParam(searchParams, "view", view === "compact" ? undefined : view),
1922
+ )
1923
+ }
1924
+ />
1925
+ </div>
1926
+ </div>
1927
+
1928
+ {activeView === "compact" ? (
1929
+ <MessageExamplesCompactView
1930
+ examples={visibleExamples}
1931
+ localeDirections={localeDirections}
1932
+ originalTranslationsByLocale={originalTranslationsByLocale}
1933
+ />
1934
+ ) : (
1935
+ <MessageExamplesExpandedView
1936
+ examples={visibleExamples}
1937
+ localeDirections={localeDirections}
1938
+ originalTranslationsByLocale={originalTranslationsByLocale}
1939
+ />
1940
+ )}
1941
+ </div>
1942
+ );
1943
+ }
1944
+
1945
+ function ExampleTitle(props: {
1946
+ title: string;
1947
+ targetId: string;
1948
+ description?: string;
1949
+ highlightQuery?: string;
1950
+ }) {
1951
+ const titleContent =
1952
+ props.highlightQuery?.trim() && props.title ? (
1953
+ <ExamplesSearchHighlight text={props.title} query={props.highlightQuery} />
1954
+ ) : (
1955
+ props.title
1956
+ );
1957
+
1958
+ return (
1959
+ <div className="space-y-2">
1960
+ <div className="group flex items-center gap-2">
1961
+ <h2 id={props.targetId} className="font-semibold">
1962
+ {titleContent}
1963
+ </h2>
1964
+ <ExamplePermalink targetId={props.targetId} />
1965
+ </div>
1966
+ {props.description?.trim() ? (
1967
+ props.highlightQuery?.trim() ? (
1968
+ <div className="text-sm text-muted whitespace-pre-wrap [overflow-wrap:anywhere]">
1969
+ <ExamplesSearchHighlight text={props.description.trim()} query={props.highlightQuery} />
1970
+ </div>
1971
+ ) : (
1972
+ <div className="text-sm text-muted">
1973
+ <MarkdownContent value={props.description} />
1974
+ </div>
1975
+ )
1976
+ ) : null}
1977
+ </div>
1978
+ );
1979
+ }
1980
+
1981
+ function ExampleTable(props: {
1982
+ inputs: React.ReactNode;
1983
+ evaluatedTranslation: unknown;
1984
+ direction?: string;
1985
+ highlightQuery?: string;
1986
+ }) {
1987
+ const highlightTranslation =
1988
+ props.highlightQuery?.trim() &&
1989
+ props.evaluatedTranslation !== undefined &&
1990
+ props.evaluatedTranslation !== null;
1991
+
1992
+ const translationBody = highlightTranslation ? (
1993
+ <div
1994
+ dir={props.direction}
1995
+ className={[
1996
+ "min-w-0 max-w-full whitespace-pre-wrap [overflow-wrap:anywhere]",
1997
+ props.direction === "rtl" ? "text-right" : "",
1998
+ ]
1999
+ .filter(Boolean)
2000
+ .join(" ")}
2001
+ style={props.direction ? { unicodeBidi: "plaintext" } : undefined}
2002
+ >
2003
+ <ExamplesSearchHighlight
2004
+ text={
2005
+ typeof props.evaluatedTranslation === "string"
2006
+ ? props.evaluatedTranslation
2007
+ : JSON.stringify(props.evaluatedTranslation)
2008
+ }
2009
+ query={props.highlightQuery ?? ""}
2010
+ />
2011
+ </div>
2012
+ ) : (
2013
+ <TranslationValueBlock value={props.evaluatedTranslation} direction={props.direction} />
2014
+ );
2015
+
2016
+ return (
2017
+ <div className="min-w-0 overflow-hidden rounded-xl border border-border">
2018
+ <table className="w-full table-fixed border-collapse text-sm">
2019
+ <colgroup>
2020
+ <col className="w-1/2" />
2021
+ <col className="w-1/2" />
2022
+ </colgroup>
2023
+ <thead className="bg-elevated text-left text-muted">
2024
+ <tr>
2025
+ <th className="border-b border-border px-4 py-3 font-semibold">Input</th>
2026
+ <th className="border-b border-border px-4 py-3 font-semibold">Output</th>
2027
+ </tr>
2028
+ </thead>
2029
+ <tbody>
2030
+ <tr className="align-top">
2031
+ <td className="min-w-0 border-b border-border px-4 py-4">{props.inputs}</td>
2032
+ <td className="min-w-0 border-b border-border px-4 py-4">
2033
+ <div className="space-y-1">
2034
+ <div className="text-xs font-semibold uppercase tracking-wide text-muted">
2035
+ Evaluated translation
2036
+ </div>
2037
+ <div className="min-w-0">{translationBody}</div>
2038
+ </div>
2039
+ </td>
2040
+ </tr>
2041
+ </tbody>
2042
+ </table>
2043
+ </div>
2044
+ );
2045
+ }
2046
+
2047
+ function InputField(props: { label: string; children: React.ReactNode }) {
2048
+ return (
2049
+ <div className="space-y-1">
2050
+ <div className="text-xs font-semibold uppercase tracking-wide text-muted">{props.label}</div>
2051
+ <div className="min-w-0">{props.children}</div>
2052
+ </div>
2053
+ );
2054
+ }
2055
+
2056
+ function getMessageExampleId(example: EvaluatedMessageExample) {
2057
+ return slugifyFragment(
2058
+ [
2059
+ `example-${example.exampleIndex + 1}`,
2060
+ example.locale,
2061
+ typeof example.matrixIndex === "number" ? `matrix-${example.matrixIndex + 1}` : "",
2062
+ ]
2063
+ .filter(Boolean)
2064
+ .join("-"),
2065
+ );
2066
+ }
2067
+
2068
+ function getMessageExampleTitle(example: EvaluatedMessageExample) {
2069
+ const titleParts = [`Example #${example.exampleIndex + 1}`];
2070
+
2071
+ if (typeof example.matrixIndex === "number") {
2072
+ titleParts.push(`matrix #${example.matrixIndex + 1}`);
2073
+ }
2074
+
2075
+ return titleParts.join(" · ");
2076
+ }
2077
+
2078
+ function getMessageExampleCompactLabel(example: EvaluatedMessageExample) {
2079
+ if (typeof example.matrixIndex === "number") {
2080
+ return `#${example.exampleIndex + 1}.${example.matrixIndex + 1}`;
2081
+ }
2082
+
2083
+ return `#${example.exampleIndex + 1}`;
2084
+ }
2085
+
2086
+ function MessageExampleDetails(props: {
2087
+ example: EvaluatedMessageExample;
2088
+ showLocale?: boolean;
2089
+ originalTranslationsByLocale?: Record<string, string | undefined>;
2090
+ }) {
2091
+ const { example } = props;
2092
+ const { detail, setKey } = useEntityDetail();
2093
+ const originalTranslation = props.originalTranslationsByLocale?.[example.locale];
2094
+
2095
+ return (
2096
+ <div className="min-w-0 space-y-4">
2097
+ {props.showLocale !== false && (
2098
+ <InputField label="Locale">
2099
+ <SourceLocaleLink localeKey={example.locale} />
2100
+ </InputField>
2101
+ )}
2102
+
2103
+ {typeof example.values !== "undefined" && (
2104
+ <InputField label="Values">
2105
+ <JsonValueBlock value={example.values} />
2106
+ </InputField>
2107
+ )}
2108
+
2109
+ {typeof example.context !== "undefined" && (
2110
+ <InputField label="Context">
2111
+ <JsonValueBlock value={example.context} />
2112
+ </InputField>
2113
+ )}
2114
+
2115
+ {typeof example.timeZone !== "undefined" && (
2116
+ <InputField label="Time zone">
2117
+ <span className="font-mono text-sm text-text">{example.timeZone}</span>
2118
+ </InputField>
2119
+ )}
2120
+
2121
+ {typeof example.currency !== "undefined" && (
2122
+ <InputField label="Currency">
2123
+ <span className="font-mono text-sm text-text">{example.currency}</span>
2124
+ </InputField>
2125
+ )}
2126
+
2127
+ {typeof example.formats !== "undefined" && (
2128
+ <InputField label="Formats">
2129
+ <JsonValueBlock value={example.formats} />
2130
+ </InputField>
2131
+ )}
2132
+
2133
+ {originalTranslation && (
2134
+ <InputField label="Original translation">
2135
+ <div className="space-y-2">
2136
+ <TranslationValueBlock value={originalTranslation} />
2137
+ <p className="text-xs text-muted">
2138
+ See more{" "}
2139
+ <Link
2140
+ to={`${getEntityRoute("message", detail.key, setKey)}/translations`}
2141
+ className="font-medium text-primary hover:underline"
2142
+ >
2143
+ translations
2144
+ </Link>{" "}
2145
+ and{" "}
2146
+ <Link
2147
+ to={`${getEntityRoute("message", detail.key, setKey)}/overrides`}
2148
+ className="font-medium text-primary hover:underline"
2149
+ >
2150
+ overrides
2151
+ </Link>
2152
+ .
2153
+ </p>
2154
+ </div>
2155
+ </InputField>
2156
+ )}
2157
+ </div>
2158
+ );
2159
+ }
2160
+
2161
+ function MessageExamplesExpandedView(props: {
2162
+ examples: EvaluatedMessageExample[];
2163
+ localeDirections: Record<string, string | undefined>;
2164
+ originalTranslationsByLocale: Record<string, string | undefined>;
2165
+ }) {
2166
+ return (
2167
+ <div className="space-y-6">
2168
+ {props.examples.map((example) => {
2169
+ const titleId = getMessageExampleId(example);
2170
+
2171
+ return (
2172
+ <section
2173
+ key={`${example.exampleIndex}-${example.matrixIndex ?? "base"}-${example.locale}`}
2174
+ className="min-w-0 space-y-4"
2175
+ >
2176
+ <ExampleTitle
2177
+ title={getMessageExampleTitle(example)}
2178
+ targetId={titleId}
2179
+ description={example.description}
2180
+ />
2181
+
2182
+ <ExampleTable
2183
+ inputs={
2184
+ <MessageExampleDetails
2185
+ example={example}
2186
+ showLocale
2187
+ originalTranslationsByLocale={props.originalTranslationsByLocale}
2188
+ />
2189
+ }
2190
+ evaluatedTranslation={example.evaluatedTranslation}
2191
+ direction={getLocaleDirection(example.locale, props.localeDirections)}
2192
+ />
2193
+ </section>
2194
+ );
2195
+ })}
2196
+ </div>
2197
+ );
2198
+ }
2199
+
2200
+ function MessageExamplesCompactView(props: {
2201
+ examples: EvaluatedMessageExample[];
2202
+ localeDirections: Record<string, string | undefined>;
2203
+ originalTranslationsByLocale: Record<string, string | undefined>;
2204
+ }) {
2205
+ const [expandedExampleIds, setExpandedExampleIds] = React.useState<string[]>([]);
2206
+ const [lastOpenedExampleId, setLastOpenedExampleId] = React.useState<string | null>(null);
2207
+
2208
+ React.useEffect(() => {
2209
+ if (typeof window === "undefined") {
2210
+ return;
2211
+ }
2212
+
2213
+ const hashTargetId = decodeURIComponent(window.location.hash.slice(1));
2214
+
2215
+ if (!hashTargetId) {
2216
+ return;
2217
+ }
2218
+
2219
+ const matchingExample = props.examples.find(
2220
+ (example) => getMessageExampleId(example) === hashTargetId,
2221
+ );
2222
+
2223
+ if (!matchingExample) {
2224
+ return;
2225
+ }
2226
+
2227
+ setExpandedExampleIds((current) =>
2228
+ current.includes(hashTargetId) ? current : [...current, hashTargetId],
2229
+ );
2230
+ setLastOpenedExampleId(hashTargetId);
2231
+ }, [props.examples]);
2232
+
2233
+ function toggleExample(exampleId: string) {
2234
+ if (expandedExampleIds.includes(exampleId)) {
2235
+ const nextExpandedExampleIds = expandedExampleIds.filter(
2236
+ (currentId) => currentId !== exampleId,
2237
+ );
2238
+ const nextLastOpenedExampleId =
2239
+ lastOpenedExampleId === exampleId
2240
+ ? nextExpandedExampleIds[nextExpandedExampleIds.length - 1] || null
2241
+ : lastOpenedExampleId;
2242
+
2243
+ setExpandedExampleIds(nextExpandedExampleIds);
2244
+ setLastOpenedExampleId(nextLastOpenedExampleId);
2245
+ setWindowHash(nextLastOpenedExampleId || undefined);
2246
+ return;
2247
+ }
2248
+
2249
+ setExpandedExampleIds((current) => [...current, exampleId]);
2250
+ setLastOpenedExampleId(exampleId);
2251
+ setWindowHash(exampleId);
2252
+ }
2253
+
2254
+ return (
2255
+ <div className="min-w-0 overflow-hidden rounded-xl border border-border">
2256
+ <table className="w-full table-fixed border-collapse bg-surface text-xs">
2257
+ <colgroup>
2258
+ <col className="w-16" />
2259
+ <col className="w-28" />
2260
+ <col className="w-1/3" />
2261
+ <col className="w-auto" />
2262
+ </colgroup>
2263
+ <thead className="bg-elevated text-left text-[11px] uppercase tracking-wide text-muted">
2264
+ <tr>
2265
+ <th className="border-b border-border px-3 py-2 font-semibold">#</th>
2266
+ <th className="border-b border-border px-3 py-2 font-semibold">Locale</th>
2267
+ <th className="border-b border-border px-3 py-2 font-semibold">Description</th>
2268
+ <th className="border-b border-border px-3 py-2 font-semibold">
2269
+ Evaluated translation
2270
+ </th>
2271
+ </tr>
2272
+ </thead>
2273
+ <tbody>
2274
+ {props.examples.map((example) => {
2275
+ const exampleId = getMessageExampleId(example);
2276
+ const isExpanded = expandedExampleIds.includes(exampleId);
2277
+ const direction = getLocaleDirection(example.locale, props.localeDirections);
2278
+
2279
+ return (
2280
+ <React.Fragment key={exampleId}>
2281
+ <tr
2282
+ id={exampleId}
2283
+ className={[
2284
+ "cursor-pointer align-top transition-colors",
2285
+ isExpanded ? "bg-elevated" : "hover:bg-elevated/60",
2286
+ ].join(" ")}
2287
+ onClick={() => toggleExample(exampleId)}
2288
+ >
2289
+ <td className="border-b border-border px-3 py-2 font-medium text-muted">
2290
+ {getMessageExampleCompactLabel(example)}
2291
+ </td>
2292
+ <td className="border-b border-border px-3 py-2">{example.locale}</td>
2293
+ <td className="min-w-0 border-b border-border px-3 py-2 text-muted">
2294
+ <div className="whitespace-pre-wrap [overflow-wrap:anywhere]">
2295
+ {example.description || "—"}
2296
+ </div>
2297
+ </td>
2298
+ <td
2299
+ className={[
2300
+ "min-w-0 border-b border-border px-3 py-2",
2301
+ direction === "rtl" ? "text-right" : "",
2302
+ ].join(" ")}
2303
+ dir={direction}
2304
+ style={direction ? { unicodeBidi: "plaintext" } : undefined}
2305
+ >
2306
+ <div className="whitespace-pre-wrap [overflow-wrap:anywhere]">
2307
+ {typeof example.evaluatedTranslation === "string"
2308
+ ? example.evaluatedTranslation
2309
+ : JSON.stringify(example.evaluatedTranslation)}
2310
+ </div>
2311
+ </td>
2312
+ </tr>
2313
+ {isExpanded && (
2314
+ <tr className="bg-background/60">
2315
+ <td colSpan={4} className="min-w-0 border-b border-border px-4 py-4">
2316
+ <div className="min-w-0 space-y-4">
2317
+ <div className="group flex items-center gap-2">
2318
+ <h3 className="text-sm font-semibold">
2319
+ {getMessageExampleTitle(example)}
2320
+ </h3>
2321
+ <ExamplePermalink targetId={exampleId} />
2322
+ </div>
2323
+
2324
+ <MessageExampleDetails
2325
+ example={example}
2326
+ showLocale={false}
2327
+ originalTranslationsByLocale={props.originalTranslationsByLocale}
2328
+ />
2329
+ </div>
2330
+ </td>
2331
+ </tr>
2332
+ )}
2333
+ </React.Fragment>
2334
+ );
2335
+ })}
2336
+ </tbody>
2337
+ </table>
2338
+ </div>
2339
+ );
2340
+ }
2341
+
2342
+ function getLocaleExampleId(example: EvaluatedLocaleExample) {
2343
+ return slugifyFragment(
2344
+ [
2345
+ `example-${example.exampleIndex + 1}`,
2346
+ example.locale,
2347
+ typeof example.matrixIndex === "number" ? `matrix-${example.matrixIndex + 1}` : "",
2348
+ ]
2349
+ .filter(Boolean)
2350
+ .join("-"),
2351
+ );
2352
+ }
2353
+
2354
+ function getLocaleExampleTitle(example: EvaluatedLocaleExample) {
2355
+ const titleParts = [`Example #${example.exampleIndex + 1}`];
2356
+
2357
+ if (typeof example.matrixIndex === "number") {
2358
+ titleParts.push(`matrix #${example.matrixIndex + 1}`);
2359
+ }
2360
+
2361
+ return titleParts.join(" · ");
2362
+ }
2363
+
2364
+ function getLocaleExampleCompactLabel(example: EvaluatedLocaleExample) {
2365
+ if (typeof example.matrixIndex === "number") {
2366
+ return `#${example.exampleIndex + 1}.${example.matrixIndex + 1}`;
2367
+ }
2368
+
2369
+ return `#${example.exampleIndex + 1}`;
2370
+ }
2371
+
2372
+ function getLocaleExampleEvaluatedTranslationText(example: EvaluatedLocaleExample) {
2373
+ return typeof example.evaluatedTranslation === "string"
2374
+ ? example.evaluatedTranslation
2375
+ : JSON.stringify(example.evaluatedTranslation);
2376
+ }
2377
+
2378
+ function filterLocaleExamplesBySearch(
2379
+ examples: EvaluatedLocaleExample[],
2380
+ query: string,
2381
+ ): EvaluatedLocaleExample[] {
2382
+ const needle = query.trim().toLowerCase();
2383
+ if (!needle) {
2384
+ return examples;
2385
+ }
2386
+
2387
+ return examples.filter((example) => {
2388
+ const id = getLocaleExampleId(example);
2389
+ const compactId = getLocaleExampleCompactLabel(example);
2390
+ const description = example.description ?? "";
2391
+ const translation = getLocaleExampleEvaluatedTranslationText(example);
2392
+ const haystack = `${id} ${compactId} ${description} ${translation}`.toLowerCase();
2393
+ return haystack.includes(needle);
2394
+ });
2395
+ }
2396
+
2397
+ function LocaleExampleDetails(props: {
2398
+ example: EvaluatedLocaleExample;
2399
+ setKey?: string;
2400
+ localeDirection?: string;
2401
+ showLocale?: boolean;
2402
+ highlightQuery?: string;
2403
+ }) {
2404
+ const { example, setKey, localeDirection } = props;
2405
+ const q = props.highlightQuery?.trim() ?? "";
2406
+ const highlight = Boolean(q);
2407
+
2408
+ function localeLink(localeKey: string) {
2409
+ return highlight ? (
2410
+ <Link
2411
+ to={getEntityRoute("locale", localeKey, setKey)}
2412
+ className="font-medium text-primary hover:underline"
2413
+ >
2414
+ <ExamplesSearchHighlight text={localeKey} query={q} />
2415
+ </Link>
2416
+ ) : (
2417
+ <SourceLocaleLink localeKey={localeKey} />
2418
+ );
2419
+ }
2420
+
2421
+ return (
2422
+ <div className="min-w-0 space-y-4">
2423
+ {props.showLocale !== false && (
2424
+ <InputField label="Locale">{localeLink(example.locale)}</InputField>
2425
+ )}
2426
+
2427
+ {example.sourceLocale !== example.locale && (
2428
+ <InputField label="Merged from">{localeLink(example.sourceLocale)}</InputField>
2429
+ )}
2430
+
2431
+ {example.message && (
2432
+ <>
2433
+ <InputField label="Message">
2434
+ <Link
2435
+ to={getEntityRoute("message", example.message, setKey)}
2436
+ className="font-medium text-primary hover:underline"
2437
+ >
2438
+ {highlight ? (
2439
+ <ExamplesSearchHighlight text={example.message} query={q} />
2440
+ ) : (
2441
+ example.message
2442
+ )}
2443
+ </Link>
2444
+ </InputField>
2445
+
2446
+ {example.originalTranslation && (
2447
+ <InputField label="Original translation">
2448
+ <div className="space-y-2">
2449
+ {highlight ? (
2450
+ <div
2451
+ dir={localeDirection}
2452
+ className={[
2453
+ "min-w-0 max-w-full whitespace-pre-wrap [overflow-wrap:anywhere]",
2454
+ localeDirection === "rtl" ? "text-right" : "",
2455
+ ]
2456
+ .filter(Boolean)
2457
+ .join(" ")}
2458
+ style={localeDirection ? { unicodeBidi: "plaintext" } : undefined}
2459
+ >
2460
+ <ExamplesSearchHighlight text={example.originalTranslation} query={q} />
2461
+ </div>
2462
+ ) : (
2463
+ <TranslationValueBlock
2464
+ value={example.originalTranslation}
2465
+ direction={localeDirection}
2466
+ />
2467
+ )}
2468
+ <p className="text-xs text-muted">
2469
+ See more{" "}
2470
+ <Link
2471
+ to={`${getEntityRoute("message", example.message, setKey)}/translations`}
2472
+ className="font-medium text-primary hover:underline"
2473
+ >
2474
+ translations
2475
+ </Link>{" "}
2476
+ and{" "}
2477
+ <Link
2478
+ to={`${getEntityRoute("message", example.message, setKey)}/overrides`}
2479
+ className="font-medium text-primary hover:underline"
2480
+ >
2481
+ overrides
2482
+ </Link>
2483
+ .
2484
+ </p>
2485
+ </div>
2486
+ </InputField>
2487
+ )}
2488
+ </>
2489
+ )}
2490
+
2491
+ {example.rawMessage && (
2492
+ <InputField label="Raw message">
2493
+ <div
2494
+ dir={localeDirection}
2495
+ className={["min-w-0 max-w-full", localeDirection === "rtl" ? "text-right" : ""]
2496
+ .filter(Boolean)
2497
+ .join(" ")}
2498
+ style={localeDirection ? { unicodeBidi: "plaintext" } : undefined}
2499
+ >
2500
+ {highlight ? (
2501
+ <pre className="max-w-full whitespace-pre-wrap rounded border border-border bg-elevated p-4 text-xs text-text [overflow-wrap:anywhere]">
2502
+ <ExamplesSearchHighlight text={example.rawMessage} query={q} />
2503
+ </pre>
2504
+ ) : (
2505
+ <CodeBlock value={example.rawMessage} />
2506
+ )}
2507
+ </div>
2508
+ </InputField>
2509
+ )}
2510
+
2511
+ {typeof example.values !== "undefined" && (
2512
+ <InputField label="Values">
2513
+ {highlight ? (
2514
+ <pre className="max-w-full whitespace-pre-wrap rounded border border-border bg-elevated p-4 text-xs text-text [overflow-wrap:anywhere]">
2515
+ <ExamplesSearchHighlight text={JSON.stringify(example.values, null, 2)} query={q} />
2516
+ </pre>
2517
+ ) : (
2518
+ <JsonValueBlock value={example.values} />
2519
+ )}
2520
+ </InputField>
2521
+ )}
2522
+
2523
+ {typeof example.context !== "undefined" && (
2524
+ <InputField label="Context">
2525
+ {highlight ? (
2526
+ <pre className="max-w-full whitespace-pre-wrap rounded border border-border bg-elevated p-4 text-xs text-text [overflow-wrap:anywhere]">
2527
+ <ExamplesSearchHighlight text={JSON.stringify(example.context, null, 2)} query={q} />
2528
+ </pre>
2529
+ ) : (
2530
+ <JsonValueBlock value={example.context} />
2531
+ )}
2532
+ </InputField>
2533
+ )}
2534
+
2535
+ {typeof example.timeZone !== "undefined" && (
2536
+ <InputField label="Time zone">
2537
+ <span className="font-mono text-sm text-text">
2538
+ {highlight ? (
2539
+ <ExamplesSearchHighlight text={example.timeZone} query={q} />
2540
+ ) : (
2541
+ example.timeZone
2542
+ )}
2543
+ </span>
2544
+ </InputField>
2545
+ )}
2546
+
2547
+ {typeof example.currency !== "undefined" && (
2548
+ <InputField label="Currency">
2549
+ <span className="font-mono text-sm text-text">
2550
+ {highlight ? (
2551
+ <ExamplesSearchHighlight text={example.currency} query={q} />
2552
+ ) : (
2553
+ example.currency
2554
+ )}
2555
+ </span>
2556
+ </InputField>
2557
+ )}
2558
+
2559
+ {typeof example.formats !== "undefined" && (
2560
+ <InputField label="Formats">
2561
+ {highlight ? (
2562
+ <pre className="max-w-full whitespace-pre-wrap rounded border border-border bg-elevated p-4 text-xs text-text [overflow-wrap:anywhere]">
2563
+ <ExamplesSearchHighlight text={JSON.stringify(example.formats, null, 2)} query={q} />
2564
+ </pre>
2565
+ ) : (
2566
+ <JsonValueBlock value={example.formats} />
2567
+ )}
2568
+ </InputField>
2569
+ )}
2570
+ </div>
2571
+ );
2572
+ }
2573
+
2574
+ function LocaleExamplesExpandedView(props: {
2575
+ examples: EvaluatedLocaleExample[];
2576
+ setKey?: string;
2577
+ localeDirection?: string;
2578
+ searchQuery: string;
2579
+ }) {
2580
+ return (
2581
+ <div className="space-y-6">
2582
+ {props.examples.map((example) => {
2583
+ const titleId = getLocaleExampleId(example);
2584
+
2585
+ return (
2586
+ <section
2587
+ key={`${example.exampleIndex}-${example.matrixIndex ?? "base"}-${example.sourceLocale}-${example.locale}`}
2588
+ className="min-w-0 space-y-4"
2589
+ >
2590
+ <ExampleTitle
2591
+ title={getLocaleExampleTitle(example)}
2592
+ targetId={titleId}
2593
+ description={example.description}
2594
+ highlightQuery={props.searchQuery}
2595
+ />
2596
+
2597
+ <ExampleTable
2598
+ inputs={
2599
+ <LocaleExampleDetails
2600
+ example={example}
2601
+ setKey={props.setKey}
2602
+ localeDirection={props.localeDirection}
2603
+ showLocale={false}
2604
+ highlightQuery={props.searchQuery}
2605
+ />
2606
+ }
2607
+ evaluatedTranslation={example.evaluatedTranslation}
2608
+ direction={props.localeDirection}
2609
+ highlightQuery={props.searchQuery}
2610
+ />
2611
+ </section>
2612
+ );
2613
+ })}
2614
+ </div>
2615
+ );
2616
+ }
2617
+
2618
+ function LocaleExamplesCompactView(props: {
2619
+ examples: EvaluatedLocaleExample[];
2620
+ setKey?: string;
2621
+ localeDirection?: string;
2622
+ searchQuery: string;
2623
+ }) {
2624
+ const [expandedExampleIds, setExpandedExampleIds] = React.useState<string[]>([]);
2625
+ const [lastOpenedExampleId, setLastOpenedExampleId] = React.useState<string | null>(null);
2626
+
2627
+ React.useEffect(() => {
2628
+ if (typeof window === "undefined") {
2629
+ return;
2630
+ }
2631
+
2632
+ const hashTargetId = decodeURIComponent(window.location.hash.slice(1));
2633
+
2634
+ if (!hashTargetId) {
2635
+ return;
2636
+ }
2637
+
2638
+ const matchingExample = props.examples.find(
2639
+ (example) => getLocaleExampleId(example) === hashTargetId,
2640
+ );
2641
+
2642
+ if (!matchingExample) {
2643
+ return;
2644
+ }
2645
+
2646
+ setExpandedExampleIds((current) =>
2647
+ current.includes(hashTargetId) ? current : [...current, hashTargetId],
2648
+ );
2649
+ setLastOpenedExampleId(hashTargetId);
2650
+ }, [props.examples]);
2651
+
2652
+ function toggleExample(exampleId: string) {
2653
+ if (expandedExampleIds.includes(exampleId)) {
2654
+ const nextExpandedExampleIds = expandedExampleIds.filter(
2655
+ (currentId) => currentId !== exampleId,
2656
+ );
2657
+ const nextLastOpenedExampleId =
2658
+ lastOpenedExampleId === exampleId
2659
+ ? nextExpandedExampleIds[nextExpandedExampleIds.length - 1] || null
2660
+ : lastOpenedExampleId;
2661
+
2662
+ setExpandedExampleIds(nextExpandedExampleIds);
2663
+ setLastOpenedExampleId(nextLastOpenedExampleId);
2664
+ setWindowHash(nextLastOpenedExampleId || undefined);
2665
+ return;
2666
+ }
2667
+
2668
+ setExpandedExampleIds((current) => [...current, exampleId]);
2669
+ setLastOpenedExampleId(exampleId);
2670
+ setWindowHash(exampleId);
2671
+ }
2672
+
2673
+ return (
2674
+ <div className="min-w-0 overflow-hidden rounded-xl border border-border">
2675
+ <table className="w-full table-fixed border-collapse bg-surface text-xs">
2676
+ <colgroup>
2677
+ <col className="w-16" />
2678
+ <col className="w-2/5" />
2679
+ <col className="w-auto" />
2680
+ </colgroup>
2681
+ <thead className="bg-elevated text-left text-[11px] uppercase tracking-wide text-muted">
2682
+ <tr>
2683
+ <th className="border-b border-border px-3 py-2 font-semibold">#</th>
2684
+ <th className="border-b border-border px-3 py-2 font-semibold">Description</th>
2685
+ <th className="border-b border-border px-3 py-2 font-semibold">
2686
+ Evaluated translation
2687
+ </th>
2688
+ </tr>
2689
+ </thead>
2690
+ <tbody>
2691
+ {props.examples.map((example) => {
2692
+ const exampleId = getLocaleExampleId(example);
2693
+ const isExpanded = expandedExampleIds.includes(exampleId);
2694
+
2695
+ return (
2696
+ <React.Fragment key={exampleId}>
2697
+ <tr
2698
+ id={exampleId}
2699
+ className={[
2700
+ "cursor-pointer align-top transition-colors",
2701
+ isExpanded ? "bg-elevated" : "hover:bg-elevated/60",
2702
+ ].join(" ")}
2703
+ onClick={() => toggleExample(exampleId)}
2704
+ >
2705
+ <td className="border-b border-border px-3 py-2 font-medium text-muted">
2706
+ <ExamplesSearchHighlight
2707
+ text={getLocaleExampleCompactLabel(example)}
2708
+ query={props.searchQuery}
2709
+ />
2710
+ </td>
2711
+ <td className="min-w-0 border-b border-border px-3 py-2 text-muted">
2712
+ <div className="whitespace-pre-wrap [overflow-wrap:anywhere]">
2713
+ <ExamplesSearchHighlight
2714
+ text={example.description || "—"}
2715
+ query={props.searchQuery}
2716
+ />
2717
+ </div>
2718
+ </td>
2719
+ <td
2720
+ className={[
2721
+ "min-w-0 border-b border-border px-3 py-2",
2722
+ props.localeDirection === "rtl" ? "text-right" : "",
2723
+ ].join(" ")}
2724
+ dir={props.localeDirection}
2725
+ style={props.localeDirection ? { unicodeBidi: "plaintext" } : undefined}
2726
+ >
2727
+ <div className="whitespace-pre-wrap [overflow-wrap:anywhere]">
2728
+ <ExamplesSearchHighlight
2729
+ text={
2730
+ typeof example.evaluatedTranslation === "string"
2731
+ ? example.evaluatedTranslation
2732
+ : JSON.stringify(example.evaluatedTranslation)
2733
+ }
2734
+ query={props.searchQuery}
2735
+ />
2736
+ </div>
2737
+ </td>
2738
+ </tr>
2739
+ {isExpanded && (
2740
+ <tr className="bg-background/60">
2741
+ <td colSpan={3} className="min-w-0 border-b border-border px-4 py-4">
2742
+ <div className="min-w-0 space-y-4">
2743
+ <div className="group flex items-center gap-2">
2744
+ <h3 className="text-sm font-semibold">
2745
+ {props.searchQuery.trim() ? (
2746
+ <ExamplesSearchHighlight
2747
+ text={getLocaleExampleTitle(example)}
2748
+ query={props.searchQuery}
2749
+ />
2750
+ ) : (
2751
+ getLocaleExampleTitle(example)
2752
+ )}
2753
+ </h3>
2754
+ <ExamplePermalink targetId={exampleId} />
2755
+ </div>
2756
+
2757
+ <LocaleExampleDetails
2758
+ example={example}
2759
+ setKey={props.setKey}
2760
+ localeDirection={props.localeDirection}
2761
+ showLocale={false}
2762
+ highlightQuery={props.searchQuery}
2763
+ />
2764
+ </div>
2765
+ </td>
2766
+ </tr>
2767
+ )}
2768
+ </React.Fragment>
2769
+ );
2770
+ })}
2771
+ </tbody>
2772
+ </table>
2773
+ </div>
2774
+ );
2775
+ }
2776
+
2777
+ export function LocaleExamplesTab() {
2778
+ const { detail, setKey } = useEntityDetail();
2779
+ const [searchParams, setSearchParams] = useSearchParams();
2780
+ const examples = (detail.evaluatedExamples || []) as EvaluatedLocaleExample[];
2781
+ const localeDirection = (detail.entity as Record<string, any>).direction as string | undefined;
2782
+ const view = searchParams.get("view");
2783
+ const activeView = view === "expanded" ? "expanded" : "compact";
2784
+ const sourceLocaleKeys = Array.from(
2785
+ new Set(examples.map((example) => example.sourceLocale)),
2786
+ ).sort();
2787
+ const sourceLocaleFilter = searchParams.get("sourceLocale");
2788
+ const activeSourceLocaleFilter =
2789
+ sourceLocaleFilter && sourceLocaleKeys.includes(sourceLocaleFilter) ? sourceLocaleFilter : "";
2790
+ const visibleExamples = activeSourceLocaleFilter
2791
+ ? examples.filter((example) => example.sourceLocale === activeSourceLocaleFilter)
2792
+ : examples;
2793
+
2794
+ const searchQuery = searchParams.get("q") ?? "";
2795
+ const filteredExamples = filterLocaleExamplesBySearch(visibleExamples, searchQuery);
2796
+
2797
+ useScrollToHash([filteredExamples.length, activeSourceLocaleFilter, activeView, searchQuery]);
2798
+
2799
+ if (examples.length === 0) {
2800
+ return <p className="text-sm text-muted">No examples found.</p>;
2801
+ }
2802
+
2803
+ return (
2804
+ <div className="space-y-6">
2805
+ <div className="flex flex-wrap items-center gap-x-4 gap-y-3">
2806
+ <div className="flex min-h-0 min-w-0 flex-1 basis-[min(100%,22rem)] items-center">
2807
+ <Input
2808
+ type="search"
2809
+ value={searchQuery}
2810
+ onChange={(event) => {
2811
+ const val = event.target.value;
2812
+ setSearchParams(setSearchParam(searchParams, "q", val.trim() ? val : undefined), {
2813
+ replace: true,
2814
+ });
2815
+ }}
2816
+ placeholder="Search examples…"
2817
+ aria-label="Search examples"
2818
+ className={[
2819
+ EXAMPLES_TOOLBAR_CONTROL_HEIGHT_CLASS,
2820
+ "box-border rounded-lg border border-border bg-elevated py-0 pl-2 pr-3",
2821
+ "text-xs leading-snug text-text",
2822
+ "placeholder:text-xs placeholder:text-muted placeholder:leading-snug",
2823
+ ].join(" ")}
2824
+ />
2825
+ </div>
2826
+
2827
+ {sourceLocaleKeys.length > 1 ? (
2828
+ <div className="flex min-w-0 flex-wrap items-center gap-2">
2829
+ <span className="text-sm text-muted">Filter by source locale</span>
2830
+ <nav className="flex flex-wrap gap-2">
2831
+ <button
2832
+ type="button"
2833
+ className={[
2834
+ "inline-flex rounded-full border px-3 py-1 text-xs font-semibold transition-colors",
2835
+ activeSourceLocaleFilter
2836
+ ? "border-pill bg-transparent text-text hover:bg-elevated"
2837
+ : "border-primary bg-header-active text-header-text",
2838
+ ].join(" ")}
2839
+ onClick={() =>
2840
+ setSearchParams(setSearchParam(searchParams, "sourceLocale", undefined))
2841
+ }
2842
+ >
2843
+ All
2844
+ </button>
2845
+
2846
+ {sourceLocaleKeys.map((sourceLocaleKey) => {
2847
+ const isActive = activeSourceLocaleFilter === sourceLocaleKey;
2848
+
2849
+ return (
2850
+ <button
2851
+ key={sourceLocaleKey}
2852
+ type="button"
2853
+ className={[
2854
+ "inline-flex rounded-full border px-3 py-1 text-xs font-semibold transition-colors",
2855
+ isActive
2856
+ ? "border-primary bg-header-active text-header-text"
2857
+ : "border-pill bg-transparent text-text hover:bg-elevated",
2858
+ ].join(" ")}
2859
+ onClick={() =>
2860
+ setSearchParams(
2861
+ setSearchParam(
2862
+ searchParams,
2863
+ "sourceLocale",
2864
+ isActive ? undefined : sourceLocaleKey,
2865
+ ),
2866
+ )
2867
+ }
2868
+ >
2869
+ {sourceLocaleKey}
2870
+ </button>
2871
+ );
2872
+ })}
2873
+ </nav>
2874
+ </div>
2875
+ ) : null}
2876
+
2877
+ <div className="ml-auto shrink-0">
2878
+ <ExamplesViewModeSwitch
2879
+ activeView={activeView}
2880
+ onViewChange={(view) =>
2881
+ setSearchParams(
2882
+ setSearchParam(searchParams, "view", view === "compact" ? undefined : view),
2883
+ )
2884
+ }
2885
+ />
2886
+ </div>
2887
+ </div>
2888
+
2889
+ {filteredExamples.length === 0 ? (
2890
+ <p className="text-sm text-muted">No examples match your search.</p>
2891
+ ) : activeView === "compact" ? (
2892
+ <LocaleExamplesCompactView
2893
+ examples={filteredExamples}
2894
+ setKey={setKey}
2895
+ localeDirection={localeDirection}
2896
+ searchQuery={searchQuery}
2897
+ />
2898
+ ) : (
2899
+ <LocaleExamplesExpandedView
2900
+ examples={filteredExamples}
2901
+ setKey={setKey}
2902
+ localeDirection={localeDirection}
2903
+ searchQuery={searchQuery}
2904
+ />
2905
+ )}
2906
+ </div>
2907
+ );
2908
+ }
2909
+
2910
+ function filterDuplicateValuesBySearch(
2911
+ duplicateValues: DuplicateTranslationValue[],
2912
+ query: string,
2913
+ ): DuplicateTranslationValue[] {
2914
+ const needle = query.trim().toLowerCase();
2915
+
2916
+ if (!needle) {
2917
+ return duplicateValues;
2918
+ }
2919
+
2920
+ return duplicateValues.filter((duplicate) => {
2921
+ const haystack = [
2922
+ duplicate.value,
2923
+ ...duplicate.messageKeys,
2924
+ ...duplicate.sources.map((source) => source.locale),
2925
+ ]
2926
+ .join(" ")
2927
+ .toLowerCase();
2928
+
2929
+ return haystack.includes(needle);
2930
+ });
2931
+ }
2932
+
2933
+ export function LocaleDuplicatesTab() {
2934
+ const { detail, setKey } = useEntityDetail();
2935
+ const [searchParams, setSearchParams] = useSearchParams();
2936
+ const [duplicates, setDuplicates] = React.useState<LocaleDuplicates | null>(null);
2937
+ const [error, setError] = React.useState<string | null>(null);
2938
+ const [expandedDuplicateHashes, setExpandedDuplicateHashes] = React.useState<string[]>([]);
2939
+ const localeDirection = (detail.entity as Record<string, any>).direction as string | undefined;
2940
+ const searchQuery = searchParams.get("q") ?? "";
2941
+
2942
+ function toggleDuplicateValue(duplicate: DuplicateTranslationValue) {
2943
+ const duplicateHash = hashTranslationValue(duplicate.value);
2944
+
2945
+ setExpandedDuplicateHashes((current) => {
2946
+ if (current.includes(duplicateHash)) {
2947
+ const next = current.filter((item) => item !== duplicateHash);
2948
+ setWindowHash(undefined);
2949
+ return next;
2950
+ }
2951
+
2952
+ setWindowHash(duplicateHash);
2953
+ return [...current, duplicateHash];
2954
+ });
2955
+ }
2956
+
2957
+ React.useEffect(() => {
2958
+ let cancelled = false;
2959
+
2960
+ setDuplicates(null);
2961
+ setError(null);
2962
+ setExpandedDuplicateHashes([]);
2963
+
2964
+ fetchLocaleDuplicates(detail.key, setKey)
2965
+ .then((data) => {
2966
+ if (!cancelled) {
2967
+ setDuplicates(data);
2968
+ }
2969
+ })
2970
+ .catch((err: Error) => {
2971
+ if (!cancelled) {
2972
+ setError(err.message);
2973
+ }
2974
+ });
2975
+
2976
+ return () => {
2977
+ cancelled = true;
2978
+ };
2979
+ }, [detail.key, setKey]);
2980
+
2981
+ React.useEffect(() => {
2982
+ if (!duplicates || typeof window === "undefined") {
2983
+ return;
2984
+ }
2985
+
2986
+ const hashTargetId = decodeURIComponent(window.location.hash.slice(1));
2987
+
2988
+ if (!hashTargetId) {
2989
+ return;
2990
+ }
2991
+
2992
+ const matchingDuplicate = duplicates.duplicateValues.find(
2993
+ (duplicate) => hashTranslationValue(duplicate.value) === hashTargetId,
2994
+ );
2995
+
2996
+ if (!matchingDuplicate) {
2997
+ return;
2998
+ }
2999
+
3000
+ const matchingHash = hashTranslationValue(matchingDuplicate.value);
3001
+
3002
+ setExpandedDuplicateHashes((current) =>
3003
+ current.includes(matchingHash) ? current : [...current, matchingHash],
3004
+ );
3005
+ }, [duplicates]);
3006
+
3007
+ useScrollToHash([duplicates?.duplicateValues.length, expandedDuplicateHashes.length]);
3008
+
3009
+ if (error) {
3010
+ return <EmptyState title="Unable to load duplicate translations" description={error} />;
3011
+ }
3012
+
3013
+ if (!duplicates) {
3014
+ return <div className="text-sm text-muted">Loading duplicate translations...</div>;
3015
+ }
3016
+
3017
+ if (duplicates.duplicateValues.length === 0) {
3018
+ return <p className="text-sm text-muted">No duplicate translations found for this locale.</p>;
3019
+ }
3020
+
3021
+ const visibleDuplicateValues = filterDuplicateValuesBySearch(
3022
+ duplicates.duplicateValues,
3023
+ searchQuery,
3024
+ );
3025
+
3026
+ return (
3027
+ <div className="space-y-6">
3028
+ <div className="flex flex-wrap items-center gap-x-4 gap-y-3">
3029
+ <div className="flex min-h-0 min-w-0 flex-1 basis-[min(100%,22rem)] items-center">
3030
+ <Input
3031
+ type="search"
3032
+ value={searchQuery}
3033
+ onChange={(event) => {
3034
+ const val = event.target.value;
3035
+ setSearchParams(setSearchParam(searchParams, "q", val.trim() ? val : undefined), {
3036
+ replace: true,
3037
+ });
3038
+ }}
3039
+ placeholder="Search duplicates..."
3040
+ aria-label="Search duplicates"
3041
+ className={[
3042
+ EXAMPLES_TOOLBAR_CONTROL_HEIGHT_CLASS,
3043
+ "box-border rounded-lg border border-border bg-elevated py-0 pl-2 pr-3",
3044
+ "text-xs leading-snug text-text",
3045
+ "placeholder:text-xs placeholder:text-muted placeholder:leading-snug",
3046
+ ].join(" ")}
3047
+ />
3048
+ </div>
3049
+
3050
+ <div className="shrink-0 text-xs text-muted">
3051
+ {duplicates.summary.duplicateValues} value
3052
+ {duplicates.summary.duplicateValues === 1 ? "" : "s"} ·{" "}
3053
+ {duplicates.summary.duplicateMessageKeys} message key
3054
+ {duplicates.summary.duplicateMessageKeys === 1 ? "" : "s"}
3055
+ </div>
3056
+ </div>
3057
+
3058
+ {visibleDuplicateValues.length === 0 ? (
3059
+ <p className="text-sm text-muted">No duplicates match your search.</p>
3060
+ ) : (
3061
+ <div className="min-w-0 overflow-hidden rounded-xl border border-border">
3062
+ <table className="w-full table-fixed border-collapse bg-surface text-xs">
3063
+ <colgroup>
3064
+ <col className="w-[70%]" />
3065
+ <col className="w-[30%]" />
3066
+ </colgroup>
3067
+ <thead className="bg-elevated text-left text-[11px] uppercase tracking-wide text-muted">
3068
+ <tr>
3069
+ <th className="border-b border-border px-3 py-2 font-semibold">Duplicate value</th>
3070
+ <th className="border-b border-border px-3 py-2 font-semibold">Messages</th>
3071
+ </tr>
3072
+ </thead>
3073
+ <tbody>
3074
+ {visibleDuplicateValues.map((duplicate) => {
3075
+ const sourcesByMessageKey = Object.fromEntries(
3076
+ duplicate.sources.map((source) => [source.messageKey, source.locale]),
3077
+ );
3078
+ const duplicateHash = hashTranslationValue(duplicate.value);
3079
+ const isExpanded = expandedDuplicateHashes.includes(duplicateHash);
3080
+
3081
+ return (
3082
+ <React.Fragment key={duplicate.value}>
3083
+ <tr
3084
+ id={duplicateHash}
3085
+ className={[
3086
+ "cursor-pointer align-top transition-colors",
3087
+ isExpanded ? "bg-elevated" : "hover:bg-elevated/60",
3088
+ ].join(" ")}
3089
+ onClick={() => toggleDuplicateValue(duplicate)}
3090
+ >
3091
+ <td
3092
+ className={[
3093
+ "min-w-0 border-b border-border px-3 py-2 font-medium text-text",
3094
+ localeDirection === "rtl" ? "text-right" : "",
3095
+ ].join(" ")}
3096
+ dir={localeDirection}
3097
+ style={localeDirection ? { unicodeBidi: "plaintext" } : undefined}
3098
+ >
3099
+ <div className="truncate">
3100
+ <ExamplesSearchHighlight text={duplicate.value} query={searchQuery} />
3101
+ </div>
3102
+ </td>
3103
+ <td className="min-w-0 border-b border-border px-3 py-2 text-muted">
3104
+ {duplicate.messageKeys.length} message
3105
+ {duplicate.messageKeys.length === 1 ? "" : "s"}
3106
+ </td>
3107
+ </tr>
3108
+ {isExpanded && (
3109
+ <tr className="bg-background/60">
3110
+ <td colSpan={2} className="min-w-0 border-b border-border px-4 py-4">
3111
+ <div className="min-w-0 space-y-3">
3112
+ <div className="space-y-2">
3113
+ <h3 className="text-sm font-semibold">Translation value</h3>
3114
+ <CodeBlock value={duplicate.value} />
3115
+ </div>
3116
+ <h3 className="text-sm font-semibold">Messages</h3>
3117
+ <div className="min-w-0 overflow-hidden rounded-lg border border-border">
3118
+ <table className="w-full table-fixed border-collapse bg-surface text-xs">
3119
+ <colgroup>
3120
+ <col className="w-[70%]" />
3121
+ <col className="w-[30%]" />
3122
+ </colgroup>
3123
+ <thead className="bg-elevated text-left text-[11px] uppercase tracking-wide text-muted">
3124
+ <tr>
3125
+ <th className="border-b border-border px-3 py-2 font-semibold">
3126
+ Message
3127
+ </th>
3128
+ <th className="border-b border-border px-3 py-2 font-semibold">
3129
+ Source
3130
+ </th>
3131
+ </tr>
3132
+ </thead>
3133
+ <tbody>
3134
+ {duplicate.messageKeys.map((messageKey) => {
3135
+ const sourceLocale =
3136
+ sourcesByMessageKey[messageKey] || duplicates.locale;
3137
+ const isInherited = sourceLocale !== duplicates.locale;
3138
+
3139
+ return (
3140
+ <tr key={messageKey} className="align-top">
3141
+ <td className="min-w-0 border-b border-border px-3 py-2">
3142
+ <Link
3143
+ to={getEntityRoute("message", messageKey, setKey)}
3144
+ className="font-medium text-primary hover:underline"
3145
+ >
3146
+ <ExamplesSearchHighlight
3147
+ text={messageKey}
3148
+ query={searchQuery}
3149
+ />
3150
+ </Link>
3151
+ </td>
3152
+ <td className="min-w-0 border-b border-border px-3 py-2 text-muted">
3153
+ {isInherited ? (
3154
+ <>
3155
+ <span>from </span>
3156
+ <Link
3157
+ to={getEntityRoute("locale", sourceLocale, setKey)}
3158
+ className="font-medium text-primary hover:underline"
3159
+ onClick={(event) => event.stopPropagation()}
3160
+ >
3161
+ <ExamplesSearchHighlight
3162
+ text={sourceLocale}
3163
+ query={searchQuery}
3164
+ />
3165
+ </Link>
3166
+ </>
3167
+ ) : (
3168
+ <span>direct</span>
3169
+ )}
3170
+ </td>
3171
+ </tr>
3172
+ );
3173
+ })}
3174
+ </tbody>
3175
+ </table>
3176
+ </div>
3177
+ </div>
3178
+ </td>
3179
+ </tr>
3180
+ )}
3181
+ </React.Fragment>
3182
+ );
3183
+ })}
3184
+ </tbody>
3185
+ </table>
3186
+ </div>
3187
+ )}
3188
+ </div>
3189
+ );
3190
+ }
3191
+
3192
+ export function EntityExamplesTab() {
3193
+ const { detail } = useEntityDetail();
3194
+
3195
+ if (detail.type === "locale") {
3196
+ return <LocaleExamplesTab />;
3197
+ }
3198
+
3199
+ return <MessageExamplesTab />;
3200
+ }
3201
+
3202
+ export function AttributeUsageTab() {
3203
+ const { detail, setKey } = useEntityDetail();
3204
+ const usage = (detail.usage || {}) as Record<string, string[]>;
3205
+
3206
+ return (
3207
+ <div className="grid gap-6 md:grid-cols-2">
3208
+ <section>
3209
+ <h2 className="mb-2 font-semibold">Segments</h2>
3210
+ <UsageLinks type="segment" keys={usage.segments} setKey={setKey} />
3211
+ </section>
3212
+ <section>
3213
+ <h2 className="mb-2 font-semibold">Messages</h2>
3214
+ <UsageLinks type="message" keys={usage.messages} setKey={setKey} />
3215
+ </section>
3216
+ </div>
3217
+ );
3218
+ }
3219
+
3220
+ export function SegmentUsageTab() {
3221
+ const { detail, setKey } = useEntityDetail();
3222
+ const usage = (detail.usage || {}) as Record<string, string[]>;
3223
+
3224
+ return (
3225
+ <div className="grid gap-6 md:grid-cols-2">
3226
+ <section>
3227
+ <h2 className="mb-2 font-semibold">Attributes</h2>
3228
+ <UsageLinks type="attribute" keys={usage.attributes} setKey={setKey} />
3229
+ </section>
3230
+ <section>
3231
+ <h2 className="mb-2 font-semibold">Messages</h2>
3232
+ <UsageLinks type="message" keys={usage.messages} setKey={setKey} />
3233
+ </section>
3234
+ </div>
3235
+ );
3236
+ }
3237
+
3238
+ export function UsageTab() {
3239
+ const { detail } = useEntityDetail();
3240
+
3241
+ if (detail.type === "segment") {
3242
+ return <SegmentUsageTab />;
3243
+ }
3244
+
3245
+ return <AttributeUsageTab />;
3246
+ }
3247
+
3248
+ export function SegmentConditionsTab() {
3249
+ const { detail, setKey } = useEntityDetail();
3250
+ const entity = detail.entity as Record<string, any>;
3251
+
3252
+ return <ConditionTree conditions={entity.conditions} setKey={setKey} />;
3253
+ }
3254
+
3255
+ export function TargetFormatsTab() {
3256
+ const { detail, setKey } = useEntityDetail();
3257
+ const { localeKey } = useParams();
3258
+ const [searchParams] = useSearchParams();
3259
+ const searchQuery = searchParams.get("q") ?? "";
3260
+ const rowsByLocale = (detail.formatRowsByLocale || {}) as Record<string, FormatRow[]>;
3261
+ const localeKeys = Object.keys(rowsByLocale).sort();
3262
+
3263
+ const activeLocaleKey =
3264
+ localeKeys.length === 0 ? "" : localeKey && rowsByLocale[localeKey] ? localeKey : localeKeys[0];
3265
+
3266
+ const activeFormatRows =
3267
+ activeLocaleKey && rowsByLocale[activeLocaleKey] ? rowsByLocale[activeLocaleKey] : [];
3268
+
3269
+ const { formatTypes, formatTypePillKeys, selectedFormatType } =
3270
+ useFormatsTypePillSelection(activeFormatRows);
3271
+
3272
+ if (localeKeys.length === 0) {
3273
+ return <p className="text-sm text-muted">No target formats found.</p>;
3274
+ }
3275
+
3276
+ const basePath = `${getBasePath(setKey)}/targets/${encodeRouteSegment(detail.key)}/formats`;
3277
+
3278
+ if (!localeKey) {
3279
+ const qs = searchParams.toString();
3280
+ return (
3281
+ <Navigate
3282
+ to={`${basePath}/${encodeRouteSegment(activeLocaleKey)}${qs ? `?${qs}` : ""}`}
3283
+ replace
3284
+ />
3285
+ );
3286
+ }
3287
+
3288
+ return (
3289
+ <div className="space-y-6">
3290
+ <nav className="flex min-w-0 flex-wrap gap-2">
3291
+ {localeKeys.map((item) => {
3292
+ const qs = searchParams.toString();
3293
+ const suffix = qs ? `?${qs}` : "";
3294
+ const isActive = item === activeLocaleKey;
3295
+ return (
3296
+ <Link
3297
+ key={item}
3298
+ to={`${basePath}/${encodeRouteSegment(item)}${suffix}`}
3299
+ className={[
3300
+ "inline-flex rounded-full border px-3 py-1 text-xs font-semibold transition-colors",
3301
+ isActive
3302
+ ? "border-primary bg-header-active text-header-text"
3303
+ : "border-pill bg-transparent text-text hover:bg-elevated",
3304
+ ].join(" ")}
3305
+ >
3306
+ {item}
3307
+ </Link>
3308
+ );
3309
+ })}
3310
+ </nav>
3311
+
3312
+ {formatTypes.length > 0 ? <FormatsTypePills typeKeys={formatTypePillKeys} /> : null}
3313
+
3314
+ <FormatsSearchToolbar />
3315
+
3316
+ <FormatRowsTable
3317
+ rows={rowsByLocale[activeLocaleKey]}
3318
+ searchQuery={searchQuery}
3319
+ formatPathLayout="split"
3320
+ hideTypeColumn={selectedFormatType != null}
3321
+ selectedFormatType={selectedFormatType}
3322
+ showExampleColumn={showFormatExampleColumn(selectedFormatType)}
3323
+ />
3324
+ </div>
3325
+ );
3326
+ }
3327
+
3328
+ export function TargetMessagesTab() {
3329
+ const { detail, setKey } = useEntityDetail();
3330
+
3331
+ return <UsageLinks type="message" keys={detail.messages as string[]} setKey={setKey} />;
3332
+ }
3333
+
3334
+ export function EntityHistoryTab() {
3335
+ const { manifest } = useCatalog();
3336
+ const { detail, setKey } = useEntityDetail();
3337
+
3338
+ return (
3339
+ <HistoryTimeline
3340
+ path={`${setKey ? `/data/sets/${encodeRouteSegment(setKey)}` : "/data/root"}/history/${detail.type}/${encodeRouteSegment(detail.key)}`}
3341
+ setKey={setKey}
3342
+ commitUrl={manifest.links?.commit}
3343
+ />
3344
+ );
3345
+ }