@parhelia/localization 0.1.12788 → 0.1.12790
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/dist/LocalizeItemDialog.d.ts.map +1 -1
- package/dist/LocalizeItemDialog.js +92 -34
- package/dist/LocalizeItemUtils.d.ts +1 -2
- package/dist/LocalizeItemUtils.d.ts.map +1 -1
- package/dist/LocalizeItemUtils.js +44 -12
- package/dist/api/discovery.d.ts +25 -0
- package/dist/api/discovery.d.ts.map +1 -1
- package/dist/api/discovery.js +87 -0
- package/dist/index.d.ts +8 -17
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +32 -32
- package/dist/services/translationService.d.ts +40 -9
- package/dist/services/translationService.d.ts.map +1 -1
- package/dist/services/translationService.js +30 -4
- package/dist/settings/TranslationServicesPanel.d.ts.map +1 -1
- package/dist/settings/TranslationServicesPanel.js +18 -36
- package/dist/sidebar/TranslationSidebar.d.ts.map +1 -1
- package/dist/sidebar/TranslationSidebar.js +4 -1
- package/dist/steps/ItemSelectionStep.d.ts +3 -0
- package/dist/steps/ItemSelectionStep.d.ts.map +1 -0
- package/dist/steps/ItemSelectionStep.js +23 -0
- package/dist/steps/ItemSelectionTree.d.ts +13 -0
- package/dist/steps/ItemSelectionTree.d.ts.map +1 -0
- package/dist/steps/ItemSelectionTree.js +326 -0
- package/dist/steps/MetadataInputStep.d.ts.map +1 -1
- package/dist/steps/MetadataInputStep.js +8 -1
- package/dist/steps/PromptCustomizationStep.d.ts +1 -1
- package/dist/steps/PromptCustomizationStep.d.ts.map +1 -1
- package/dist/steps/PromptCustomizationStep.js +161 -56
- package/dist/steps/ServiceLanguageSelectionStep.d.ts +6 -1
- package/dist/steps/ServiceLanguageSelectionStep.d.ts.map +1 -1
- package/dist/steps/ServiceLanguageSelectionStep.js +53 -163
- package/dist/steps/WizardStepShell.d.ts +17 -0
- package/dist/steps/WizardStepShell.d.ts.map +1 -0
- package/dist/steps/WizardStepShell.js +11 -0
- package/dist/steps/index.d.ts +1 -0
- package/dist/steps/index.d.ts.map +1 -1
- package/dist/steps/index.js +1 -0
- package/dist/steps/types.d.ts +17 -1
- package/dist/steps/types.d.ts.map +1 -1
- package/dist/translation-center/TranslationBatches.d.ts +2 -0
- package/dist/translation-center/TranslationBatches.d.ts.map +1 -0
- package/dist/translation-center/TranslationBatches.js +995 -0
- package/dist/translation-center/TranslationManagement.d.ts.map +1 -1
- package/dist/translation-center/TranslationManagement.js +22 -14
- package/dist/translation-center/TranslationsTitlebar.d.ts +7 -0
- package/dist/translation-center/TranslationsTitlebar.d.ts.map +1 -0
- package/dist/translation-center/TranslationsTitlebar.js +16 -0
- package/dist/types.d.ts +1 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/dist/translation-center/BatchTranslationView.d.ts +0 -8
- package/dist/translation-center/BatchTranslationView.d.ts.map +0 -1
- package/dist/translation-center/BatchTranslationView.js +0 -870
- package/dist/translation-center/RecentTranslations.d.ts +0 -2
- package/dist/translation-center/RecentTranslations.d.ts.map +0 -1
- package/dist/translation-center/RecentTranslations.js +0 -309
|
@@ -0,0 +1,995 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { useCallback, useEffect, useRef, useState, useMemo } from "react";
|
|
3
|
+
import React from "react";
|
|
4
|
+
import { Button, Select, Input, UserPicker, Popover, PopoverContent, PopoverTrigger, ContentTree, fetchItemStubs, searchUsers as searchBackendUsers, useEditContext, getLanguages, SimpleIconButton, } from "@parhelia/core";
|
|
5
|
+
import { listBatches, getTranslationProviders, listBatchTranslationJobs, abortBatch, deleteBatch, } from "../services/translationService";
|
|
6
|
+
import { useDebouncedCallback } from "use-debounce";
|
|
7
|
+
import { X, ChevronDown, Languages, Loader2, Calendar, CheckCircle2, User as UserIcon2, Cloud, Globe, FileText, FolderTree, ExternalLink, Check, AlertCircle, CircleStop, Trash2, Hourglass, Copy, Sparkles, } from "lucide-react";
|
|
8
|
+
import { toast } from "sonner";
|
|
9
|
+
// Wrappers for lucide-react icons to work with React 19
|
|
10
|
+
const XIcon = (props) => React.createElement(X, props);
|
|
11
|
+
const ChevronDownIcon = (props) => React.createElement(ChevronDown, props);
|
|
12
|
+
const LanguagesIcon = (props) => React.createElement(Languages, props);
|
|
13
|
+
const LoaderIcon = (props) => React.createElement(Loader2, props);
|
|
14
|
+
const CalendarIcon = (props) => React.createElement(Calendar, props);
|
|
15
|
+
const StatusIcon = (props) => React.createElement(CheckCircle2, props);
|
|
16
|
+
const UserFilterIcon = (props) => React.createElement(UserIcon2, props);
|
|
17
|
+
const ProviderIcon = (props) => React.createElement(Cloud, props);
|
|
18
|
+
const GlobeIcon = (props) => React.createElement(Globe, props);
|
|
19
|
+
const ItemIcon = (props) => React.createElement(FileText, props);
|
|
20
|
+
const FolderTreeIcon = (props) => React.createElement(FolderTree, props);
|
|
21
|
+
const ExternalLinkIcon = (props) => React.createElement(ExternalLink, props);
|
|
22
|
+
const CheckIcon = (props) => React.createElement(Check, props);
|
|
23
|
+
const AlertCircleIcon = (props) => React.createElement(AlertCircle, props);
|
|
24
|
+
const AbortIcon = (props) => React.createElement(CircleStop, props);
|
|
25
|
+
const TrashIcon = (props) => React.createElement(Trash2, props);
|
|
26
|
+
const QueuedIcon = (props) => React.createElement(Hourglass, props);
|
|
27
|
+
const CopyIcon = (props) => React.createElement(Copy, props);
|
|
28
|
+
const SparklesIcon = (props) => React.createElement(Sparkles, props);
|
|
29
|
+
const DATE_RANGE_OPTIONS = [
|
|
30
|
+
{ value: "lastHour", label: "Last Hour" },
|
|
31
|
+
{ value: "last24hours", label: "Last 24 Hours" },
|
|
32
|
+
{ value: "today", label: "Today" },
|
|
33
|
+
{ value: "last7days", label: "Last 7 Days" },
|
|
34
|
+
{ value: "last30days", label: "Last 30 Days" },
|
|
35
|
+
{ value: "last90days", label: "Last 90 Days" },
|
|
36
|
+
{ value: "last6months", label: "Last 6 Months" },
|
|
37
|
+
{ value: "lastYear", label: "Last Year" },
|
|
38
|
+
{ value: "allTime", label: "All Time" },
|
|
39
|
+
];
|
|
40
|
+
const STATUS_OPTIONS = [
|
|
41
|
+
{ value: "all", label: "All Status" },
|
|
42
|
+
{ value: "in-progress", label: "In Progress" },
|
|
43
|
+
{ value: "completed", label: "Completed" },
|
|
44
|
+
{ value: "errors", label: "Errors Only" },
|
|
45
|
+
{ value: "aborted", label: "Aborted" },
|
|
46
|
+
];
|
|
47
|
+
// Maps the UI status filter to the DB status string stored on the batch row.
|
|
48
|
+
const STATUS_FILTER_TO_DB = {
|
|
49
|
+
"in-progress": "In Progress",
|
|
50
|
+
completed: "Completed",
|
|
51
|
+
errors: "Error",
|
|
52
|
+
aborted: "Aborted",
|
|
53
|
+
};
|
|
54
|
+
function isTerminalBatchStatus(status) {
|
|
55
|
+
return status === "Completed" || status === "Error" || status === "Aborted";
|
|
56
|
+
}
|
|
57
|
+
function isActiveBatchStatus(status) {
|
|
58
|
+
return status === "Pending" || status === "In Progress";
|
|
59
|
+
}
|
|
60
|
+
function normalizeGuid(value) {
|
|
61
|
+
return (value ?? "").toString().replace(/[{}()]/g, "").toLowerCase();
|
|
62
|
+
}
|
|
63
|
+
function dateRangeToFromUtc(range) {
|
|
64
|
+
const now = new Date();
|
|
65
|
+
const cutoff = new Date(now);
|
|
66
|
+
switch (range) {
|
|
67
|
+
case "lastHour":
|
|
68
|
+
cutoff.setTime(now.getTime() - 60 * 60 * 1000);
|
|
69
|
+
break;
|
|
70
|
+
case "last24hours":
|
|
71
|
+
cutoff.setTime(now.getTime() - 24 * 60 * 60 * 1000);
|
|
72
|
+
break;
|
|
73
|
+
case "today":
|
|
74
|
+
cutoff.setHours(0, 0, 0, 0);
|
|
75
|
+
break;
|
|
76
|
+
case "last7days":
|
|
77
|
+
cutoff.setDate(now.getDate() - 7);
|
|
78
|
+
break;
|
|
79
|
+
case "last30days":
|
|
80
|
+
cutoff.setDate(now.getDate() - 30);
|
|
81
|
+
break;
|
|
82
|
+
case "last90days":
|
|
83
|
+
cutoff.setDate(now.getDate() - 90);
|
|
84
|
+
break;
|
|
85
|
+
case "last6months":
|
|
86
|
+
cutoff.setMonth(now.getMonth() - 6);
|
|
87
|
+
break;
|
|
88
|
+
case "lastYear":
|
|
89
|
+
cutoff.setFullYear(now.getFullYear() - 1);
|
|
90
|
+
break;
|
|
91
|
+
case "allTime":
|
|
92
|
+
return undefined;
|
|
93
|
+
}
|
|
94
|
+
return cutoff.toISOString();
|
|
95
|
+
}
|
|
96
|
+
export function TranslationBatches() {
|
|
97
|
+
const editContext = useEditContext();
|
|
98
|
+
const [batches, setBatches] = useState([]);
|
|
99
|
+
const [providers, setProviders] = useState([]);
|
|
100
|
+
const [sitecoreLanguages, setSitecoreLanguages] = useState([]);
|
|
101
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
102
|
+
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
|
103
|
+
const [hasMore, setHasMore] = useState(true);
|
|
104
|
+
const [currentOffset, setCurrentOffset] = useState(0);
|
|
105
|
+
const [itemIdOrPathInput, setItemIdOrPathInput] = useState("");
|
|
106
|
+
const [itemPickerOpen, setItemPickerOpen] = useState(false);
|
|
107
|
+
const [expandedBatchId, setExpandedBatchId] = useState(null);
|
|
108
|
+
const [expandedItems, setExpandedItems] = useState(new Set());
|
|
109
|
+
const [batchJobs, setBatchJobs] = useState({});
|
|
110
|
+
const [loadingJobs, setLoadingJobs] = useState(new Set());
|
|
111
|
+
const [abortingBatchIds, setAbortingBatchIds] = useState(new Set());
|
|
112
|
+
const [deletingBatchIds, setDeletingBatchIds] = useState(new Set());
|
|
113
|
+
// Cache of userName → displayName so we can render full names on batch rows
|
|
114
|
+
// without re-querying the user service every render.
|
|
115
|
+
const [userDisplayNames, setUserDisplayNames] = useState({});
|
|
116
|
+
// Monotonic request id — only the most recent in-flight request is allowed
|
|
117
|
+
// to write to state, so a slower earlier load can't clobber a newer one
|
|
118
|
+
// (e.g. when the user changes filters quickly).
|
|
119
|
+
const requestIdRef = useRef(0);
|
|
120
|
+
const currentUserName = editContext?.user?.name || "all";
|
|
121
|
+
const isLimitedPreviewUser = editContext?.user?.isLimitedPreviewUser ?? false;
|
|
122
|
+
const [filters, setFilters] = useState({
|
|
123
|
+
dateRange: "last30days",
|
|
124
|
+
status: "all",
|
|
125
|
+
user: currentUserName,
|
|
126
|
+
provider: "all",
|
|
127
|
+
targetLanguage: "all",
|
|
128
|
+
itemIdOrPath: "",
|
|
129
|
+
itemIncludeSubitems: false,
|
|
130
|
+
});
|
|
131
|
+
// Enforce current user filter for limited preview users
|
|
132
|
+
useEffect(() => {
|
|
133
|
+
if (isLimitedPreviewUser && currentUserName !== "all" && filters.user !== currentUserName) {
|
|
134
|
+
setFilters(prev => ({ ...prev, user: currentUserName }));
|
|
135
|
+
}
|
|
136
|
+
}, [isLimitedPreviewUser, currentUserName, filters.user]);
|
|
137
|
+
const effectiveFilters = useMemo(() => {
|
|
138
|
+
const userFilter = isLimitedPreviewUser ? currentUserName : filters.user;
|
|
139
|
+
const trimmedItem = filters.itemIdOrPath.trim();
|
|
140
|
+
return {
|
|
141
|
+
fromUtc: dateRangeToFromUtc(filters.dateRange),
|
|
142
|
+
status: filters.status === "all" ? undefined : STATUS_FILTER_TO_DB[filters.status],
|
|
143
|
+
user: userFilter === "all" ? undefined : userFilter,
|
|
144
|
+
provider: filters.provider === "all" ? undefined : filters.provider,
|
|
145
|
+
targetLanguage: filters.targetLanguage === "all" ? undefined : filters.targetLanguage,
|
|
146
|
+
itemIdOrPath: trimmedItem ? trimmedItem : undefined,
|
|
147
|
+
itemIncludeSubitems: trimmedItem && filters.itemIncludeSubitems ? true : undefined,
|
|
148
|
+
};
|
|
149
|
+
}, [filters, isLimitedPreviewUser, currentUserName]);
|
|
150
|
+
const loadRecentBatches = useCallback(async (offset, append, filtersToUse) => {
|
|
151
|
+
const myRequestId = ++requestIdRef.current;
|
|
152
|
+
if (offset === 0) {
|
|
153
|
+
setIsLoading(true);
|
|
154
|
+
}
|
|
155
|
+
else {
|
|
156
|
+
setIsLoadingMore(true);
|
|
157
|
+
}
|
|
158
|
+
try {
|
|
159
|
+
const limit = 100;
|
|
160
|
+
const res = await listBatches(limit, offset, filtersToUse);
|
|
161
|
+
// Drop stale responses — a newer request has already superseded this one.
|
|
162
|
+
if (myRequestId !== requestIdRef.current)
|
|
163
|
+
return;
|
|
164
|
+
const maybeData = res?.data ?? res;
|
|
165
|
+
const newBatches = Array.isArray(maybeData) ? maybeData : [];
|
|
166
|
+
if (append) {
|
|
167
|
+
setBatches(prev => {
|
|
168
|
+
const map = new Map();
|
|
169
|
+
for (const b of prev)
|
|
170
|
+
map.set(b.id, b);
|
|
171
|
+
for (const b of newBatches)
|
|
172
|
+
map.set(b.id, b);
|
|
173
|
+
return Array.from(map.values());
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
else {
|
|
177
|
+
const map = new Map();
|
|
178
|
+
for (const b of newBatches)
|
|
179
|
+
map.set(b.id, b);
|
|
180
|
+
setBatches(Array.from(map.values()));
|
|
181
|
+
}
|
|
182
|
+
setHasMore(newBatches.length === limit);
|
|
183
|
+
setCurrentOffset(offset + limit);
|
|
184
|
+
}
|
|
185
|
+
catch (error) {
|
|
186
|
+
if (myRequestId !== requestIdRef.current)
|
|
187
|
+
return;
|
|
188
|
+
console.error("Failed to load batches:", error);
|
|
189
|
+
}
|
|
190
|
+
finally {
|
|
191
|
+
if (myRequestId === requestIdRef.current) {
|
|
192
|
+
setIsLoading(false);
|
|
193
|
+
setIsLoadingMore(false);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}, []);
|
|
197
|
+
const debouncedReload = useDebouncedCallback(() => loadRecentBatches(0, false, effectiveFilters), 800);
|
|
198
|
+
const debouncedSetItemFilter = useDebouncedCallback((value) => setFilters((prev) => ({ ...prev, itemIdOrPath: value })), 400);
|
|
199
|
+
const loadMore = () => {
|
|
200
|
+
if (!isLoadingMore && hasMore) {
|
|
201
|
+
loadRecentBatches(currentOffset, true, effectiveFilters);
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
// Load providers
|
|
205
|
+
useEffect(() => {
|
|
206
|
+
const loadProviders = async () => {
|
|
207
|
+
try {
|
|
208
|
+
const res = await getTranslationProviders();
|
|
209
|
+
const providerData = (res?.data ?? res ?? []);
|
|
210
|
+
setProviders(providerData);
|
|
211
|
+
}
|
|
212
|
+
catch (error) {
|
|
213
|
+
console.error("Failed to load translation providers:", error);
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
loadProviders();
|
|
217
|
+
}, []);
|
|
218
|
+
// Load Sitecore languages once for the Target Language dropdown
|
|
219
|
+
useEffect(() => {
|
|
220
|
+
const loadLanguages = async () => {
|
|
221
|
+
try {
|
|
222
|
+
const res = await getLanguages();
|
|
223
|
+
const langs = (res?.data ?? res ?? []);
|
|
224
|
+
setSitecoreLanguages(Array.isArray(langs) ? langs : []);
|
|
225
|
+
}
|
|
226
|
+
catch (error) {
|
|
227
|
+
console.error("Failed to load Sitecore languages:", error);
|
|
228
|
+
}
|
|
229
|
+
};
|
|
230
|
+
loadLanguages();
|
|
231
|
+
}, []);
|
|
232
|
+
// Reload when filters change (resets pagination)
|
|
233
|
+
useEffect(() => {
|
|
234
|
+
loadRecentBatches(0, false, effectiveFilters);
|
|
235
|
+
}, [loadRecentBatches, effectiveFilters]);
|
|
236
|
+
// Seed the cache with the current user's full name so rows authored by us
|
|
237
|
+
// resolve immediately, without waiting on a searchUsers round-trip.
|
|
238
|
+
useEffect(() => {
|
|
239
|
+
const me = editContext?.user;
|
|
240
|
+
if (!me?.name)
|
|
241
|
+
return;
|
|
242
|
+
const fullName = me.fullName?.trim();
|
|
243
|
+
if (!fullName)
|
|
244
|
+
return;
|
|
245
|
+
setUserDisplayNames((prev) => {
|
|
246
|
+
if (prev[me.name] === fullName)
|
|
247
|
+
return prev;
|
|
248
|
+
return { ...prev, [me.name]: fullName };
|
|
249
|
+
});
|
|
250
|
+
}, [editContext?.user]);
|
|
251
|
+
// Resolve `initiatedByUser` to a full display name for every batch we haven't
|
|
252
|
+
// looked up yet. Runs once per unique username.
|
|
253
|
+
useEffect(() => {
|
|
254
|
+
const unresolved = new Set();
|
|
255
|
+
for (const b of batches) {
|
|
256
|
+
const name = b.initiatedByUser?.trim();
|
|
257
|
+
if (!name)
|
|
258
|
+
continue;
|
|
259
|
+
if (userDisplayNames[name] !== undefined)
|
|
260
|
+
continue;
|
|
261
|
+
unresolved.add(name);
|
|
262
|
+
}
|
|
263
|
+
if (unresolved.size === 0)
|
|
264
|
+
return;
|
|
265
|
+
let cancelled = false;
|
|
266
|
+
(async () => {
|
|
267
|
+
const updates = {};
|
|
268
|
+
await Promise.all(Array.from(unresolved).map(async (userName) => {
|
|
269
|
+
try {
|
|
270
|
+
const results = await searchBackendUsers(userName, 5);
|
|
271
|
+
const exact = results.find((r) => r.userName?.toLowerCase() === userName.toLowerCase()) ??
|
|
272
|
+
results[0];
|
|
273
|
+
updates[userName] = exact?.displayName?.trim() || userName;
|
|
274
|
+
}
|
|
275
|
+
catch {
|
|
276
|
+
updates[userName] = userName;
|
|
277
|
+
}
|
|
278
|
+
}));
|
|
279
|
+
if (cancelled || Object.keys(updates).length === 0)
|
|
280
|
+
return;
|
|
281
|
+
setUserDisplayNames((prev) => ({ ...prev, ...updates }));
|
|
282
|
+
})();
|
|
283
|
+
return () => {
|
|
284
|
+
cancelled = true;
|
|
285
|
+
};
|
|
286
|
+
}, [batches, userDisplayNames]);
|
|
287
|
+
// Keep refs to the latest reload + expansion state so the socket listener
|
|
288
|
+
// can be registered once and survive editContext identity changes.
|
|
289
|
+
// Without this, a context recreation (triggered e.g. by EditorShell's
|
|
290
|
+
// requestRefresh("immediate") on batch-completed) tears down the listener
|
|
291
|
+
// and cancels the pending debouncedReload before it fires.
|
|
292
|
+
const debouncedReloadRef = useRef(debouncedReload);
|
|
293
|
+
useEffect(() => {
|
|
294
|
+
debouncedReloadRef.current = debouncedReload;
|
|
295
|
+
}, [debouncedReload]);
|
|
296
|
+
const expandedBatchIdRef = useRef(expandedBatchId);
|
|
297
|
+
useEffect(() => {
|
|
298
|
+
expandedBatchIdRef.current = expandedBatchId;
|
|
299
|
+
}, [expandedBatchId]);
|
|
300
|
+
const loadBatchJobsRef = useRef(null);
|
|
301
|
+
// Listen for batch-related events to refresh the list
|
|
302
|
+
useEffect(() => {
|
|
303
|
+
const removeSocketMessageListener = editContext?.addSocketMessageListener((message) => {
|
|
304
|
+
const batchIdFromPayload = normalizeGuid(message?.payload?.batchId ?? message?.payload?.BatchId);
|
|
305
|
+
if (message.type === "batch-completed" ||
|
|
306
|
+
((message.type === "translation-started" ||
|
|
307
|
+
message.type === "translation-finished" ||
|
|
308
|
+
message.type === "translation-error" ||
|
|
309
|
+
message.type === "translation-aborted") &&
|
|
310
|
+
batchIdFromPayload)) {
|
|
311
|
+
debouncedReloadRef.current();
|
|
312
|
+
// If the impacted batch is currently expanded, refresh its per-job
|
|
313
|
+
// list too — otherwise the language chips keep their stale spinner.
|
|
314
|
+
if (batchIdFromPayload &&
|
|
315
|
+
expandedBatchIdRef.current === batchIdFromPayload) {
|
|
316
|
+
void loadBatchJobsRef.current?.(batchIdFromPayload);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
});
|
|
320
|
+
return () => {
|
|
321
|
+
if (removeSocketMessageListener) {
|
|
322
|
+
removeSocketMessageListener();
|
|
323
|
+
}
|
|
324
|
+
// Intentionally do NOT cancel the pending debouncedReload here —
|
|
325
|
+
// editContext can change identity between message arrival and the
|
|
326
|
+
// debounce expiry, and cancelling on re-subscription would drop the
|
|
327
|
+
// refresh that brings the UI to its final state.
|
|
328
|
+
};
|
|
329
|
+
}, [editContext]);
|
|
330
|
+
const searchTranslationUsers = useCallback((query, limit) => searchBackendUsers(query, limit), []);
|
|
331
|
+
// Languages available in the Target Language dropdown — sourced from Sitecore so
|
|
332
|
+
// the list is stable regardless of the current filter selection.
|
|
333
|
+
const languageOptions = useMemo(() => sitecoreLanguages
|
|
334
|
+
.map(l => l.languageCode || l.name)
|
|
335
|
+
.filter((code) => !!code)
|
|
336
|
+
.sort(), [sitecoreLanguages]);
|
|
337
|
+
// Group by date. Filtering happens server-side, so we operate on `batches` directly.
|
|
338
|
+
const groupedBatches = useMemo(() => {
|
|
339
|
+
if (batches.length === 0)
|
|
340
|
+
return [];
|
|
341
|
+
const dedupMap = new Map();
|
|
342
|
+
for (const b of batches)
|
|
343
|
+
dedupMap.set(b.id, b);
|
|
344
|
+
const sorted = Array.from(dedupMap.values()).sort((a, b) => new Date(b.startedAtUtc || b.createdAtUtc || b.lastUpdatedUtc || '').getTime() -
|
|
345
|
+
new Date(a.startedAtUtc || a.createdAtUtc || a.lastUpdatedUtc || '').getTime());
|
|
346
|
+
const list = sorted.map(b => ({
|
|
347
|
+
id: b.id,
|
|
348
|
+
timestamp: b.startedAtUtc || b.createdAtUtc || b.lastUpdatedUtc || new Date().toISOString(),
|
|
349
|
+
info: b,
|
|
350
|
+
}));
|
|
351
|
+
const now = new Date();
|
|
352
|
+
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
|
353
|
+
const yesterday = new Date(today.getTime() - 24 * 60 * 60 * 1000);
|
|
354
|
+
const thisWeek = new Date(today.getTime() - 7 * 24 * 60 * 60 * 1000);
|
|
355
|
+
const dateGroups = [];
|
|
356
|
+
const todayBatches = [];
|
|
357
|
+
const yesterdayBatches = [];
|
|
358
|
+
const thisWeekBatches = [];
|
|
359
|
+
const olderBatches = [];
|
|
360
|
+
for (const b of list) {
|
|
361
|
+
const d = new Date(b.timestamp);
|
|
362
|
+
const day = new Date(d.getFullYear(), d.getMonth(), d.getDate());
|
|
363
|
+
if (day.getTime() === today.getTime())
|
|
364
|
+
todayBatches.push(b);
|
|
365
|
+
else if (day.getTime() === yesterday.getTime())
|
|
366
|
+
yesterdayBatches.push(b);
|
|
367
|
+
else if (day >= thisWeek)
|
|
368
|
+
thisWeekBatches.push(b);
|
|
369
|
+
else
|
|
370
|
+
olderBatches.push(b);
|
|
371
|
+
}
|
|
372
|
+
if (todayBatches.length)
|
|
373
|
+
dateGroups.push({ label: "Today", batches: todayBatches });
|
|
374
|
+
if (yesterdayBatches.length)
|
|
375
|
+
dateGroups.push({ label: "Yesterday", batches: yesterdayBatches });
|
|
376
|
+
if (thisWeekBatches.length)
|
|
377
|
+
dateGroups.push({ label: "This Week", batches: thisWeekBatches });
|
|
378
|
+
if (olderBatches.length)
|
|
379
|
+
dateGroups.push({ label: "Older", batches: olderBatches });
|
|
380
|
+
return dateGroups;
|
|
381
|
+
}, [batches]);
|
|
382
|
+
const loadBatchJobs = useCallback(async (batchId) => {
|
|
383
|
+
setLoadingJobs(prev => {
|
|
384
|
+
const next = new Set(prev);
|
|
385
|
+
next.add(batchId);
|
|
386
|
+
return next;
|
|
387
|
+
});
|
|
388
|
+
try {
|
|
389
|
+
const res = await listBatchTranslationJobs(batchId);
|
|
390
|
+
const data = (res?.data ?? res ?? []);
|
|
391
|
+
setBatchJobs(prev => ({ ...prev, [batchId]: Array.isArray(data) ? data : [] }));
|
|
392
|
+
}
|
|
393
|
+
catch (error) {
|
|
394
|
+
console.error("Failed to load batch jobs:", error);
|
|
395
|
+
}
|
|
396
|
+
finally {
|
|
397
|
+
setLoadingJobs(prev => {
|
|
398
|
+
const next = new Set(prev);
|
|
399
|
+
next.delete(batchId);
|
|
400
|
+
return next;
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
}, []);
|
|
404
|
+
useEffect(() => {
|
|
405
|
+
loadBatchJobsRef.current = loadBatchJobs;
|
|
406
|
+
}, [loadBatchJobs]);
|
|
407
|
+
const toggleBatch = useCallback((batchId) => {
|
|
408
|
+
setExpandedBatchId(prev => {
|
|
409
|
+
const next = prev === batchId ? null : batchId;
|
|
410
|
+
if (next && !batchJobs[next]) {
|
|
411
|
+
void loadBatchJobs(next);
|
|
412
|
+
}
|
|
413
|
+
return next;
|
|
414
|
+
});
|
|
415
|
+
setExpandedItems(new Set());
|
|
416
|
+
}, [batchJobs, loadBatchJobs]);
|
|
417
|
+
const toggleItem = useCallback((key) => {
|
|
418
|
+
setExpandedItems(prev => {
|
|
419
|
+
const next = new Set(prev);
|
|
420
|
+
if (next.has(key))
|
|
421
|
+
next.delete(key);
|
|
422
|
+
else
|
|
423
|
+
next.add(key);
|
|
424
|
+
return next;
|
|
425
|
+
});
|
|
426
|
+
}, []);
|
|
427
|
+
const handleAbortBatch = useCallback((batchId) => {
|
|
428
|
+
editContext?.confirm({
|
|
429
|
+
header: "Abort Translation Batch",
|
|
430
|
+
message: "Abort all pending and in-progress jobs in this batch? This stops remaining translation work, but it does not roll back content that was already written.",
|
|
431
|
+
acceptLabel: "Abort",
|
|
432
|
+
showCancel: true,
|
|
433
|
+
accept: async () => {
|
|
434
|
+
setAbortingBatchIds(prev => new Set(prev).add(batchId));
|
|
435
|
+
try {
|
|
436
|
+
const result = await abortBatch(batchId);
|
|
437
|
+
if (result.type !== "success") {
|
|
438
|
+
toast.error(result.details || result.summary || "Failed to abort translation batch.");
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
toast.success("Translation batch aborted.");
|
|
442
|
+
await loadRecentBatches(0, false, effectiveFilters);
|
|
443
|
+
if (expandedBatchId === batchId) {
|
|
444
|
+
await loadBatchJobs(batchId);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
finally {
|
|
448
|
+
setAbortingBatchIds(prev => {
|
|
449
|
+
const next = new Set(prev);
|
|
450
|
+
next.delete(batchId);
|
|
451
|
+
return next;
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
},
|
|
455
|
+
});
|
|
456
|
+
}, [editContext, effectiveFilters, expandedBatchId, loadBatchJobs, loadRecentBatches]);
|
|
457
|
+
const handleDeleteBatch = useCallback((batchId) => {
|
|
458
|
+
editContext?.confirm({
|
|
459
|
+
header: "Delete Translation History",
|
|
460
|
+
message: "Delete this translation batch from history? This removes only translation job records; it does not delete Sitecore items or translated content.",
|
|
461
|
+
acceptLabel: "Delete",
|
|
462
|
+
showCancel: true,
|
|
463
|
+
accept: async () => {
|
|
464
|
+
setDeletingBatchIds(prev => new Set(prev).add(batchId));
|
|
465
|
+
try {
|
|
466
|
+
const result = await deleteBatch(batchId);
|
|
467
|
+
if (result.type !== "success") {
|
|
468
|
+
toast.error(result.details || result.summary || "Failed to delete translation batch.");
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
toast.success("Translation batch deleted.");
|
|
472
|
+
setBatches(prev => prev.filter(batch => batch.id !== batchId));
|
|
473
|
+
setBatchJobs(prev => {
|
|
474
|
+
const next = { ...prev };
|
|
475
|
+
delete next[batchId];
|
|
476
|
+
return next;
|
|
477
|
+
});
|
|
478
|
+
setExpandedBatchId(prev => prev === batchId ? null : prev);
|
|
479
|
+
}
|
|
480
|
+
finally {
|
|
481
|
+
setDeletingBatchIds(prev => {
|
|
482
|
+
const next = new Set(prev);
|
|
483
|
+
next.delete(batchId);
|
|
484
|
+
return next;
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
},
|
|
488
|
+
});
|
|
489
|
+
}, [editContext]);
|
|
490
|
+
const openItemInEditor = useCallback(async (itemId, language) => {
|
|
491
|
+
const normalized = (itemId ?? "").toString().toLowerCase();
|
|
492
|
+
editContext?.setShowAgentsWorkspaceEditor(true);
|
|
493
|
+
await editContext?.loadItem({ id: normalized, language, version: 0 });
|
|
494
|
+
}, [editContext]);
|
|
495
|
+
const totalBatches = groupedBatches.reduce((sum, group) => sum + group.batches.length, 0);
|
|
496
|
+
const isMobile = editContext?.isMobile ?? false;
|
|
497
|
+
const hasActiveFilters = filters.dateRange !== "last30days" ||
|
|
498
|
+
filters.status !== "all" ||
|
|
499
|
+
filters.user !== currentUserName ||
|
|
500
|
+
filters.provider !== "all" ||
|
|
501
|
+
filters.targetLanguage !== "all" ||
|
|
502
|
+
filters.itemIdOrPath.trim() !== "";
|
|
503
|
+
const filterLabelClass = "flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-[0.06em] text-(--color-gray-2)";
|
|
504
|
+
return (_jsxs("div", { className: "flex h-full flex-col min-h-0 bg-background", "data-testid": "translation-batches", children: [_jsx("div", { className: "shrink-0 px-4 pt-4 pb-3 md:px-6", children: _jsxs("div", { className: "flex flex-wrap items-end gap-x-3 gap-y-2", children: [_jsxs("div", { className: "flex min-w-[160px] flex-1 flex-col gap-1", children: [_jsxs("label", { className: filterLabelClass, children: [_jsx(CalendarIcon, { className: "h-3 w-3" }), "Date Range"] }), _jsx(Select, { className: "w-full bg-gray-5", options: DATE_RANGE_OPTIONS, value: filters.dateRange, onValueChange: (value) => setFilters((prev) => ({ ...prev, dateRange: value })), placeholder: "Select date range" })] }), _jsxs("div", { className: "flex min-w-[140px] flex-1 flex-col gap-1", children: [_jsxs("label", { className: filterLabelClass, children: [_jsx(StatusIcon, { className: "h-3 w-3" }), "Status"] }), _jsx(Select, { className: "w-full bg-gray-5", options: STATUS_OPTIONS, value: filters.status, onValueChange: (value) => setFilters((prev) => ({ ...prev, status: value })), placeholder: "Select status" })] }), _jsxs("div", { className: "flex min-w-[200px] flex-1 flex-col gap-1", children: [_jsxs("label", { className: filterLabelClass, children: [_jsx(UserFilterIcon, { className: "h-3 w-3" }), "User"] }), _jsx(UserPicker, { value: isLimitedPreviewUser
|
|
505
|
+
? currentUserName
|
|
506
|
+
: filters.user === "all"
|
|
507
|
+
? null
|
|
508
|
+
: filters.user, displayValue: isLimitedPreviewUser || filters.user === currentUserName
|
|
509
|
+
? `${editContext?.user?.fullName?.trim() || currentUserName} (You)`
|
|
510
|
+
: undefined, onChange: (user) => setFilters((prev) => ({
|
|
511
|
+
...prev,
|
|
512
|
+
user: user?.userName ?? "all",
|
|
513
|
+
})), disabled: isLimitedPreviewUser, placeholder: "All Users", buttonClassName: "h-[27px] w-full justify-start rounded-md border bg-gray-5 text-xs font-medium", allowClear: true, clearLabel: "All Users", searchUsers: searchTranslationUsers })] }), _jsxs("div", { className: "flex min-w-[160px] flex-1 flex-col gap-1", children: [_jsxs("label", { className: filterLabelClass, children: [_jsx(ProviderIcon, { className: "h-3 w-3" }), "Provider"] }), _jsx(Select, { className: "w-full bg-gray-5", options: [
|
|
514
|
+
{ value: "all", label: "All Providers" },
|
|
515
|
+
...providers.map((p) => ({
|
|
516
|
+
value: p.name,
|
|
517
|
+
label: p.displayName || p.name,
|
|
518
|
+
})),
|
|
519
|
+
], value: filters.provider, onValueChange: (value) => setFilters((prev) => ({ ...prev, provider: value })), placeholder: "Select provider" })] }), _jsxs("div", { className: "flex min-w-[160px] flex-1 flex-col gap-1", children: [_jsxs("label", { className: filterLabelClass, children: [_jsx(GlobeIcon, { className: "h-3 w-3" }), "Target Language"] }), _jsx(Select, { className: "w-full bg-gray-5", options: [
|
|
520
|
+
{ value: "all", label: "All Languages" },
|
|
521
|
+
...languageOptions.map((lang) => ({
|
|
522
|
+
value: lang,
|
|
523
|
+
label: lang,
|
|
524
|
+
})),
|
|
525
|
+
], value: filters.targetLanguage, onValueChange: (value) => setFilters((prev) => ({ ...prev, targetLanguage: value })), placeholder: "Select language" })] }), _jsxs("div", { className: "flex min-w-[200px] flex-1 flex-col gap-1", children: [_jsxs("div", { className: "flex items-center justify-between gap-2", children: [_jsxs("label", { className: filterLabelClass, children: [_jsx(ItemIcon, { className: "h-3 w-3" }), "Item ID / Path"] }), _jsxs("label", { className: "flex items-center gap-1 text-[10px] text-(--color-gray-2) cursor-pointer select-none", children: [_jsx("input", { type: "checkbox", checked: filters.itemIncludeSubitems, onChange: (e) => setFilters((prev) => ({ ...prev, itemIncludeSubitems: e.target.checked })), className: "h-3 w-3 accent-(--color-theme-secondary) cursor-pointer" }), "Include subitems"] })] }), _jsxs("div", { className: "relative", children: [_jsx(Input, { value: itemIdOrPathInput, onChange: (e) => {
|
|
526
|
+
const next = e.target.value;
|
|
527
|
+
setItemIdOrPathInput(next);
|
|
528
|
+
debouncedSetItemFilter(next);
|
|
529
|
+
}, placeholder: "GUID or /sitecore/content/\u2026", className: "h-[27px] w-full bg-gray-5 text-xs pr-14" }), _jsxs("div", { className: "absolute right-1.5 top-1/2 -translate-y-1/2 flex items-center gap-1", children: [itemIdOrPathInput && (_jsx("button", { type: "button", onClick: () => {
|
|
530
|
+
setItemIdOrPathInput("");
|
|
531
|
+
debouncedSetItemFilter.cancel();
|
|
532
|
+
setFilters((prev) => ({ ...prev, itemIdOrPath: "", itemIncludeSubitems: false }));
|
|
533
|
+
}, className: "text-gray-2 hover:text-(--color-dark) cursor-pointer", "aria-label": "Clear", children: _jsx(XIcon, { className: "h-3.5 w-3.5" }) })), _jsxs(Popover, { open: itemPickerOpen, onOpenChange: setItemPickerOpen, children: [_jsx(PopoverTrigger, { asChild: true, children: _jsx("button", { type: "button", className: "text-gray-2 hover:text-(--color-dark) cursor-pointer", "aria-label": "Browse content tree", title: "Browse content tree", children: _jsx(FolderTreeIcon, { className: "h-3.5 w-3.5" }) }) }), _jsxs(PopoverContent, { align: "end", sideOffset: 6, className: "w-[28rem] max-w-[calc(100vw-2rem)] p-0", children: [_jsx("div", { className: "flex items-center justify-between gap-2 border-b border-gray-3 px-3 py-2 text-[11px] text-gray-2", children: _jsx("span", { children: "Pick item to filter by" }) }), _jsx("div", { className: "max-h-80 overflow-auto p-2", children: _jsx(ContentTree, { language: "en", selectionMode: "single", rootItemIds: ["0de95ae4-41ab-4d01-9eb0-67441b7c2450"], onSelectionChange: (items) => {
|
|
534
|
+
const picked = items?.[0];
|
|
535
|
+
if (!picked)
|
|
536
|
+
return;
|
|
537
|
+
const applyValue = (value) => {
|
|
538
|
+
setItemIdOrPathInput(value);
|
|
539
|
+
debouncedSetItemFilter.cancel();
|
|
540
|
+
setFilters((prev) => ({ ...prev, itemIdOrPath: value }));
|
|
541
|
+
setItemPickerOpen(false);
|
|
542
|
+
};
|
|
543
|
+
if (picked.path) {
|
|
544
|
+
applyValue(picked.path);
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
// Fall back: resolve the item's path via a stub fetch
|
|
548
|
+
// so the input shows a human-readable path instead of a GUID.
|
|
549
|
+
fetchItemStubs([{ id: picked.id, language: "en", version: 1 }])
|
|
550
|
+
.then((stubs) => {
|
|
551
|
+
const stubPath = stubs?.[0]?.path;
|
|
552
|
+
applyValue(stubPath || picked.id);
|
|
553
|
+
})
|
|
554
|
+
.catch(() => applyValue(picked.id));
|
|
555
|
+
} }) })] })] })] })] })] })] }) }), _jsx("div", { className: "flex-1 overflow-auto p-4 md:p-6 min-h-0", children: isLoading && batches.length === 0 ? (_jsx("div", { className: "flex items-center justify-center h-32", children: _jsxs("div", { className: "flex items-center gap-2 text-gray-2", children: [_jsx(LoaderIcon, { className: "h-5 w-5 animate-spin text-theme-secondary" }), "Loading recent translations..."] }) })) : groupedBatches.length === 0 ? (_jsx("div", { className: "flex items-center justify-center h-32", children: _jsxs("div", { className: "text-center text-gray-2", children: [_jsx(LanguagesIcon, { className: "h-8 w-8 block mx-auto mb-2 text-gray-3" }), _jsx("p", { className: "font-medium text-(--color-gray-1)", children: "No translations found" }), _jsx("p", { className: "text-sm mt-1", children: (() => {
|
|
556
|
+
const hasActiveFilters = filters.dateRange !== "last30days" ||
|
|
557
|
+
filters.status !== "all" ||
|
|
558
|
+
filters.user !== currentUserName ||
|
|
559
|
+
filters.provider !== "all" ||
|
|
560
|
+
filters.targetLanguage !== "all" ||
|
|
561
|
+
filters.itemIdOrPath.trim() !== "";
|
|
562
|
+
return hasActiveFilters
|
|
563
|
+
? "Try adjusting your filters or start a translation to see it appear here"
|
|
564
|
+
: "Start a translation to see it appear here";
|
|
565
|
+
})() })] }) })) : (_jsxs("div", { className: "space-y-10", children: [groupedBatches.map((dateGroup) => (_jsxs("div", { children: [_jsxs("div", { className: "flex items-baseline gap-2 mb-2", children: [_jsx("h2", { className: "text-[11px] font-semibold uppercase tracking-[0.08em] text-(--color-gray-2)", children: dateGroup.label }), _jsx("span", { className: "flex-1 ml-2 border-t border-gray-3" })] }), _jsx("div", { className: "divide-y divide-gray-3/70", children: dateGroup.batches.map((b) => {
|
|
566
|
+
const batchDate = new Date(b.timestamp);
|
|
567
|
+
const now = new Date();
|
|
568
|
+
const isToday = batchDate.toDateString() === now.toDateString();
|
|
569
|
+
const timeDisplay = isToday
|
|
570
|
+
? batchDate.toLocaleTimeString()
|
|
571
|
+
: batchDate.toLocaleDateString() + " " + batchDate.toLocaleTimeString();
|
|
572
|
+
const info = b.info;
|
|
573
|
+
const totalJobs = info?.expectedJobs ?? 0;
|
|
574
|
+
const itemsCount = typeof info?.itemsCount === 'number' ? info.itemsCount : undefined;
|
|
575
|
+
const languagesCsv = info?.languages;
|
|
576
|
+
const languages = languagesCsv ? languagesCsv.split(',') : [];
|
|
577
|
+
const completedJobs = info?.completedJobs ?? 0;
|
|
578
|
+
const errorJobs = info?.errorJobs ?? 0;
|
|
579
|
+
const anyError = errorJobs > 0;
|
|
580
|
+
const anyInProgress = isActiveBatchStatus(info?.status);
|
|
581
|
+
const anyAborted = info?.status === "Aborted";
|
|
582
|
+
const progressPct = totalJobs > 0 ? Math.min(100, Math.round((completedJobs / totalJobs) * 100)) : 0;
|
|
583
|
+
const showProgress = totalJobs > 0 && (anyInProgress || anyError);
|
|
584
|
+
const statusDotClass = anyError
|
|
585
|
+
? "bg-red-400"
|
|
586
|
+
: anyInProgress
|
|
587
|
+
? "bg-(--color-theme-secondary)"
|
|
588
|
+
: anyAborted
|
|
589
|
+
? "bg-amber-400"
|
|
590
|
+
: "bg-emerald-400/70";
|
|
591
|
+
const metaParts = [];
|
|
592
|
+
metaParts.push(_jsxs("span", { className: "text-(--color-gray-1)", children: [totalJobs, " translation", totalJobs !== 1 ? 's' : ''] }, "trans"));
|
|
593
|
+
if (typeof itemsCount === 'number') {
|
|
594
|
+
metaParts.push(_jsxs("span", { children: [itemsCount, " item", itemsCount !== 1 ? 's' : ''] }, "items"));
|
|
595
|
+
}
|
|
596
|
+
if (languages.length > 0) {
|
|
597
|
+
metaParts.push(_jsxs("span", { title: languages.join(", "), children: [languages.length, " language", languages.length !== 1 ? 's' : ''] }, "langs"));
|
|
598
|
+
}
|
|
599
|
+
if (info?.provider) {
|
|
600
|
+
metaParts.push(_jsx("span", { children: info.provider }, "prov"));
|
|
601
|
+
}
|
|
602
|
+
if (info?.totalCost != null) {
|
|
603
|
+
metaParts.push(_jsxs("span", { children: ["Cost: ", _jsx("span", { className: "font-medium text-(--color-gray-1)", children: formatUsdCost(info.totalCost) })] }, "cost"));
|
|
604
|
+
}
|
|
605
|
+
if (info?.initiatedByUser) {
|
|
606
|
+
const resolved = userDisplayNames[info.initiatedByUser];
|
|
607
|
+
metaParts.push(_jsx("span", { title: info.initiatedByUser, children: resolved || info.initiatedByUser }, "user"));
|
|
608
|
+
}
|
|
609
|
+
const isExpanded = expandedBatchId === b.id;
|
|
610
|
+
const jobs = batchJobs[b.id];
|
|
611
|
+
const isLoadingThis = loadingJobs.has(b.id);
|
|
612
|
+
const canAbort = isActiveBatchStatus(info?.status);
|
|
613
|
+
const canDelete = isTerminalBatchStatus(info?.status);
|
|
614
|
+
const isAborting = abortingBatchIds.has(b.id);
|
|
615
|
+
const isDeleting = deletingBatchIds.has(b.id);
|
|
616
|
+
// Stable test-id status used by Playwright assertions.
|
|
617
|
+
// Matches the visual status dot/spinner logic above.
|
|
618
|
+
const testStatus = anyError
|
|
619
|
+
? "error"
|
|
620
|
+
: anyInProgress
|
|
621
|
+
? "in-progress"
|
|
622
|
+
: anyAborted
|
|
623
|
+
? "aborted"
|
|
624
|
+
: "completed";
|
|
625
|
+
return (_jsxs("div", { "data-testid": `batch-row-${b.id}`, "data-batch-status": testStatus, children: [_jsx("div", { role: "button", tabIndex: 0, className: "group w-full text-left px-3 md:px-4 py-3 hover:bg-gray-5 transition-colors cursor-pointer", onClick: () => toggleBatch(b.id), onKeyDown: (e) => {
|
|
626
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
627
|
+
e.preventDefault();
|
|
628
|
+
toggleBatch(b.id);
|
|
629
|
+
}
|
|
630
|
+
}, children: _jsxs("div", { className: "flex items-center justify-between gap-3", children: [_jsxs("div", { className: "min-w-0 flex-1", children: [_jsxs("div", { className: "flex items-center gap-2.5", children: [anyInProgress ? (_jsx(LoaderIcon, { className: "h-3 w-3 shrink-0 animate-spin text-theme-secondary" })) : (_jsx("span", { className: `h-1.5 w-1.5 shrink-0 rounded-full ${statusDotClass}`, "aria-hidden": "true" })), _jsx("span", { className: "text-(--color-dark-lighter) truncate", title: timeDisplay, children: (() => {
|
|
631
|
+
let parsedName;
|
|
632
|
+
if (info?.metadata) {
|
|
633
|
+
try {
|
|
634
|
+
const m = typeof info.metadata === "string"
|
|
635
|
+
? JSON.parse(info.metadata)
|
|
636
|
+
: info.metadata;
|
|
637
|
+
const candidate = m?.name;
|
|
638
|
+
if (typeof candidate === "string" && candidate.trim()) {
|
|
639
|
+
parsedName = candidate.trim();
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
catch {
|
|
643
|
+
// Metadata isn't JSON — fall through to timestamp.
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
return parsedName ?? `Translation Batch · ${timeDisplay}`;
|
|
647
|
+
})() }), showProgress && (_jsxs("span", { className: `inline-flex items-center gap-1 text-[11px] tabular-nums ${anyError ? 'text-red-600' : 'text-theme-secondary'}`, "data-testid": `batch-progress-${b.id}`, children: [completedJobs, "/", totalJobs, anyError && _jsxs("span", { className: "text-red-600", children: ["\u00B7 ", errorJobs, " error", errorJobs !== 1 ? 's' : ''] })] }))] }), _jsxs("div", { className: "mt-1 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-[12px] text-gray-2 pl-4", children: [metaParts.map((part, i) => (_jsxs(React.Fragment, { children: [i > 0 && _jsx("span", { className: "text-gray-3", children: "\u00B7" }), part] }, i))), info?.lastUpdatedUtc && (_jsxs(_Fragment, { children: [_jsx("span", { className: "text-gray-3", children: "\u00B7" }), _jsx("span", { children: new Date(info.lastUpdatedUtc).toLocaleString() })] }))] })] }), _jsxs("div", { className: "flex shrink-0 items-center gap-2", children: [canAbort && (_jsx(SimpleIconButton, { onClick: (event) => {
|
|
648
|
+
event.stopPropagation();
|
|
649
|
+
handleAbortBatch(b.id);
|
|
650
|
+
}, icon: _jsx(AbortIcon, { className: `h-3.5 w-3.5 ${isAborting ? "animate-spin" : ""}`, strokeWidth: 1.5 }), label: "Abort translation batch", disabled: isAborting || isDeleting, className: "p-0! text-amber-600 hover:text-amber-700" })), canDelete && (_jsx(SimpleIconButton, { onClick: (event) => {
|
|
651
|
+
event.stopPropagation();
|
|
652
|
+
handleDeleteBatch(b.id);
|
|
653
|
+
}, icon: _jsx(TrashIcon, { className: "h-3.5 w-3.5", strokeWidth: 1.5 }), label: "Delete translation history", disabled: isDeleting || isAborting, className: "p-0! text-red-500 hover:text-red-600" })), _jsx("div", { className: `text-gray-3 transition-transform ${isExpanded ? 'rotate-180' : ''} group-hover:text-(--color-gray-2)`, children: _jsx(ChevronDownIcon, { className: "h-4 w-4" }) })] })] }) }), isExpanded && (_jsxs(_Fragment, { children: [_jsx(CustomPromptPanel, { prompts: parseCustomPrompts(info?.metadata) }), _jsx(BatchItemList, { jobs: jobs, isLoading: isLoadingThis, expandedItems: expandedItems, languages: sitecoreLanguages, itemFilter: filters.itemIdOrPath.trim(), itemIncludeSubitems: filters.itemIncludeSubitems, statusFilter: filters.status, batchIsTerminal: isTerminalBatchStatus(info?.status), batchStartedAtUtc: info?.startedAtUtc ?? info?.createdAtUtc, onToggleItem: toggleItem, onOpenItem: openItemInEditor })] }))] }, b.id));
|
|
654
|
+
}) })] }, dateGroup.label))), hasMore && (_jsx("div", { className: "flex justify-center pt-6", children: _jsx(Button, { variant: "outline", size: "sm", onClick: loadMore, disabled: isLoadingMore, children: isLoadingMore ? (_jsxs(_Fragment, { children: [_jsx(LoaderIcon, { className: "h-4 w-4 animate-spin text-theme-secondary" }), "Loading more..."] })) : (_jsxs(_Fragment, { children: [_jsx(ChevronDownIcon, { className: "h-4 w-4" }), "Load More Batches"] })) }) }))] })) })] }));
|
|
655
|
+
}
|
|
656
|
+
function isLikelyGuid(value) {
|
|
657
|
+
return /^[{(]?[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}[)}]?$/i.test(value.trim());
|
|
658
|
+
}
|
|
659
|
+
function parseItemName(metadata, fallback) {
|
|
660
|
+
if (!metadata)
|
|
661
|
+
return fallback;
|
|
662
|
+
try {
|
|
663
|
+
const parsed = typeof metadata === "string" ? JSON.parse(metadata) : metadata;
|
|
664
|
+
const name = parsed?.name ?? parsed?.itemName ?? parsed?.displayName;
|
|
665
|
+
if (typeof name === "string" && name.trim())
|
|
666
|
+
return name.trim();
|
|
667
|
+
}
|
|
668
|
+
catch {
|
|
669
|
+
// not JSON
|
|
670
|
+
}
|
|
671
|
+
return fallback;
|
|
672
|
+
}
|
|
673
|
+
function parseCustomPrompts(metadata) {
|
|
674
|
+
if (!metadata)
|
|
675
|
+
return [];
|
|
676
|
+
try {
|
|
677
|
+
const parsed = typeof metadata === "string" ? JSON.parse(metadata) : metadata;
|
|
678
|
+
const scd = parsed?.serviceCustomData;
|
|
679
|
+
if (!scd || typeof scd !== "object")
|
|
680
|
+
return [];
|
|
681
|
+
const out = [];
|
|
682
|
+
for (const [provider, value] of Object.entries(scd)) {
|
|
683
|
+
const v = value;
|
|
684
|
+
const prompt = (v?.customPrompt || "").trim();
|
|
685
|
+
if (v?.enableCustomPrompt && prompt) {
|
|
686
|
+
out.push({ provider, prompt });
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
return out;
|
|
690
|
+
}
|
|
691
|
+
catch {
|
|
692
|
+
return [];
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
function parseTranslationStatistics(metadata) {
|
|
696
|
+
if (!metadata)
|
|
697
|
+
return null;
|
|
698
|
+
try {
|
|
699
|
+
const parsed = typeof metadata === "string" ? JSON.parse(metadata) : metadata;
|
|
700
|
+
const stats = parsed?.statistics ?? parsed?.translationStats;
|
|
701
|
+
if (!stats || typeof stats !== "object")
|
|
702
|
+
return null;
|
|
703
|
+
const fieldCount = Number(stats.fieldCount);
|
|
704
|
+
const nonEmptyFieldCount = Number(stats.nonEmptyFieldCount ?? stats.translatedFieldCount ?? fieldCount);
|
|
705
|
+
const emptyFieldCount = Number(stats.emptyFieldCount ?? Math.max(0, fieldCount - nonEmptyFieldCount));
|
|
706
|
+
const sourceCharacterCount = Number(stats.sourceCharacterCount ?? stats.textCharacterCount ?? stats.characterCount);
|
|
707
|
+
if (!Number.isFinite(fieldCount) ||
|
|
708
|
+
!Number.isFinite(nonEmptyFieldCount) ||
|
|
709
|
+
!Number.isFinite(emptyFieldCount) ||
|
|
710
|
+
!Number.isFinite(sourceCharacterCount)) {
|
|
711
|
+
return null;
|
|
712
|
+
}
|
|
713
|
+
return {
|
|
714
|
+
fieldCount,
|
|
715
|
+
nonEmptyFieldCount,
|
|
716
|
+
emptyFieldCount,
|
|
717
|
+
sourceCharacterCount,
|
|
718
|
+
};
|
|
719
|
+
}
|
|
720
|
+
catch {
|
|
721
|
+
return null;
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
function formatCount(value) {
|
|
725
|
+
return new Intl.NumberFormat().format(value);
|
|
726
|
+
}
|
|
727
|
+
function formatUsdCost(value) {
|
|
728
|
+
return new Intl.NumberFormat("en-US", {
|
|
729
|
+
style: "currency",
|
|
730
|
+
currency: "USD",
|
|
731
|
+
minimumFractionDigits: value > 0 && value < 0.01 ? 4 : 2,
|
|
732
|
+
maximumFractionDigits: value > 0 && value < 0.01 ? 6 : 2,
|
|
733
|
+
}).format(value);
|
|
734
|
+
}
|
|
735
|
+
function itemStatusDot(jobs) {
|
|
736
|
+
const hasError = jobs.some(j => j.status === "Error");
|
|
737
|
+
const hasInProgress = jobs.some(j => j.status === "In Progress");
|
|
738
|
+
const hasPending = jobs.some(j => j.status === "Pending");
|
|
739
|
+
const hasAborted = jobs.some(j => j.status === "Aborted");
|
|
740
|
+
if (hasError)
|
|
741
|
+
return { cls: "bg-red-400", label: "Has errors" };
|
|
742
|
+
if (hasInProgress)
|
|
743
|
+
return { cls: "bg-(--color-theme-secondary)", label: "In progress" };
|
|
744
|
+
if (hasPending)
|
|
745
|
+
return { cls: "bg-gray-3", label: "Queued" };
|
|
746
|
+
if (hasAborted)
|
|
747
|
+
return { cls: "bg-amber-400", label: "Aborted" };
|
|
748
|
+
return { cls: "bg-emerald-400/70", label: "Completed" };
|
|
749
|
+
}
|
|
750
|
+
function CustomPromptPanel({ prompts }) {
|
|
751
|
+
const [openProvider, setOpenProvider] = React.useState(null);
|
|
752
|
+
const [copiedProvider, setCopiedProvider] = React.useState(null);
|
|
753
|
+
const copyTimerRef = React.useRef(null);
|
|
754
|
+
React.useEffect(() => {
|
|
755
|
+
return () => {
|
|
756
|
+
if (copyTimerRef.current)
|
|
757
|
+
clearTimeout(copyTimerRef.current);
|
|
758
|
+
};
|
|
759
|
+
}, []);
|
|
760
|
+
if (prompts.length === 0)
|
|
761
|
+
return null;
|
|
762
|
+
const handleCopy = async (provider, prompt) => {
|
|
763
|
+
try {
|
|
764
|
+
await navigator.clipboard.writeText(prompt);
|
|
765
|
+
setCopiedProvider(provider);
|
|
766
|
+
if (copyTimerRef.current)
|
|
767
|
+
clearTimeout(copyTimerRef.current);
|
|
768
|
+
copyTimerRef.current = setTimeout(() => setCopiedProvider(null), 1500);
|
|
769
|
+
}
|
|
770
|
+
catch {
|
|
771
|
+
toast.error("Failed to copy prompt to clipboard");
|
|
772
|
+
}
|
|
773
|
+
};
|
|
774
|
+
return (_jsx("div", { className: "bg-gray-5/60 border-t border-gray-3/50", children: prompts.map((p) => {
|
|
775
|
+
const isOpen = openProvider === p.provider;
|
|
776
|
+
const justCopied = copiedProvider === p.provider;
|
|
777
|
+
return (_jsxs("div", { children: [_jsxs("div", { role: "button", tabIndex: 0, style: { paddingLeft: "2rem" }, className: "group flex items-center gap-2 pr-3 md:pr-4 py-2 text-[12px] text-gray-2 hover:bg-gray-5 transition-colors cursor-pointer", onClick: () => setOpenProvider(isOpen ? null : p.provider), onKeyDown: (e) => {
|
|
778
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
779
|
+
e.preventDefault();
|
|
780
|
+
setOpenProvider(isOpen ? null : p.provider);
|
|
781
|
+
}
|
|
782
|
+
}, children: [_jsx(SparklesIcon, { className: "h-3 w-3 shrink-0 text-(--color-theme-secondary)", strokeWidth: 1.75 }), _jsx("span", { className: "text-(--color-dark-lighter)", children: "Custom prompt" }), _jsx("span", { className: "text-gray-3", children: "\u00B7" }), _jsx("span", { children: p.provider }), _jsxs("span", { className: "ml-auto flex items-center gap-2", children: [isOpen && (_jsx(SimpleIconButton, { onClick: (event) => {
|
|
783
|
+
event.stopPropagation();
|
|
784
|
+
void handleCopy(p.provider, p.prompt);
|
|
785
|
+
}, icon: justCopied ? (_jsx(CheckIcon, { className: "h-3.5 w-3.5", strokeWidth: 1.5 })) : (_jsx(CopyIcon, { className: "h-3.5 w-3.5", strokeWidth: 1.5 })), label: justCopied ? "Copied" : "Copy prompt", className: "p-0! text-gray-2 hover:text-(--color-dark-lighter)" })), _jsx(ChevronDownIcon, { className: `h-3.5 w-3.5 text-gray-3 transition-transform group-hover:text-(--color-gray-2) ${isOpen ? "rotate-180" : ""}` })] })] }), isOpen && (_jsx("div", { style: { paddingLeft: "2.5rem" }, className: "pr-3 md:pr-4 pb-3", children: _jsx("pre", { className: "max-h-64 overflow-y-auto whitespace-pre-wrap break-words rounded border border-gray-3/50 bg-white p-2 font-mono text-[12px] text-(--color-dark-lighter)", children: p.prompt }) }))] }, p.provider));
|
|
786
|
+
}) }));
|
|
787
|
+
}
|
|
788
|
+
function BatchItemList({ jobs, isLoading, expandedItems, languages, itemFilter, itemIncludeSubitems, statusFilter, batchIsTerminal, batchStartedAtUtc, onToggleItem, onOpenItem }) {
|
|
789
|
+
const languageMap = React.useMemo(() => {
|
|
790
|
+
const map = new Map();
|
|
791
|
+
for (const l of languages) {
|
|
792
|
+
const code = (l.languageCode || l.name || "").toLowerCase();
|
|
793
|
+
if (code)
|
|
794
|
+
map.set(code, l);
|
|
795
|
+
}
|
|
796
|
+
return map;
|
|
797
|
+
}, [languages]);
|
|
798
|
+
// When the batch is effectively terminal, any job still reported as
|
|
799
|
+
// active in the DB is stale (worker died / queue cleared). Override its
|
|
800
|
+
// display status to "Aborted" so chips don't spin forever.
|
|
801
|
+
const effectiveJobs = React.useMemo(() => {
|
|
802
|
+
if (!jobs)
|
|
803
|
+
return jobs;
|
|
804
|
+
if (!batchIsTerminal)
|
|
805
|
+
return jobs;
|
|
806
|
+
return jobs.map((j) => {
|
|
807
|
+
if (j.status === "In Progress" || j.status === "Pending") {
|
|
808
|
+
return { ...j, status: "Aborted" };
|
|
809
|
+
}
|
|
810
|
+
return j;
|
|
811
|
+
});
|
|
812
|
+
}, [jobs, batchIsTerminal]);
|
|
813
|
+
const filteredJobs = React.useMemo(() => {
|
|
814
|
+
if (!effectiveJobs)
|
|
815
|
+
return effectiveJobs;
|
|
816
|
+
const filter = itemFilter.trim();
|
|
817
|
+
if (!filter)
|
|
818
|
+
return effectiveJobs;
|
|
819
|
+
// When subitems is enabled and the filter is a GUID, derive the root path
|
|
820
|
+
// from any job in this batch that matches the GUID — then fall through to
|
|
821
|
+
// prefix-matching on path. If no such job is in the batch we keep only
|
|
822
|
+
// the exact id match (best-effort: descendants without their root in the
|
|
823
|
+
// same batch can't be inferred here).
|
|
824
|
+
let pathPrefix = null;
|
|
825
|
+
if (isLikelyGuid(filter)) {
|
|
826
|
+
const normalized = normalizeGuid(filter);
|
|
827
|
+
if (itemIncludeSubitems) {
|
|
828
|
+
const rootJob = effectiveJobs.find(j => normalizeGuid((j.itemId || "").toString()) === normalized);
|
|
829
|
+
pathPrefix = rootJob?.itemPath ? rootJob.itemPath.toLowerCase() : null;
|
|
830
|
+
}
|
|
831
|
+
if (!pathPrefix) {
|
|
832
|
+
return effectiveJobs.filter(j => normalizeGuid((j.itemId || "").toString()) === normalized);
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
else if (filter.startsWith("/")) {
|
|
836
|
+
pathPrefix = filter.toLowerCase();
|
|
837
|
+
}
|
|
838
|
+
else {
|
|
839
|
+
return effectiveJobs;
|
|
840
|
+
}
|
|
841
|
+
if (itemIncludeSubitems) {
|
|
842
|
+
const prefix = pathPrefix;
|
|
843
|
+
const prefixSlash = prefix.endsWith("/") ? prefix : prefix + "/";
|
|
844
|
+
return effectiveJobs.filter(j => {
|
|
845
|
+
const p = (j.itemPath || "").toLowerCase();
|
|
846
|
+
return p === prefix || p.startsWith(prefixSlash);
|
|
847
|
+
});
|
|
848
|
+
}
|
|
849
|
+
return effectiveJobs.filter(j => (j.itemPath || "").toLowerCase() === pathPrefix);
|
|
850
|
+
}, [effectiveJobs, itemFilter, itemIncludeSubitems]);
|
|
851
|
+
// Keep all jobs of items that have at least one job matching the selected status —
|
|
852
|
+
// this way the per-item language overview stays intact even when filtering.
|
|
853
|
+
const statusFilteredJobs = React.useMemo(() => {
|
|
854
|
+
if (!filteredJobs)
|
|
855
|
+
return filteredJobs;
|
|
856
|
+
if (statusFilter === "all")
|
|
857
|
+
return filteredJobs;
|
|
858
|
+
const targetDbStatus = STATUS_FILTER_TO_DB[statusFilter];
|
|
859
|
+
const matchingItemIds = new Set();
|
|
860
|
+
for (const j of filteredJobs) {
|
|
861
|
+
if (j.status === targetDbStatus) {
|
|
862
|
+
matchingItemIds.add((j.itemId || "").toString().toLowerCase());
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
if (matchingItemIds.size === 0)
|
|
866
|
+
return [];
|
|
867
|
+
return filteredJobs.filter(j => matchingItemIds.has((j.itemId || "").toString().toLowerCase()));
|
|
868
|
+
}, [filteredJobs, statusFilter]);
|
|
869
|
+
if (isLoading && !jobs) {
|
|
870
|
+
return (_jsxs("div", { className: "px-3 md:px-4 py-4 pl-8 text-[12px] text-gray-2", children: [_jsx(LoaderIcon, { className: "inline h-3.5 w-3.5 mr-1.5 animate-spin text-theme-secondary" }), "Loading items\u2026"] }));
|
|
871
|
+
}
|
|
872
|
+
if (!statusFilteredJobs || statusFilteredJobs.length === 0) {
|
|
873
|
+
const hasItemFilter = itemFilter.trim() !== "";
|
|
874
|
+
const hasStatusFilter = statusFilter !== "all";
|
|
875
|
+
return (_jsx("div", { className: "px-3 md:px-4 py-3 pl-8 text-[12px] text-gray-2", children: hasItemFilter || hasStatusFilter
|
|
876
|
+
? "No items match the current filter."
|
|
877
|
+
: "No items in this batch." }));
|
|
878
|
+
}
|
|
879
|
+
const byItem = new Map();
|
|
880
|
+
for (const job of statusFilteredJobs) {
|
|
881
|
+
const key = (job.itemId ?? "").toString().toLowerCase();
|
|
882
|
+
if (!byItem.has(key))
|
|
883
|
+
byItem.set(key, []);
|
|
884
|
+
byItem.get(key).push(job);
|
|
885
|
+
}
|
|
886
|
+
return (_jsx("div", { className: "bg-gray-5/60 divide-y divide-gray-3/50 border-t border-gray-3/50", children: Array.from(byItem.entries()).map(([itemId, itemJobs]) => {
|
|
887
|
+
const isItemExpanded = expandedItems.has(itemId);
|
|
888
|
+
const itemAnyInProgress = itemJobs.some(j => j.status === "In Progress");
|
|
889
|
+
const itemAnyPending = itemJobs.some(j => j.status === "Pending");
|
|
890
|
+
const itemIsQueued = !itemAnyInProgress && itemAnyPending;
|
|
891
|
+
const status = itemStatusDot(itemJobs);
|
|
892
|
+
const firstJob = itemJobs[0];
|
|
893
|
+
const itemPath = firstJob?.itemPath ?? null;
|
|
894
|
+
const serverName = firstJob?.itemName ?? null;
|
|
895
|
+
const metadataName = parseItemName(firstJob?.metadata, "");
|
|
896
|
+
const displayName = metadataName || serverName || itemId;
|
|
897
|
+
const langs = itemJobs.map(j => j.targetLanguage).filter(Boolean);
|
|
898
|
+
const uniqueLangs = Array.from(new Set(langs));
|
|
899
|
+
const titleAttr = `${itemPath ?? ""}\n${itemId}`.trim();
|
|
900
|
+
return (_jsxs("div", { children: [_jsx("div", { role: "button", tabIndex: 0, style: { paddingLeft: "2.5rem" }, className: "group w-full pr-3 md:pr-4 py-2 hover:bg-gray-5 transition-colors cursor-pointer text-left", onClick: () => onToggleItem(itemId), onKeyDown: (e) => {
|
|
901
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
902
|
+
e.preventDefault();
|
|
903
|
+
onToggleItem(itemId);
|
|
904
|
+
}
|
|
905
|
+
}, children: _jsxs("div", { className: "flex items-start gap-2.5 min-w-0", children: [_jsx("span", { className: "mt-1 shrink-0", children: itemAnyInProgress ? (_jsx(LoaderIcon, { className: "h-3 w-3 animate-spin text-theme-secondary" })) : itemIsQueued ? (_jsx(QueuedIcon, { className: "h-3 w-3 text-gray-2", strokeWidth: 1.75, "aria-label": "Queued" })) : (_jsx("span", { className: `block h-1.5 w-1.5 rounded-full ${status.cls}`, "aria-hidden": "true" })) }), _jsxs("div", { className: "min-w-0 flex-1", children: [_jsxs("div", { className: "flex items-center gap-2 min-w-0", children: [_jsx("span", { className: "text-[13px] text-(--color-dark-lighter) truncate", title: titleAttr, children: displayName }), _jsxs("span", { className: "text-[11px] text-gray-2 shrink-0", children: ["\u00B7 ", uniqueLangs.length, " language", uniqueLangs.length !== 1 ? 's' : ''] })] }), itemPath && (_jsx("div", { className: "text-[11px] text-gray-2 truncate font-mono", title: titleAttr, children: itemPath }))] }), _jsx("span", { className: "mt-1 shrink-0 text-gray-3 group-hover:text-(--color-gray-2)", children: _jsx(ChevronDownIcon, { className: `h-3.5 w-3.5 transition-transform ${isItemExpanded ? 'rotate-180' : ''}` }) })] }) }), isItemExpanded && (_jsx("div", { style: { paddingLeft: "4rem" }, className: "pr-3 md:pr-4 pb-3 pt-1 flex flex-wrap gap-1.5", children: itemJobs.map((job, idx) => {
|
|
906
|
+
const lang = languageMap.get((job.targetLanguage || "").toLowerCase());
|
|
907
|
+
return (_jsx(LanguageJobChip, { job: job, language: lang, batchStartedAtUtc: batchStartedAtUtc, onOpen: () => onOpenItem(job.itemId, job.targetLanguage) }, `${job.id ?? idx}-${job.targetLanguage}`));
|
|
908
|
+
}) }))] }, itemId));
|
|
909
|
+
}) }));
|
|
910
|
+
}
|
|
911
|
+
function LanguageJobChip({ job, language, batchStartedAtUtc, onOpen }) {
|
|
912
|
+
const [open, setOpen] = React.useState(false);
|
|
913
|
+
const jobStatus = job.status;
|
|
914
|
+
const jobInProgress = jobStatus === "In Progress";
|
|
915
|
+
const jobPending = jobStatus === "Pending";
|
|
916
|
+
const jobError = jobStatus === "Error";
|
|
917
|
+
const jobAborted = jobStatus === "Aborted";
|
|
918
|
+
const langName = language?.name || job.targetLanguage;
|
|
919
|
+
const chipCls = jobError
|
|
920
|
+
? "border-red-200 bg-red-50 text-red-700 hover:bg-red-100"
|
|
921
|
+
: jobAborted
|
|
922
|
+
? "border-amber-200 bg-amber-50 text-amber-700 hover:bg-amber-100"
|
|
923
|
+
: jobInProgress
|
|
924
|
+
? "border-(--color-theme-secondary)/30 bg-theme-secondary-light text-theme-secondary hover:brightness-95"
|
|
925
|
+
: jobPending
|
|
926
|
+
? "border-gray-3 bg-gray-5 text-gray-2 hover:bg-gray-4"
|
|
927
|
+
: "border-gray-3 bg-background text-(--color-gray-1) hover:bg-gray-5";
|
|
928
|
+
const statusBadgeCls = jobError
|
|
929
|
+
? "bg-red-50 text-red-700 ring-1 ring-inset ring-red-200"
|
|
930
|
+
: jobAborted
|
|
931
|
+
? "bg-amber-50 text-amber-700 ring-1 ring-inset ring-amber-200"
|
|
932
|
+
: jobInProgress
|
|
933
|
+
? "bg-theme-secondary-light text-theme-secondary ring-1 ring-inset ring-(--color-theme-secondary)/20"
|
|
934
|
+
: jobPending
|
|
935
|
+
? "bg-gray-4 text-gray-2 ring-1 ring-inset ring-gray-3"
|
|
936
|
+
: "bg-emerald-50 text-emerald-700 ring-1 ring-inset ring-emerald-200";
|
|
937
|
+
const timestamp = job.timestamp ? new Date(job.timestamp) : null;
|
|
938
|
+
const timestampValid = timestamp && !isNaN(timestamp.getTime());
|
|
939
|
+
const claimedAt = job.claimedAtUtc ? new Date(job.claimedAtUtc) : null;
|
|
940
|
+
const claimedAtValid = !!(claimedAt && !isNaN(claimedAt.getTime()));
|
|
941
|
+
const lastHeartbeat = job.lastHeartbeatUtc ? new Date(job.lastHeartbeatUtc) : null;
|
|
942
|
+
const lastHeartbeatValid = !!(lastHeartbeat && !isNaN(lastHeartbeat.getTime()));
|
|
943
|
+
// Fall back to the batch's start time when the job didn't record one of its
|
|
944
|
+
// own — older jobs (and provider-less paths) skip ClaimedAtUtc.
|
|
945
|
+
const batchStart = batchStartedAtUtc ? new Date(batchStartedAtUtc) : null;
|
|
946
|
+
const batchStartValid = !!(batchStart && !isNaN(batchStart.getTime()));
|
|
947
|
+
const startMs = claimedAtValid
|
|
948
|
+
? claimedAt.getTime()
|
|
949
|
+
: batchStartValid
|
|
950
|
+
? batchStart.getTime()
|
|
951
|
+
: null;
|
|
952
|
+
const durationApproximated = !claimedAtValid && batchStartValid;
|
|
953
|
+
// Duration of the run when we have both ends. For terminal statuses we use
|
|
954
|
+
// `timestamp` as the end. For active jobs we keep it ticking against now.
|
|
955
|
+
const durationMs = (() => {
|
|
956
|
+
if (startMs == null)
|
|
957
|
+
return null;
|
|
958
|
+
const end = jobInProgress || jobPending
|
|
959
|
+
? Date.now()
|
|
960
|
+
: (timestampValid ? timestamp.getTime() : null);
|
|
961
|
+
if (end == null)
|
|
962
|
+
return null;
|
|
963
|
+
const ms = end - startMs;
|
|
964
|
+
return ms >= 0 ? ms : null;
|
|
965
|
+
})();
|
|
966
|
+
const formatDuration = (ms) => {
|
|
967
|
+
if (ms < 1000)
|
|
968
|
+
return "<1s";
|
|
969
|
+
const totalSec = Math.round(ms / 1000);
|
|
970
|
+
if (totalSec < 60)
|
|
971
|
+
return `${totalSec}s`;
|
|
972
|
+
const m = Math.floor(totalSec / 60);
|
|
973
|
+
const s = totalSec % 60;
|
|
974
|
+
if (m < 60)
|
|
975
|
+
return s === 0 ? `${m}m` : `${m}m ${s}s`;
|
|
976
|
+
const h = Math.floor(m / 60);
|
|
977
|
+
const rm = m % 60;
|
|
978
|
+
return rm === 0 ? `${h}h` : `${h}h ${rm}m`;
|
|
979
|
+
};
|
|
980
|
+
const attemptCount = typeof job.attemptCount === "number" ? job.attemptCount : 0;
|
|
981
|
+
const statistics = parseTranslationStatistics(job.metadata);
|
|
982
|
+
const fieldText = statistics
|
|
983
|
+
? statistics.nonEmptyFieldCount === statistics.fieldCount
|
|
984
|
+
? formatCount(statistics.fieldCount)
|
|
985
|
+
: `${formatCount(statistics.nonEmptyFieldCount)}/${formatCount(statistics.fieldCount)}`
|
|
986
|
+
: null;
|
|
987
|
+
const emptyFieldText = statistics && statistics.emptyFieldCount > 0
|
|
988
|
+
? `${formatCount(statistics.emptyFieldCount)} empty`
|
|
989
|
+
: null;
|
|
990
|
+
return (_jsxs(Popover, { open: open, onOpenChange: setOpen, children: [_jsx(PopoverTrigger, { asChild: true, children: _jsxs("button", { type: "button", className: `inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-[12px] transition-colors cursor-pointer ${chipCls}`, children: [language?.icon ? (_jsx("img", { src: language.icon, alt: langName, className: "h-3.5 shrink-0" })) : null, _jsx("span", { className: "font-mono tracking-tight", children: job.targetLanguage }), jobInProgress && (_jsx(LoaderIcon, { className: "h-3 w-3 shrink-0 animate-spin" })), jobPending && (_jsx(QueuedIcon, { className: "h-3 w-3 shrink-0", strokeWidth: 1.75, "aria-label": "Queued" })), jobError && (_jsx(AlertCircleIcon, { className: "h-3 w-3 shrink-0" })), !jobInProgress && !jobError && !jobPending && !jobAborted && (_jsx(CheckIcon, { className: "h-3 w-3 shrink-0 text-emerald-600" }))] }) }), _jsxs(PopoverContent, { align: "start", sideOffset: 6, className: "w-[22rem] max-w-[calc(100vw-2rem)] p-0", children: [_jsxs("div", { className: "flex items-center gap-2 border-b border-gray-3 px-3 py-2", children: [language?.icon ? (_jsx("img", { src: language.icon, alt: langName, className: "h-4 shrink-0" })) : null, _jsxs("div", { className: "min-w-0 flex-1", children: [_jsx("div", { className: "text-[13px] font-medium text-(--color-dark-lighter) truncate", children: langName }), _jsxs("div", { className: "text-[11px] text-gray-2 font-mono truncate", children: [job.targetLanguage, job.sourceLanguage ? ` ← ${job.sourceLanguage}` : ""] })] }), _jsxs("span", { className: `inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide ${statusBadgeCls}`, children: [jobInProgress && _jsx(LoaderIcon, { className: "h-2.5 w-2.5 animate-spin" }), jobStatus || "Unknown"] })] }), _jsxs("dl", { className: "divide-y divide-gray-3/60", children: [claimedAtValid && (_jsxs("div", { className: "flex items-baseline gap-3 px-3 py-2", children: [_jsx("dt", { className: "w-20 shrink-0 text-[10px] uppercase tracking-wide text-gray-2", children: "Claimed" }), _jsx("dd", { className: "text-[12px] text-(--color-gray-1)", children: claimedAt.toLocaleString() })] })), timestampValid && (jobInProgress || jobPending) && (_jsxs("div", { className: "flex items-baseline gap-3 px-3 py-2", children: [_jsx("dt", { className: "w-20 shrink-0 text-[10px] uppercase tracking-wide text-gray-2", children: "Updated" }), _jsx("dd", { className: "text-[12px] text-(--color-gray-1)", children: timestamp.toLocaleString() })] })), durationMs != null && (_jsxs("div", { className: "flex items-baseline gap-3 px-3 py-2", children: [_jsx("dt", { className: "w-20 shrink-0 text-[10px] uppercase tracking-wide text-gray-2", children: jobInProgress || jobPending ? "Running" : "Duration" }), _jsxs("dd", { className: "text-[12px] tabular-nums text-(--color-gray-1)", title: durationApproximated ? "Approximate — measured from batch start" : undefined, children: [durationApproximated ? "~" : "", formatDuration(durationMs)] })] })), statistics && (_jsxs("div", { className: "flex items-baseline gap-3 px-3 py-2", children: [_jsx("dt", { className: "w-20 shrink-0 text-[10px] uppercase tracking-wide text-gray-2", children: "Fields" }), _jsxs("dd", { className: "text-[12px] tabular-nums text-(--color-gray-1)", children: [fieldText, emptyFieldText ? _jsxs("span", { className: "ml-1.5 text-gray-2", children: ["(", emptyFieldText, ")"] }) : null] })] })), statistics && (_jsxs("div", { className: "flex items-baseline gap-3 px-3 py-2", children: [_jsx("dt", { className: "w-20 shrink-0 text-[10px] uppercase tracking-wide text-gray-2", children: "Text" }), _jsxs("dd", { className: "text-[12px] tabular-nums text-(--color-gray-1)", children: [formatCount(statistics.sourceCharacterCount), " chars"] })] })), job.totalCost != null && (_jsxs("div", { className: "flex items-baseline gap-3 px-3 py-2", children: [_jsx("dt", { className: "w-20 shrink-0 text-[10px] uppercase tracking-wide text-gray-2", children: "Cost" }), _jsx("dd", { className: "text-[12px] tabular-nums text-(--color-gray-1)", children: formatUsdCost(job.totalCost) })] })), lastHeartbeatValid && (jobInProgress || jobPending) && (_jsxs("div", { className: "flex items-baseline gap-3 px-3 py-2", children: [_jsx("dt", { className: "w-20 shrink-0 text-[10px] uppercase tracking-wide text-gray-2", children: "Heartbeat" }), _jsx("dd", { className: "text-[12px] text-(--color-gray-1)", children: lastHeartbeat.toLocaleString() })] })), attemptCount > 1 && (_jsxs("div", { className: "flex items-baseline gap-3 px-3 py-2", children: [_jsx("dt", { className: "w-20 shrink-0 text-[10px] uppercase tracking-wide text-gray-2", children: "Attempts" }), _jsx("dd", { className: "text-[12px] tabular-nums text-(--color-gray-1)", children: attemptCount })] })), job.hash && (_jsxs("div", { className: "flex items-baseline gap-3 px-3 py-2", children: [_jsx("dt", { className: "w-20 shrink-0 text-[10px] uppercase tracking-wide text-gray-2", children: "Hash" }), _jsx("dd", { className: "text-[11px] font-mono text-(--color-gray-1) break-all", children: job.hash })] })), job.message && (_jsxs("div", { className: "flex items-baseline gap-3 px-3 py-2", children: [_jsx("dt", { className: "w-20 shrink-0 text-[10px] uppercase tracking-wide text-gray-2", children: jobError ? "Error" : jobAborted ? "Aborted" : "Message" }), _jsx("dd", { className: `text-[12px] break-words ${jobError ? "text-red-600" : jobAborted ? "text-amber-700" : "text-(--color-gray-1)"}`, children: job.message })] })), job.batchId && (_jsxs("div", { className: "flex items-baseline gap-3 px-3 py-2", children: [_jsx("dt", { className: "w-20 shrink-0 text-[10px] uppercase tracking-wide text-gray-2", children: "Batch" }), _jsx("dd", { className: "text-[11px] font-mono text-gray-2 break-all", children: job.batchId })] }))] }), _jsx("div", { className: "flex justify-end gap-2 border-t border-gray-3 px-3 py-2", children: _jsxs(Button, { size: "sm", variant: "default", onClick: (e) => {
|
|
991
|
+
e.stopPropagation();
|
|
992
|
+
void onOpen();
|
|
993
|
+
setOpen(false);
|
|
994
|
+
}, className: "gap-1.5", children: [_jsx(ExternalLinkIcon, { className: "h-3.5 w-3.5" }), "Open in editor"] }) })] })] }));
|
|
995
|
+
}
|