@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.
- package/LICENSE +21 -0
- package/README.md +7 -0
- package/dist/assets/index-CfGbXx4X.css +1 -0
- package/dist/assets/index-r8ugP5JL.js +73 -0
- package/dist/favicon.png +0 -0
- package/dist/index.html +14 -0
- package/dist/logo-text.png +0 -0
- package/lib/index.d.ts +1 -0
- package/lib/index.js +18 -0
- package/lib/index.js.map +1 -0
- package/lib/node/formatExamplePreview.d.ts +10 -0
- package/lib/node/formatExamplePreview.js +79 -0
- package/lib/node/formatExamplePreview.js.map +1 -0
- package/lib/node/index.d.ts +191 -0
- package/lib/node/index.js +1645 -0
- package/lib/node/index.js.map +1 -0
- package/package.json +59 -13
- package/src/App.tsx +73 -0
- package/src/api.spec.ts +42 -0
- package/src/api.ts +87 -0
- package/src/catalogBrandAssets.ts +8 -0
- package/src/components/details/ConditionTree.tsx +146 -0
- package/src/components/details/FieldGrid.tsx +16 -0
- package/src/components/details/GroupSegmentTree.tsx +73 -0
- package/src/components/details/MarkdownContent.tsx +23 -0
- package/src/components/details/TranslationsTable.tsx +263 -0
- package/src/components/details/UsageLinks.tsx +29 -0
- package/src/components/history/HistoryTimeline.tsx +122 -0
- package/src/components/layout/AppShell.tsx +338 -0
- package/src/components/layout/PageHeader.tsx +13 -0
- package/src/components/layout/Tabs.tsx +35 -0
- package/src/components/lists/EntityList.tsx +451 -0
- package/src/components/ui/Badge.tsx +21 -0
- package/src/components/ui/Button.tsx +12 -0
- package/src/components/ui/Card.tsx +9 -0
- package/src/components/ui/CodeBlock.tsx +7 -0
- package/src/components/ui/EmptyState.tsx +8 -0
- package/src/components/ui/Input.tsx +12 -0
- package/src/components/ui/LabelValueBadge.tsx +55 -0
- package/src/config.ts +2 -0
- package/src/context/CatalogContext.tsx +50 -0
- package/src/entityTypes.ts +49 -0
- package/src/index.ts +1 -0
- package/src/main.tsx +28 -0
- package/src/node/formatExamplePreview.ts +85 -0
- package/src/node/index.spec.ts +713 -0
- package/src/node/index.ts +2007 -0
- package/src/pages/EntityDetailPage.tsx +3345 -0
- package/src/pages/HistoryPage.tsx +26 -0
- package/src/pages/HomePage.tsx +21 -0
- package/src/pages/ListPage.tsx +59 -0
- package/src/styles.css +95 -0
- package/src/theme.ts +36 -0
- package/src/types.ts +127 -0
- package/src/utils/formatCatalogTimestamp.ts +77 -0
- package/src/utils/hashTranslationValue.spec.ts +20 -0
- package/src/utils/hashTranslationValue.ts +22 -0
- 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
|
+
}
|