@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,23 @@
1
+ import ReactMarkdown from "react-markdown";
2
+
3
+ export function MarkdownContent(props: { value?: string }) {
4
+ if (!props.value || !props.value.trim()) {
5
+ return <p className="text-sm text-muted">No description.</p>;
6
+ }
7
+
8
+ return (
9
+ <div
10
+ className={[
11
+ "prose prose-sm max-w-none prose-headings:text-text prose-p:text-text prose-strong:text-text prose-a:text-primary",
12
+ /* Inline `code`: light fill, slightly darker border for definition */
13
+ "[&_code]:rounded-md [&_code]:border [&_code]:border-faint [&_code]:bg-elevated [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:font-mono [&_code]:text-[0.8125rem] [&_code]:font-medium [&_code]:text-text",
14
+ /* Fenced blocks: airy surface with a clearer frame */
15
+ "[&_pre]:my-3 [&_pre]:rounded-xl [&_pre]:border [&_pre]:border-faint [&_pre]:bg-elevated [&_pre]:p-4 [&_pre]:shadow-sm",
16
+ /* Reset inner code inside pre (do not use pill/chip styling) */
17
+ "[&_pre_code]:m-0 [&_pre_code]:block [&_pre_code]:w-full [&_pre_code]:rounded-none [&_pre_code]:border-0 [&_pre_code]:bg-transparent [&_pre_code]:p-0 [&_pre_code]:text-xs [&_pre_code]:font-normal [&_pre_code]:leading-relaxed [&_pre_code]:shadow-none",
18
+ ].join(" ")}
19
+ >
20
+ <ReactMarkdown>{props.value}</ReactMarkdown>
21
+ </div>
22
+ );
23
+ }
@@ -0,0 +1,263 @@
1
+ import { Link } from "react-router-dom";
2
+ import * as React from "react";
3
+
4
+ import type { TranslationRow } from "../../types";
5
+ import { getEntityRoute } from "../../entityTypes";
6
+ import { useEntityDetail } from "../../pages/EntityDetailPage";
7
+
8
+ function getDirectionClassName(direction?: string) {
9
+ return direction === "rtl" ? "text-right" : "";
10
+ }
11
+
12
+ function getDirectionStyle(direction?: string) {
13
+ if (!direction) {
14
+ return undefined;
15
+ }
16
+
17
+ return { unicodeBidi: "plaintext" as const };
18
+ }
19
+
20
+ function setWindowHash(targetId?: string) {
21
+ if (typeof window === "undefined") {
22
+ return;
23
+ }
24
+
25
+ const url = new URL(window.location.href);
26
+
27
+ if (!targetId) {
28
+ url.hash = "";
29
+ } else {
30
+ url.hash = targetId;
31
+ }
32
+
33
+ window.history.replaceState(null, "", url.toString());
34
+ }
35
+
36
+ export function TranslationsTable(props: {
37
+ translations?: Record<string, string>;
38
+ rows?: TranslationRow[];
39
+ linkLocales?: boolean;
40
+ showSource?: boolean;
41
+ translationLabel?: string;
42
+ comparisonLabel?: string;
43
+ comparisonValues?: Record<string, string | undefined>;
44
+ localeDirections?: Record<string, string | undefined>;
45
+ renderMetaCell?: (entry: TranslationRow) => React.ReactNode;
46
+ renderExpandedRow?: (entry: TranslationRow) => React.ReactNode;
47
+ getRowKey?: (entry: TranslationRow) => string;
48
+ getRowFragmentId?: (entry: TranslationRow) => string;
49
+ }) {
50
+ const { setKey } = useEntityDetail();
51
+ const showSource = props.showSource !== false;
52
+ const entries: TranslationRow[] = props.rows
53
+ ? props.rows
54
+ : Object.entries(props.translations || {})
55
+ .sort(([a], [b]) => a.localeCompare(b))
56
+ .map(([locale, value]) => ({ locale, value, source: "direct" as const }));
57
+ const [expandedRowKeys, setExpandedRowKeys] = React.useState<string[]>([]);
58
+ const [lastOpenedRowFragmentId, setLastOpenedRowFragmentId] = React.useState<string | null>(null);
59
+
60
+ if (entries.length === 0) {
61
+ return <p className="text-sm text-muted">No translations found.</p>;
62
+ }
63
+
64
+ React.useEffect(() => {
65
+ if (typeof window === "undefined" || !props.getRowFragmentId) {
66
+ return;
67
+ }
68
+
69
+ const hashTargetId = decodeURIComponent(window.location.hash.slice(1));
70
+
71
+ if (!hashTargetId) {
72
+ return;
73
+ }
74
+
75
+ const matchingEntry = entries.find((entry) => props.getRowFragmentId?.(entry) === hashTargetId);
76
+
77
+ if (!matchingEntry) {
78
+ return;
79
+ }
80
+
81
+ const rowKey = props.getRowKey ? props.getRowKey(matchingEntry) : matchingEntry.locale;
82
+
83
+ setExpandedRowKeys((current) => (current.includes(rowKey) ? current : [...current, rowKey]));
84
+ setLastOpenedRowFragmentId(hashTargetId);
85
+ }, [entries, props.getRowFragmentId, props.getRowKey]);
86
+
87
+ React.useEffect(() => {
88
+ if (!lastOpenedRowFragmentId || typeof window === "undefined") {
89
+ return;
90
+ }
91
+
92
+ const frame = window.requestAnimationFrame(() => {
93
+ const targetElement = document.getElementById(lastOpenedRowFragmentId);
94
+
95
+ if (!targetElement) {
96
+ return;
97
+ }
98
+
99
+ targetElement.scrollIntoView({ block: "start" });
100
+ });
101
+
102
+ return () => {
103
+ window.cancelAnimationFrame(frame);
104
+ };
105
+ }, [lastOpenedRowFragmentId]);
106
+
107
+ const columnCount =
108
+ 2 + (props.comparisonLabel ? 1 : 0) + (props.renderMetaCell ? 1 : 0) + (showSource ? 1 : 0);
109
+
110
+ return (
111
+ <div className="overflow-hidden rounded border border-border">
112
+ <table className="w-full border-collapse bg-surface text-xs">
113
+ <thead className="bg-elevated text-left text-[11px] uppercase tracking-wide text-muted">
114
+ <tr>
115
+ <th className="border-b border-border px-3 py-2 font-semibold">Locale</th>
116
+ <th className="border-b border-border px-3 py-2 font-semibold">
117
+ {props.translationLabel || "Translation"}
118
+ </th>
119
+ {props.comparisonLabel && (
120
+ <th className="border-b border-border px-3 py-2 font-semibold">
121
+ {props.comparisonLabel}
122
+ </th>
123
+ )}
124
+ {props.renderMetaCell && (
125
+ <th className="border-b border-border px-3 py-2 font-semibold" />
126
+ )}
127
+ {showSource && (
128
+ <th className="border-b border-border px-3 py-2 font-semibold">Source</th>
129
+ )}
130
+ </tr>
131
+ </thead>
132
+ <tbody>
133
+ {entries.map((entry) => {
134
+ const rowKey = props.getRowKey ? props.getRowKey(entry) : entry.locale;
135
+ const rowFragmentId = props.getRowFragmentId?.(entry);
136
+ const isExpandable = Boolean(props.renderExpandedRow);
137
+ const isExpanded = expandedRowKeys.includes(rowKey);
138
+
139
+ return (
140
+ <React.Fragment key={rowKey}>
141
+ <tr
142
+ id={rowFragmentId}
143
+ className={
144
+ isExpandable
145
+ ? [
146
+ "cursor-pointer transition-colors",
147
+ isExpanded ? "bg-elevated" : "hover:bg-elevated/60",
148
+ ].join(" ")
149
+ : undefined
150
+ }
151
+ onClick={
152
+ isExpandable
153
+ ? () =>
154
+ setExpandedRowKeys((current) => {
155
+ if (current.includes(rowKey)) {
156
+ const nextExpandedRowKeys = current.filter(
157
+ (value) => value !== rowKey,
158
+ );
159
+ const nextLastOpenedRowKey =
160
+ rowFragmentId && lastOpenedRowFragmentId === rowFragmentId
161
+ ? nextExpandedRowKeys[nextExpandedRowKeys.length - 1] || null
162
+ : current[current.length - 1] || null;
163
+ const nextLastOpenedRowFragmentId = nextLastOpenedRowKey
164
+ ? props.getRowFragmentId?.(
165
+ entries.find(
166
+ (item) =>
167
+ (props.getRowKey ? props.getRowKey(item) : item.locale) ===
168
+ nextLastOpenedRowKey,
169
+ ) as TranslationRow,
170
+ ) || null
171
+ : null;
172
+
173
+ setLastOpenedRowFragmentId(nextLastOpenedRowFragmentId);
174
+ setWindowHash(nextLastOpenedRowFragmentId || undefined);
175
+ return nextExpandedRowKeys;
176
+ }
177
+
178
+ if (rowFragmentId) {
179
+ setLastOpenedRowFragmentId(rowFragmentId);
180
+ setWindowHash(rowFragmentId);
181
+ }
182
+
183
+ return [...current, rowKey];
184
+ })
185
+ : undefined
186
+ }
187
+ >
188
+ <td className="border-b border-border px-3 py-2 font-medium">
189
+ {props.linkLocales ? (
190
+ <Link
191
+ to={getEntityRoute("locale", entry.locale, setKey)}
192
+ className="font-medium text-primary hover:underline"
193
+ onClick={(event) => event.stopPropagation()}
194
+ >
195
+ {entry.locale}
196
+ </Link>
197
+ ) : (
198
+ entry.locale
199
+ )}
200
+ </td>
201
+ <td
202
+ className={[
203
+ "border-b border-border px-3 py-2",
204
+ entry.source === "inherited" ? "text-faint" : "",
205
+ getDirectionClassName(props.localeDirections?.[entry.locale]),
206
+ ].join(" ")}
207
+ dir={props.localeDirections?.[entry.locale]}
208
+ style={getDirectionStyle(props.localeDirections?.[entry.locale])}
209
+ >
210
+ {entry.value || "—"}
211
+ </td>
212
+ {props.comparisonLabel && (
213
+ <td
214
+ className={[
215
+ "border-b border-border px-3 py-2 text-muted",
216
+ getDirectionClassName(props.localeDirections?.[entry.locale]),
217
+ ].join(" ")}
218
+ dir={props.localeDirections?.[entry.locale]}
219
+ style={getDirectionStyle(props.localeDirections?.[entry.locale])}
220
+ >
221
+ {props.comparisonValues?.[entry.locale] || "—"}
222
+ </td>
223
+ )}
224
+ {props.renderMetaCell && (
225
+ <td className="border-b border-border px-3 py-2">
226
+ {props.renderMetaCell(entry)}
227
+ </td>
228
+ )}
229
+ {showSource && (
230
+ <td className="border-b border-border px-3 py-2 text-muted">
231
+ {entry.source}
232
+ {entry.from ? (
233
+ <>
234
+ {" from "}
235
+ <Link
236
+ to={getEntityRoute("locale", entry.from, setKey)}
237
+ className="font-medium text-primary hover:underline"
238
+ onClick={(event) => event.stopPropagation()}
239
+ >
240
+ {entry.from}
241
+ </Link>
242
+ </>
243
+ ) : (
244
+ ""
245
+ )}
246
+ </td>
247
+ )}
248
+ </tr>
249
+ {isExpandable && isExpanded && (
250
+ <tr className="bg-background/60">
251
+ <td colSpan={columnCount} className="border-b border-border px-4 py-4">
252
+ {props.renderExpandedRow?.(entry)}
253
+ </td>
254
+ </tr>
255
+ )}
256
+ </React.Fragment>
257
+ );
258
+ })}
259
+ </tbody>
260
+ </table>
261
+ </div>
262
+ );
263
+ }
@@ -0,0 +1,29 @@
1
+ import { Link } from "react-router-dom";
2
+
3
+ import type { EntityType } from "../../types";
4
+ import { getEntityRoute } from "../../entityTypes";
5
+
6
+ export function UsageLinks(props: { type: EntityType; keys?: string[]; setKey?: string }) {
7
+ if (!props.keys || props.keys.length === 0) {
8
+ return <p className="text-sm text-muted">No usage found.</p>;
9
+ }
10
+
11
+ return (
12
+ <ul className="list-inside list-disc space-y-1 text-sm">
13
+ {props.keys.map((key) => (
14
+ <li key={key}>
15
+ <Link
16
+ className="text-primary hover:underline"
17
+ to={getEntityRoute(
18
+ props.type,
19
+ props.type === "attribute" ? key.split(".")[0] : key,
20
+ props.setKey,
21
+ )}
22
+ >
23
+ {key}
24
+ </Link>
25
+ </li>
26
+ ))}
27
+ </ul>
28
+ );
29
+ }
@@ -0,0 +1,122 @@
1
+ import * as React from "react";
2
+ import { Link } from "react-router-dom";
3
+
4
+ import { fetchHistoryPage } from "../../api";
5
+ import { entityLabels, getEntityRoute } from "../../entityTypes";
6
+ import type { HistoryEntry } from "../../types";
7
+ import { formatCatalogTimestamp } from "../../utils/formatCatalogTimestamp";
8
+ import { CATALOG_HISTORY_VISIBLE_ENTITY_LIMIT } from "../../config";
9
+ import { Button } from "../ui/Button";
10
+ import { EmptyState } from "../ui/EmptyState";
11
+
12
+ function HistoryEntryCard(props: { entry: HistoryEntry; setKey?: string; commitUrl?: string }) {
13
+ const [expanded, setExpanded] = React.useState(false);
14
+ const hasMore = props.entry.entities.length > CATALOG_HISTORY_VISIBLE_ENTITY_LIMIT;
15
+ const visibleEntities = expanded
16
+ ? props.entry.entities
17
+ : props.entry.entities.slice(0, CATALOG_HISTORY_VISIBLE_ENTITY_LIMIT);
18
+
19
+ return (
20
+ <li
21
+ key={`${props.entry.commit}-${props.entry.timestamp}`}
22
+ className="rounded-lg border border-border bg-surface p-4 shadow-sm ring-1 ring-black/5"
23
+ >
24
+ <div className="text-sm">
25
+ <span className="font-semibold">{props.entry.author}</span>{" "}
26
+ <a
27
+ className="text-primary hover:underline"
28
+ href={
29
+ props.commitUrl?.replace("{{hash}}", props.entry.commit) || `#${props.entry.commit}`
30
+ }
31
+ target="_blank"
32
+ rel="noreferrer"
33
+ >
34
+ {formatCatalogTimestamp(props.entry.timestamp)}
35
+ </a>
36
+ </div>
37
+ <ul className="mt-3 list-inside list-disc space-y-1 text-sm text-muted">
38
+ {visibleEntities.map((entity) => (
39
+ <li key={`${entity.type}-${entity.key}`}>
40
+ {entity.type !== "test" ? entityLabels[entity.type].singular : "Test"}{" "}
41
+ {entity.type !== "test" ? (
42
+ <Link
43
+ className="font-medium text-primary hover:underline"
44
+ to={getEntityRoute(entity.type, entity.key, entity.set || props.setKey)}
45
+ >
46
+ {entity.key}
47
+ </Link>
48
+ ) : (
49
+ entity.key
50
+ )}
51
+ </li>
52
+ ))}
53
+ </ul>
54
+ {hasMore && (
55
+ <button
56
+ type="button"
57
+ onClick={() => setExpanded((current) => !current)}
58
+ className="mt-3 text-sm font-semibold text-primary hover:underline"
59
+ >
60
+ {expanded
61
+ ? "See less"
62
+ : `See more (${props.entry.entities.length - CATALOG_HISTORY_VISIBLE_ENTITY_LIMIT} more)`}
63
+ </button>
64
+ )}
65
+ </li>
66
+ );
67
+ }
68
+
69
+ export function HistoryTimeline(props: { path: string; setKey?: string; commitUrl?: string }) {
70
+ const [entries, setEntries] = React.useState<HistoryEntry[]>([]);
71
+ const [page, setPage] = React.useState(0);
72
+ const [totalPages, setTotalPages] = React.useState(1);
73
+ const [error, setError] = React.useState<string | null>(null);
74
+
75
+ async function loadPage(nextPage: number) {
76
+ try {
77
+ const response = await fetchHistoryPage(props.path, nextPage);
78
+ setEntries((current) => [...current, ...response.entries]);
79
+ setPage(response.page);
80
+ setTotalPages(response.totalPages);
81
+ } catch (err) {
82
+ setError((err as Error).message);
83
+ }
84
+ }
85
+
86
+ React.useEffect(() => {
87
+ setEntries([]);
88
+ setPage(0);
89
+ setTotalPages(1);
90
+ setError(null);
91
+ loadPage(1);
92
+ }, [props.path]);
93
+
94
+ if (error) {
95
+ return <EmptyState title="History unavailable" description={error} />;
96
+ }
97
+
98
+ if (entries.length === 0 && page > 0) {
99
+ return <EmptyState title="No history found" />;
100
+ }
101
+
102
+ return (
103
+ <div className="space-y-4">
104
+ <ol className="space-y-4">
105
+ {entries.map((entry) => (
106
+ <HistoryEntryCard
107
+ key={`${entry.commit}-${entry.timestamp}`}
108
+ entry={entry}
109
+ setKey={props.setKey}
110
+ commitUrl={props.commitUrl}
111
+ />
112
+ ))}
113
+ </ol>
114
+
115
+ {page < totalPages && (
116
+ <Button onClick={() => loadPage(page + 1)} className="w-full">
117
+ Load more
118
+ </Button>
119
+ )}
120
+ </div>
121
+ );
122
+ }