@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,338 @@
1
+ import * as React from "react";
2
+ import type { ReactNode } from "react";
3
+ import { NavLink, useLocation, useNavigate } from "react-router-dom";
4
+
5
+ import { fetchIndex } from "../../api";
6
+ import {
7
+ encodeRouteSegment,
8
+ entityPaths,
9
+ entityPathToType,
10
+ entityLabels,
11
+ getBasePath,
12
+ } from "../../entityTypes";
13
+ import { CATALOG_NAV_LOGO_MARK_SRC, CATALOG_NAV_LOGO_WORDMARK_SRC } from "../../catalogBrandAssets";
14
+ import { themeClasses } from "../../theme";
15
+ import { useCatalog } from "../../context/CatalogContext";
16
+ import type { CatalogIndex, EntityPath } from "../../types";
17
+
18
+ function sidebarClass({ isActive }: { isActive: boolean }) {
19
+ return [
20
+ "flex items-center justify-between rounded-lg px-3 py-2 text-sm font-bold",
21
+ isActive ? "bg-header-active text-header-text" : "text-muted hover:bg-elevated hover:text-text",
22
+ ].join(" ");
23
+ }
24
+
25
+ function Sidebar(props: { setKey?: string }) {
26
+ const [index, setIndex] = React.useState<CatalogIndex | null>(null);
27
+ const basePath = getBasePath(props.setKey);
28
+ const historyLabel = "History";
29
+
30
+ React.useEffect(() => {
31
+ setIndex(null);
32
+ fetchIndex(props.setKey)
33
+ .then(setIndex)
34
+ .catch(() => setIndex(null));
35
+ }, [props.setKey]);
36
+
37
+ return (
38
+ <aside className="rounded-lg bg-surface p-4 shadow-md ring-1 ring-black/5 md:w-56">
39
+ <div className="mb-3 text-xs font-black uppercase tracking-wide text-muted">
40
+ <span className="block px-3 text-muted">{props.setKey ? "Set" : "Project"}</span>
41
+ </div>
42
+ <nav className="space-y-1">
43
+ {entityPaths.map((entityPath) => {
44
+ const type = entityPathToType[entityPath];
45
+
46
+ return (
47
+ <NavLink key={entityPath} to={`${basePath}/${entityPath}`} className={sidebarClass}>
48
+ <span>{entityLabels[type].plural}</span>
49
+ <span className="rounded-full bg-pill px-2 py-0.5 text-xs font-black text-header">
50
+ {index?.counts[type] ?? "-"}
51
+ </span>
52
+ </NavLink>
53
+ );
54
+ })}
55
+ <NavLink
56
+ to={`${basePath}/history`}
57
+ className={({ isActive }) =>
58
+ [
59
+ "mt-4 block rounded-lg px-3 py-2 text-sm font-bold",
60
+ isActive
61
+ ? "bg-header-active text-header-text"
62
+ : "text-muted hover:bg-elevated hover:text-text",
63
+ ].join(" ")
64
+ }
65
+ >
66
+ {historyLabel}
67
+ </NavLink>
68
+ </nav>
69
+ </aside>
70
+ );
71
+ }
72
+
73
+ function isEntityPath(value: string): value is EntityPath {
74
+ return entityPaths.indexOf(value as EntityPath) !== -1;
75
+ }
76
+
77
+ function hasEntity(index: CatalogIndex, entityPath: EntityPath, entityKey: string) {
78
+ const type = entityPathToType[entityPath];
79
+ const entities = index.entities[type] || [];
80
+
81
+ return entities.some((entity) => entity.key === entityKey);
82
+ }
83
+
84
+ async function getSetSwitchPath(pathname: string, nextSetKey: string) {
85
+ const encodedSetKey = encodeRouteSegment(nextSetKey);
86
+ const listMatch = pathname.match(/^\/sets\/[^/]+\/([^/]+)$/);
87
+
88
+ if (listMatch && isEntityPath(listMatch[1])) {
89
+ return `/sets/${encodedSetKey}/${listMatch[1]}`;
90
+ }
91
+
92
+ if (pathname.match(/^\/sets\/[^/]+\/history$/)) {
93
+ return `/sets/${encodedSetKey}/history`;
94
+ }
95
+
96
+ const detailMatch = pathname.match(/^\/sets\/[^/]+\/([^/]+)\/([^/]+)(\/.*)?$/);
97
+
98
+ if (detailMatch && isEntityPath(detailMatch[1])) {
99
+ const entityPath = detailMatch[1];
100
+ const entityKey = decodeURIComponent(detailMatch[2]);
101
+ const suffix = detailMatch[3] || "";
102
+
103
+ try {
104
+ const index = await fetchIndex(nextSetKey);
105
+
106
+ if (hasEntity(index, entityPath, entityKey)) {
107
+ return `/sets/${encodedSetKey}/${entityPath}/${encodeRouteSegment(entityKey)}${suffix}`;
108
+ }
109
+
110
+ return `/sets/${encodedSetKey}/${entityPath}`;
111
+ } catch {
112
+ return `/sets/${encodedSetKey}/${entityPath}`;
113
+ }
114
+ }
115
+
116
+ return `/sets/${encodedSetKey}/messages`;
117
+ }
118
+
119
+ function SetSwitcher(props: { currentSetKey?: string }) {
120
+ const { manifest } = useCatalog();
121
+ const location = useLocation();
122
+ const navigate = useNavigate();
123
+ const setSelectId = React.useId();
124
+ const setSelectRef = React.useRef<HTMLSelectElement | null>(null);
125
+ const selectedSetKey = props.currentSetKey || manifest.setKeys[0] || "";
126
+
127
+ if (!manifest.sets || manifest.setKeys.length === 0) {
128
+ return null;
129
+ }
130
+
131
+ function openSetPicker() {
132
+ const select = setSelectRef.current;
133
+
134
+ if (!select) {
135
+ return;
136
+ }
137
+
138
+ select.focus();
139
+
140
+ if (
141
+ typeof (select as HTMLSelectElement & { showPicker?: () => void }).showPicker === "function"
142
+ ) {
143
+ (select as HTMLSelectElement & { showPicker: () => void }).showPicker();
144
+ }
145
+ }
146
+
147
+ return (
148
+ <label
149
+ htmlFor={setSelectId}
150
+ className="relative inline-flex cursor-pointer items-center gap-2 rounded-lg bg-header-active px-3 py-1.5 text-sm font-semibold text-header-text"
151
+ onClick={(event) => {
152
+ if (event.target instanceof HTMLSelectElement) {
153
+ return;
154
+ }
155
+
156
+ openSetPicker();
157
+ }}
158
+ >
159
+ <span className="text-xs font-black uppercase tracking-wide text-pill">Set</span>
160
+ <select
161
+ id={setSelectId}
162
+ ref={setSelectRef}
163
+ value={selectedSetKey}
164
+ onChange={async (event) => {
165
+ const nextSetKey = event.target.value;
166
+
167
+ navigate(await getSetSwitchPath(location.pathname, nextSetKey));
168
+ }}
169
+ className="max-w-44 appearance-none bg-transparent pr-7 font-black text-header-text outline-none"
170
+ aria-label="Switch catalog set"
171
+ >
172
+ {manifest.setKeys.map((setKey) => (
173
+ <option key={setKey} value={setKey}>
174
+ {setKey}
175
+ </option>
176
+ ))}
177
+ </select>
178
+ <svg
179
+ aria-hidden="true"
180
+ viewBox="0 0 20 20"
181
+ fill="none"
182
+ className="pointer-events-none absolute right-3 h-4 w-4 text-pill"
183
+ >
184
+ <path
185
+ d="M6 8l4 4 4-4"
186
+ stroke="currentColor"
187
+ strokeWidth="2"
188
+ strokeLinecap="round"
189
+ strokeLinejoin="round"
190
+ />
191
+ </svg>
192
+ </label>
193
+ );
194
+ }
195
+
196
+ function RepositoryIcon(props: { provider?: string }) {
197
+ if (props.provider === "github") {
198
+ return (
199
+ <svg aria-hidden="true" className="h-5 w-5" viewBox="0 0 16 16" fill="currentColor">
200
+ <path d="M8 0C3.58 0 0 3.69 0 8.24c0 3.64 2.29 6.72 5.47 7.81.4.08.55-.18.55-.4 0-.2-.01-.86-.01-1.56-2.01.38-2.53-.5-2.69-.96-.09-.24-.48-.96-.82-1.15-.28-.16-.68-.55-.01-.56.63-.01 1.08.6 1.23.85.72 1.25 1.87.9 2.33.69.07-.54.28-.9.51-1.11-1.78-.21-3.64-.92-3.64-4.07 0-.9.31-1.64.82-2.22-.08-.21-.36-1.05.08-2.19 0 0 .67-.22 2.2.85A7.43 7.43 0 0 1 8 3.94c.68 0 1.36.09 2 .28 1.52-1.07 2.19-.85 2.19-.85.44 1.14.16 1.98.08 2.19.51.58.82 1.32.82 2.22 0 3.16-1.87 3.86-3.65 4.07.29.26.54.76.54 1.54 0 1.11-.01 2.01-.01 2.28 0 .22.15.48.55.4A8.13 8.13 0 0 0 16 8.24C16 3.69 12.42 0 8 0Z" />
201
+ </svg>
202
+ );
203
+ }
204
+
205
+ if (props.provider === "gitlab") {
206
+ return (
207
+ <svg aria-hidden="true" className="h-5 w-5" viewBox="0 0 24 24" fill="currentColor">
208
+ <path d="m22.75 9.77-.03-.08-2.17-6.69a.57.57 0 0 0-.55-.39.58.58 0 0 0-.52.35l-1.47 4.48H5.99L4.52 2.96A.58.58 0 0 0 4 2.61a.57.57 0 0 0-.55.39L1.28 9.69l-.03.08a1.54 1.54 0 0 0 .51 1.73l.01.01 10.22 7.43 10.24-7.44.01-.01a1.54 1.54 0 0 0 .51-1.72Z" />
209
+ </svg>
210
+ );
211
+ }
212
+
213
+ if (props.provider === "bitbucket") {
214
+ return (
215
+ <svg aria-hidden="true" className="h-5 w-5" viewBox="0 0 24 24" fill="currentColor">
216
+ <path d="M2.19 3.25a.77.77 0 0 0-.76.89l2.7 16.42a1.02 1.02 0 0 0 1 .86H18.9a1.02 1.02 0 0 0 1-.82l2.69-16.46a.77.77 0 0 0-.76-.89H2.19Zm13.36 10.71H9.46l-1.1-5.83h8.25l-1.06 5.83Z" />
217
+ </svg>
218
+ );
219
+ }
220
+
221
+ return null;
222
+ }
223
+
224
+ function isKnownRepositoryProvider(provider?: string) {
225
+ return provider === "github" || provider === "gitlab" || provider === "bitbucket";
226
+ }
227
+
228
+ function formatGeneratedAt(value: string) {
229
+ const date = new Date(value);
230
+
231
+ if (Number.isNaN(date.getTime())) {
232
+ return value;
233
+ }
234
+
235
+ const localTimestamp = Date.UTC(
236
+ date.getFullYear(),
237
+ date.getMonth(),
238
+ date.getDate(),
239
+ date.getHours(),
240
+ date.getMinutes(),
241
+ date.getSeconds(),
242
+ date.getMilliseconds(),
243
+ );
244
+ const offset = Math.round((localTimestamp - date.getTime()) / 60000);
245
+ const sign = offset >= 0 ? "+" : "-";
246
+ const hours = String(Math.floor(Math.abs(offset) / 60)).padStart(2, "0");
247
+ const minutes = String(Math.abs(offset) % 60).padStart(2, "0");
248
+ const local = new Date(date.getTime() + offset * 60 * 1000)
249
+ .toISOString()
250
+ .replace("T", " ")
251
+ .replace(/\.\d{3}Z$/, "");
252
+
253
+ return `${local} ${sign}${hours}:${minutes}`;
254
+ }
255
+
256
+ export function AppShell(props: { children: ReactNode }) {
257
+ const { manifest } = useCatalog();
258
+ const location = useLocation();
259
+ const setKeyMatch = location.pathname.match(/^\/sets\/([^/]+)/);
260
+ const setKey = setKeyMatch ? decodeURIComponent(setKeyMatch[1]) : undefined;
261
+ const showSidebar = !manifest.sets || Boolean(setKey);
262
+
263
+ return (
264
+ <div className={themeClasses.page}>
265
+ <header className="bg-header">
266
+ <nav className="mx-auto flex max-w-5xl items-center justify-between gap-4 px-3 py-3 sm:px-4">
267
+ <div className="flex min-w-0 flex-1 items-center">
268
+ <NavLink
269
+ to="/"
270
+ className={[
271
+ "flex min-w-0 max-w-full items-center gap-2.5 rounded-lg py-1 pr-2 outline-none",
272
+ "ring-offset-2 ring-offset-header focus-visible:ring-2 focus-visible:ring-header-text",
273
+ ].join(" ")}
274
+ aria-label="Messagevisor Catalog home"
275
+ >
276
+ <img
277
+ src={CATALOG_NAV_LOGO_MARK_SRC}
278
+ alt=""
279
+ width={32}
280
+ height={32}
281
+ className="h-8 w-8 shrink-0 object-contain"
282
+ decoding="async"
283
+ />
284
+ <img
285
+ src={CATALOG_NAV_LOGO_WORDMARK_SRC}
286
+ alt=""
287
+ className="h-auto max-h-4 min-w-0 shrink object-contain object-left pl-2"
288
+ decoding="async"
289
+ />
290
+ </NavLink>
291
+ </div>
292
+
293
+ <div className="flex items-center gap-3">
294
+ <SetSwitcher currentSetKey={setKey} />
295
+ {manifest.links?.repository && isKnownRepositoryProvider(manifest.links.provider) && (
296
+ <a
297
+ href={manifest.links.repository}
298
+ target="_blank"
299
+ rel="noreferrer"
300
+ className="rounded-lg px-3 py-2 text-header-text hover:bg-header-active"
301
+ aria-label={`Open ${manifest.links.provider || "repository"}`}
302
+ >
303
+ <RepositoryIcon provider={manifest.links.provider} />
304
+ </a>
305
+ )}
306
+ </div>
307
+ </nav>
308
+ </header>
309
+
310
+ <main className={themeClasses.pageShell}>
311
+ <div className={showSidebar ? "items-start gap-6 md:flex" : ""}>
312
+ {showSidebar && <Sidebar setKey={setKey} />}
313
+ <div className={["min-w-0", showSidebar ? "flex-1" : "w-full"].filter(Boolean).join(" ")}>
314
+ <section className="overflow-hidden rounded-lg bg-surface shadow">
315
+ {props.children}
316
+ </section>
317
+ <footer className="mt-4 pt-3 text-center">
318
+ <p className="pb-2 text-xs leading-5 text-faint">
319
+ Generated at {formatGeneratedAt(manifest.generatedAt)}
320
+ </p>
321
+ <p className="pb-5 text-xs font-medium leading-5 text-muted">
322
+ Built using{" "}
323
+ <a
324
+ target="_blank"
325
+ rel="noreferrer"
326
+ href="https://messagevisor.com"
327
+ className="font-semibold hover:underline"
328
+ >
329
+ Messagevisor
330
+ </a>
331
+ </p>
332
+ </footer>
333
+ </div>
334
+ </div>
335
+ </main>
336
+ </div>
337
+ );
338
+ }
@@ -0,0 +1,13 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ export function PageHeader(props: { title: string; description?: ReactNode; actions?: ReactNode }) {
4
+ return (
5
+ <div className="mb-6 flex flex-col justify-between gap-4 border-b border-border px-6 pb-4 pt-8 md:flex-row md:items-start">
6
+ <div className="min-w-0 flex-1">
7
+ <h1 className="break-words text-3xl font-black text-text">{props.title}</h1>
8
+ {props.description && <div className="mt-2 text-sm text-muted">{props.description}</div>}
9
+ </div>
10
+ {props.actions ? <div className="shrink-0">{props.actions}</div> : null}
11
+ </div>
12
+ );
13
+ }
@@ -0,0 +1,35 @@
1
+ import type { ReactNode } from "react";
2
+ import { NavLink } from "react-router-dom";
3
+
4
+ export interface TabItem {
5
+ label: string;
6
+ to: string;
7
+ end?: boolean;
8
+ }
9
+
10
+ export function Tabs(props: { tabs: TabItem[]; children: ReactNode }) {
11
+ return (
12
+ <div>
13
+ <nav className="border-b border-border">
14
+ {props.tabs.map((tab) => (
15
+ <NavLink
16
+ key={tab.to}
17
+ to={tab.to}
18
+ end={tab.end}
19
+ className={({ isActive }) =>
20
+ [
21
+ "inline-block min-w-28 border-b-2 px-3 pb-4 pt-2 text-center text-sm font-medium",
22
+ isActive
23
+ ? "border-primary text-primary"
24
+ : "border-transparent text-muted hover:border-border hover:text-text",
25
+ ].join(" ")
26
+ }
27
+ >
28
+ {tab.label}
29
+ </NavLink>
30
+ ))}
31
+ </nav>
32
+ <div className="px-6 py-6">{props.children}</div>
33
+ </div>
34
+ );
35
+ }