@parhelia/localization 0.1.12556 → 0.1.12560
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 +13 -17
- package/dist/api/discovery.d.ts +1 -4
- package/dist/api/discovery.d.ts.map +1 -1
- package/dist/api/discovery.js +3 -4
- package/dist/hooks/useTranslationWizard.d.ts.map +1 -1
- package/dist/hooks/useTranslationWizard.js +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +13 -28
- package/dist/services/translationService.d.ts +48 -7
- package/dist/services/translationService.d.ts.map +1 -1
- package/dist/services/translationService.js +3 -0
- package/dist/settings/TranslationServicesPanel.d.ts.map +1 -1
- package/dist/settings/TranslationServicesPanel.js +111 -42
- package/dist/setup/LocalizationSetupStep.d.ts.map +1 -1
- package/dist/setup/LocalizationSetupStep.js +8 -7
- package/dist/steps/ServiceLanguageSelectionStep.d.ts.map +1 -1
- package/dist/steps/ServiceLanguageSelectionStep.js +18 -1
- package/dist/steps/SubitemDiscoveryStep.d.ts.map +1 -1
- package/dist/steps/SubitemDiscoveryStep.js +95 -181
- package/dist/translation-center/BatchTranslationView.d.ts +1 -1
- package/dist/translation-center/BatchTranslationView.d.ts.map +1 -1
- package/dist/translation-center/BatchTranslationView.js +354 -96
- package/dist/translation-center/RecentTranslations.d.ts.map +1 -1
- package/dist/translation-center/RecentTranslations.js +20 -13
- package/dist/translation-center/TranslationManagement.d.ts.map +1 -1
- package/dist/translation-center/TranslationManagement.js +2 -4
- package/package.json +10 -11
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
2
|
import React, { useEffect, useState, useMemo, useRef, useCallback, memo, } from "react";
|
|
3
|
-
import { useEditContext, SimpleTable, Progress as CoreProgress, Button, } from "@parhelia/core";
|
|
4
|
-
import { listBatchTranslationJobs, subscribeToBatch, unsubscribeFromBatch, getBatchInfo, getTranslationProviders } from "../services/translationService";
|
|
5
|
-
import { ChevronDown, ChevronUp } from "lucide-react";
|
|
3
|
+
import { useEditContext, SimpleTable, Progress as CoreProgress, Button, SimpleIconButton, CopyButton, usePathname, useRouter, useSearchParams, } from "@parhelia/core";
|
|
4
|
+
import { listBatchTranslationJobs, retryBatchTranslation, subscribeToBatch, unsubscribeFromBatch, getBatchInfo, getTranslationProviders, } from "../services/translationService";
|
|
5
|
+
import { ChevronDown, ChevronUp, RefreshCw, ArrowLeft, Info, Loader2, Languages, ExternalLink, } from "lucide-react";
|
|
6
|
+
import { toast } from "sonner";
|
|
6
7
|
import { JsonView, defaultStyles } from "react-json-view-lite";
|
|
7
8
|
import "react-json-view-lite/dist/index.css";
|
|
8
9
|
// Wrapper so React 19 sees a plain function component instead of a forwardRef exotic component.
|
|
@@ -10,6 +11,12 @@ const Progress = (props) => React.createElement(CoreProgress, props);
|
|
|
10
11
|
// Wrappers for lucide-react icons to work with React 19
|
|
11
12
|
const ChevronUpIcon = (props) => React.createElement(ChevronUp, props);
|
|
12
13
|
const ChevronDownIcon = (props) => React.createElement(ChevronDown, props);
|
|
14
|
+
const RefreshIcon = (props) => React.createElement(RefreshCw, props);
|
|
15
|
+
const ArrowLeftIcon = (props) => React.createElement(ArrowLeft, props);
|
|
16
|
+
const InfoIcon = (props) => React.createElement(Info, props);
|
|
17
|
+
const LoaderIcon = (props) => React.createElement(Loader2, props);
|
|
18
|
+
const LanguagesIcon = (props) => React.createElement(Languages, props);
|
|
19
|
+
const ExternalLinkIcon = (props) => React.createElement(ExternalLink, props);
|
|
13
20
|
const MemoizedJsonView = memo(({ data }) => (_jsx(JsonView, { data: data, shouldExpandNode: (level) => level < 2, style: defaultStyles })), (prevProps, nextProps) => {
|
|
14
21
|
return JSON.stringify(prevProps.data) === JSON.stringify(nextProps.data);
|
|
15
22
|
});
|
|
@@ -17,32 +24,66 @@ const MemoizedJsonView = memo(({ data }) => (_jsx(JsonView, { data: data, should
|
|
|
17
24
|
function normalizeBatchInfo(raw) {
|
|
18
25
|
if (!raw)
|
|
19
26
|
return null;
|
|
20
|
-
const
|
|
21
|
-
const toNum = (v) => (typeof v === "number" ? v : v != null ? parseInt(v, 10) : undefined);
|
|
27
|
+
const toNum = (v) => typeof v === "number" ? v : v != null ? parseInt(v, 10) : undefined;
|
|
22
28
|
const info = {
|
|
23
|
-
batchId:
|
|
24
|
-
createdAtUtc:
|
|
25
|
-
startedAtUtc:
|
|
26
|
-
completedAtUtc:
|
|
27
|
-
initiatedByUser:
|
|
28
|
-
initiatorSessionId:
|
|
29
|
-
provider:
|
|
30
|
-
status:
|
|
31
|
-
expectedJobs: toNum(
|
|
32
|
-
completedJobs: toNum(
|
|
33
|
-
errorJobs: toNum(
|
|
34
|
-
metadata:
|
|
35
|
-
lastUpdatedUtc:
|
|
36
|
-
exists:
|
|
29
|
+
batchId: raw?.batchId,
|
|
30
|
+
createdAtUtc: raw?.createdAtUtc,
|
|
31
|
+
startedAtUtc: raw?.startedAtUtc,
|
|
32
|
+
completedAtUtc: raw?.completedAtUtc,
|
|
33
|
+
initiatedByUser: raw?.initiatedByUser,
|
|
34
|
+
initiatorSessionId: raw?.initiatorSessionId,
|
|
35
|
+
provider: raw?.provider,
|
|
36
|
+
status: raw?.status,
|
|
37
|
+
expectedJobs: toNum(raw?.expectedJobs),
|
|
38
|
+
completedJobs: toNum(raw?.completedJobs),
|
|
39
|
+
errorJobs: toNum(raw?.errorJobs),
|
|
40
|
+
metadata: raw?.metadata,
|
|
41
|
+
lastUpdatedUtc: raw?.lastUpdatedUtc,
|
|
42
|
+
exists: raw?.exists,
|
|
37
43
|
};
|
|
38
44
|
return info.batchId ? info : null;
|
|
39
45
|
}
|
|
40
|
-
|
|
46
|
+
function normalizeJobItemId(itemId) {
|
|
47
|
+
return (itemId ?? "").toString().toLowerCase();
|
|
48
|
+
}
|
|
49
|
+
function getJobKey(itemId, language) {
|
|
50
|
+
return `${normalizeJobItemId(itemId)}-${(language ?? "").toString()}`;
|
|
51
|
+
}
|
|
52
|
+
function getSocketPayloadMessage(payload) {
|
|
53
|
+
return payload?.message || payload?.error || "";
|
|
54
|
+
}
|
|
55
|
+
function normalizeJobRecord(raw) {
|
|
56
|
+
return {
|
|
57
|
+
id: typeof raw?.id === "number"
|
|
58
|
+
? raw.id
|
|
59
|
+
: typeof raw?.Id === "number"
|
|
60
|
+
? raw.Id
|
|
61
|
+
: undefined,
|
|
62
|
+
itemId: normalizeJobItemId(raw?.itemId ?? raw?.ItemId),
|
|
63
|
+
targetLanguage: raw?.targetLanguage ?? raw?.TargetLanguage ?? "",
|
|
64
|
+
sourceLanguage: raw?.sourceLanguage ?? raw?.SourceLanguage ?? "",
|
|
65
|
+
status: raw?.status ?? raw?.Status ?? "",
|
|
66
|
+
timestamp: raw?.timestamp ?? raw?.Timestamp ?? "",
|
|
67
|
+
hash: raw?.hash ?? raw?.Hash,
|
|
68
|
+
message: raw?.message ?? raw?.Message,
|
|
69
|
+
batchId: raw?.batchId ?? raw?.BatchId,
|
|
70
|
+
metadata: raw?.metadata ?? raw?.Metadata ?? null,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
function getRetryJobKey(job) {
|
|
74
|
+
return `${job.id ?? "no-id"}|${normalizeJobItemId(job.itemId)}|${job.sourceLanguage}|${job.targetLanguage}`;
|
|
75
|
+
}
|
|
76
|
+
export function BatchTranslationView({ batchId, onBack, }) {
|
|
41
77
|
const editContext = useEditContext();
|
|
78
|
+
const router = useRouter();
|
|
79
|
+
const pathname = usePathname();
|
|
80
|
+
const searchParams = useSearchParams();
|
|
42
81
|
const [batchJobs, setBatchJobs] = useState([]);
|
|
43
82
|
const [batchInfo, setBatchInfo] = useState(null);
|
|
44
83
|
const [providers, setProviders] = useState([]);
|
|
45
84
|
const [isLoading, setIsLoading] = useState(false);
|
|
85
|
+
const [isRetryingAll, setIsRetryingAll] = useState(false);
|
|
86
|
+
const [retryingJobKeys, setRetryingJobKeys] = useState(new Set());
|
|
46
87
|
const [translationProgress, setTranslationProgress] = useState(new Map());
|
|
47
88
|
const [itemNames, setItemNames] = useState(new Map());
|
|
48
89
|
const [isMetadataExpanded, setIsMetadataExpanded] = useState(false);
|
|
@@ -56,10 +97,94 @@ export function BatchTranslationView({ batchId, onBack }) {
|
|
|
56
97
|
// Helper function to get display name from service name
|
|
57
98
|
const getProviderDisplayName = useCallback((serviceName) => {
|
|
58
99
|
if (!serviceName)
|
|
59
|
-
return
|
|
60
|
-
const provider = providers.find(p => p.name === serviceName);
|
|
100
|
+
return "Unknown";
|
|
101
|
+
const provider = providers.find((p) => p.name === serviceName);
|
|
61
102
|
return provider?.displayName || serviceName;
|
|
62
103
|
}, [providers]);
|
|
104
|
+
const openItemInEditor = useCallback((itemId, language) => {
|
|
105
|
+
editContext?.loadItem({
|
|
106
|
+
id: normalizeJobItemId(itemId),
|
|
107
|
+
language,
|
|
108
|
+
version: 0,
|
|
109
|
+
});
|
|
110
|
+
editContext?.switchWorkspace?.("editor");
|
|
111
|
+
}, [editContext]);
|
|
112
|
+
const openBatchInView = useCallback((nextBatchId) => {
|
|
113
|
+
const current = new URLSearchParams(searchParams.toString());
|
|
114
|
+
current.set("batchId", nextBatchId);
|
|
115
|
+
router.push(`${pathname}?${current.toString()}`, { scroll: false });
|
|
116
|
+
}, [pathname, router, searchParams]);
|
|
117
|
+
const buildRetryJobs = useCallback((jobs) => {
|
|
118
|
+
const uniqueJobs = new Map();
|
|
119
|
+
for (const job of jobs) {
|
|
120
|
+
if (job.status !== "Error")
|
|
121
|
+
continue;
|
|
122
|
+
const key = getRetryJobKey(job);
|
|
123
|
+
if (uniqueJobs.has(key))
|
|
124
|
+
continue;
|
|
125
|
+
uniqueJobs.set(key, {
|
|
126
|
+
sourceTranslationId: job.id,
|
|
127
|
+
itemId: normalizeJobItemId(job.itemId),
|
|
128
|
+
sourceLanguage: job.sourceLanguage,
|
|
129
|
+
targetLanguage: job.targetLanguage,
|
|
130
|
+
metadata: job.metadata ?? undefined,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
return Array.from(uniqueJobs.values());
|
|
134
|
+
}, []);
|
|
135
|
+
const handleRetryJobs = useCallback(async (jobs, retryAll) => {
|
|
136
|
+
const retryJobs = buildRetryJobs(jobs);
|
|
137
|
+
if (retryJobs.length === 0) {
|
|
138
|
+
toast.error("No failed translations available to retry.");
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
if (!editContext?.sessionId) {
|
|
142
|
+
toast.error("Cannot retry without an active session.");
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
if (!batchInfo?.provider) {
|
|
146
|
+
toast.error("Retry provider could not be resolved for this batch.");
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
if (retryAll) {
|
|
150
|
+
setIsRetryingAll(true);
|
|
151
|
+
}
|
|
152
|
+
else {
|
|
153
|
+
setRetryingJobKeys(new Set(jobs.map((job) => getRetryJobKey(job)).filter((key) => retryJobs.some((retryJob) => key ===
|
|
154
|
+
`${retryJob.sourceTranslationId ?? "no-id"}|${retryJob.itemId}|${retryJob.sourceLanguage}|${retryJob.targetLanguage}`))));
|
|
155
|
+
}
|
|
156
|
+
try {
|
|
157
|
+
const result = await retryBatchTranslation({
|
|
158
|
+
sessionId: editContext.sessionId,
|
|
159
|
+
sourceBatchId: batchId,
|
|
160
|
+
provider: batchInfo.provider,
|
|
161
|
+
jobs: retryJobs,
|
|
162
|
+
});
|
|
163
|
+
if (result.type !== "success" || !result.data) {
|
|
164
|
+
toast.error(result.details ||
|
|
165
|
+
result.summary ||
|
|
166
|
+
"Failed to start retry translations.");
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
const response = result.data;
|
|
170
|
+
const startedCount = response.started?.length ?? 0;
|
|
171
|
+
const skippedCount = response.skipped?.length ?? 0;
|
|
172
|
+
if (startedCount === 0) {
|
|
173
|
+
toast.error(skippedCount > 0
|
|
174
|
+
? response.skipped[0]?.error || "No retries were started."
|
|
175
|
+
: "No retries were started.");
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
toast.success(retryAll
|
|
179
|
+
? `Started retry batch with ${startedCount} translation${startedCount === 1 ? "" : "s"}.`
|
|
180
|
+
: "Started retry batch for failed translation.");
|
|
181
|
+
openBatchInView(response.retryBatchId);
|
|
182
|
+
}
|
|
183
|
+
finally {
|
|
184
|
+
setIsRetryingAll(false);
|
|
185
|
+
setRetryingJobKeys(new Set());
|
|
186
|
+
}
|
|
187
|
+
}, [batchId, batchInfo?.provider, buildRetryJobs, editContext?.sessionId, openBatchInView]);
|
|
63
188
|
// Parse metadata for display
|
|
64
189
|
const parsedMetadata = useMemo(() => {
|
|
65
190
|
if (!batchInfo?.metadata)
|
|
@@ -78,7 +203,9 @@ export function BatchTranslationView({ batchId, onBack }) {
|
|
|
78
203
|
const loadProviders = useCallback(async () => {
|
|
79
204
|
try {
|
|
80
205
|
const res = await getTranslationProviders();
|
|
81
|
-
const providerData = (res?.data ??
|
|
206
|
+
const providerData = (res?.data ??
|
|
207
|
+
res ??
|
|
208
|
+
[]);
|
|
82
209
|
setProviders(providerData);
|
|
83
210
|
}
|
|
84
211
|
catch (error) {
|
|
@@ -90,36 +217,45 @@ export function BatchTranslationView({ batchId, onBack }) {
|
|
|
90
217
|
try {
|
|
91
218
|
// Use efficient batch-specific endpoint
|
|
92
219
|
const res = await listBatchTranslationJobs(batchId);
|
|
93
|
-
const
|
|
220
|
+
const apiJobsRaw = (res?.data ?? res ?? []);
|
|
221
|
+
const apiJobs = apiJobsRaw.map(normalizeJobRecord);
|
|
94
222
|
// Merge API response with existing state to prevent flickering
|
|
95
|
-
setBatchJobs(prevJobs => {
|
|
223
|
+
setBatchJobs((prevJobs) => {
|
|
96
224
|
// Create a map of existing jobs keyed by itemId-targetLanguage
|
|
97
225
|
const existingMap = new Map();
|
|
98
226
|
for (const job of prevJobs) {
|
|
99
|
-
const key =
|
|
227
|
+
const key = getJobKey(job.itemId, job.targetLanguage);
|
|
100
228
|
existingMap.set(key, job);
|
|
101
229
|
}
|
|
102
230
|
// Merge API jobs into the map (API data takes precedence)
|
|
103
231
|
for (const apiJob of apiJobs) {
|
|
104
|
-
const
|
|
232
|
+
const normalizedJob = normalizeJobRecord(apiJob);
|
|
233
|
+
const key = getJobKey(normalizedJob.itemId, normalizedJob.targetLanguage);
|
|
105
234
|
const existingJob = existingMap.get(key);
|
|
106
235
|
if (existingJob) {
|
|
107
236
|
// Prefer API data if timestamp is newer or status changed
|
|
108
|
-
const apiTimestamp =
|
|
109
|
-
|
|
110
|
-
|
|
237
|
+
const apiTimestamp = normalizedJob.timestamp
|
|
238
|
+
? new Date(normalizedJob.timestamp).getTime()
|
|
239
|
+
: 0;
|
|
240
|
+
const existingTimestamp = existingJob.timestamp
|
|
241
|
+
? new Date(existingJob.timestamp).getTime()
|
|
242
|
+
: 0;
|
|
243
|
+
if (apiTimestamp >= existingTimestamp ||
|
|
244
|
+
normalizedJob.status !== existingJob.status) {
|
|
111
245
|
// Preserve sourceLanguage from API if available, otherwise keep existing
|
|
112
246
|
existingMap.set(key, {
|
|
113
|
-
...
|
|
114
|
-
sourceLanguage:
|
|
247
|
+
...normalizedJob,
|
|
248
|
+
sourceLanguage: normalizedJob.sourceLanguage ||
|
|
249
|
+
existingJob.sourceLanguage ||
|
|
250
|
+
"",
|
|
115
251
|
});
|
|
116
252
|
}
|
|
117
253
|
}
|
|
118
254
|
else {
|
|
119
255
|
// New job from API - ensure sourceLanguage is set
|
|
120
256
|
existingMap.set(key, {
|
|
121
|
-
...
|
|
122
|
-
sourceLanguage:
|
|
257
|
+
...normalizedJob,
|
|
258
|
+
sourceLanguage: normalizedJob.sourceLanguage || "",
|
|
123
259
|
});
|
|
124
260
|
}
|
|
125
261
|
}
|
|
@@ -131,7 +267,7 @@ export function BatchTranslationView({ batchId, onBack }) {
|
|
|
131
267
|
return mergedJobs;
|
|
132
268
|
}
|
|
133
269
|
// Deep comparison: check if any job changed
|
|
134
|
-
const prevMap = new Map(prevJobs.map(j => [`${j.itemId}-${j.targetLanguage}`, j]));
|
|
270
|
+
const prevMap = new Map(prevJobs.map((j) => [`${j.itemId}-${j.targetLanguage}`, j]));
|
|
135
271
|
let hasChanges = false;
|
|
136
272
|
for (const mergedJob of mergedJobs) {
|
|
137
273
|
const key = `${mergedJob.itemId}-${mergedJob.targetLanguage}`;
|
|
@@ -181,7 +317,7 @@ export function BatchTranslationView({ batchId, onBack }) {
|
|
|
181
317
|
const info = normalizeBatchInfo(raw);
|
|
182
318
|
if (!cancelled && info)
|
|
183
319
|
setBatchInfo(info);
|
|
184
|
-
const isTerminal = info?.status ===
|
|
320
|
+
const isTerminal = info?.status === "Completed" || info?.status === "Error";
|
|
185
321
|
// Conditionally subscribe only for non-terminal batches
|
|
186
322
|
if (!isTerminal && editContext?.sessionId) {
|
|
187
323
|
await subscribeToBatch(batchId, editContext.sessionId);
|
|
@@ -204,7 +340,10 @@ export function BatchTranslationView({ batchId, onBack }) {
|
|
|
204
340
|
init();
|
|
205
341
|
return () => {
|
|
206
342
|
// Cleanup: unsubscribe when component unmounts or batch changes
|
|
207
|
-
if (isSubscribedRef.current &&
|
|
343
|
+
if (isSubscribedRef.current &&
|
|
344
|
+
editContext &&
|
|
345
|
+
editContext.sessionId &&
|
|
346
|
+
batchId) {
|
|
208
347
|
unsubscribeFromBatch(batchId, editContext.sessionId).catch(console.error);
|
|
209
348
|
}
|
|
210
349
|
};
|
|
@@ -226,8 +365,10 @@ export function BatchTranslationView({ batchId, onBack }) {
|
|
|
226
365
|
// Only update if this message is for our batch
|
|
227
366
|
if (message.payload.batchId === batchId) {
|
|
228
367
|
// Update the specific job in our local state instead of refetching all jobs
|
|
229
|
-
setBatchJobs(prevJobs => {
|
|
230
|
-
const
|
|
368
|
+
setBatchJobs((prevJobs) => {
|
|
369
|
+
const normalizedItemId = normalizeJobItemId(message.payload.itemId);
|
|
370
|
+
const payloadMessage = getSocketPayloadMessage(message.payload);
|
|
371
|
+
const jobIndex = prevJobs.findIndex((job) => normalizeJobItemId(job.itemId) === normalizedItemId &&
|
|
231
372
|
job.targetLanguage === message.payload.language);
|
|
232
373
|
if (jobIndex >= 0) {
|
|
233
374
|
// Update existing job
|
|
@@ -236,41 +377,44 @@ export function BatchTranslationView({ batchId, onBack }) {
|
|
|
236
377
|
if (existingJob) {
|
|
237
378
|
updatedJobs[jobIndex] = {
|
|
238
379
|
...existingJob,
|
|
380
|
+
itemId: normalizedItemId,
|
|
239
381
|
status: message.payload.status,
|
|
240
382
|
timestamp: message.payload.timestamp || existingJob.timestamp,
|
|
241
|
-
message:
|
|
383
|
+
message: payloadMessage || existingJob.message,
|
|
242
384
|
// Preserve sourceLanguage from existing job if not provided in message
|
|
243
|
-
sourceLanguage: message.payload.sourceLanguage ||
|
|
385
|
+
sourceLanguage: message.payload.sourceLanguage ||
|
|
386
|
+
existingJob.sourceLanguage ||
|
|
387
|
+
"",
|
|
244
388
|
};
|
|
245
389
|
}
|
|
246
390
|
return updatedJobs;
|
|
247
391
|
}
|
|
248
|
-
else
|
|
249
|
-
// Add new job if it doesn't exist
|
|
392
|
+
else {
|
|
393
|
+
// Add new job if it doesn't exist yet so terminal updates are still visible.
|
|
250
394
|
const newJob = {
|
|
251
|
-
itemId:
|
|
395
|
+
itemId: normalizedItemId,
|
|
252
396
|
targetLanguage: message.payload.language,
|
|
253
397
|
sourceLanguage: message.payload.sourceLanguage || "", // Keep this, but we'll try to populate from API
|
|
254
398
|
status: message.payload.status,
|
|
255
399
|
timestamp: message.payload.timestamp || new Date().toISOString(),
|
|
256
400
|
batchId: batchId,
|
|
257
|
-
message:
|
|
401
|
+
message: payloadMessage,
|
|
258
402
|
};
|
|
259
403
|
return [...prevJobs, newJob];
|
|
260
404
|
}
|
|
261
|
-
return prevJobs;
|
|
262
405
|
});
|
|
263
406
|
}
|
|
264
407
|
}
|
|
265
408
|
if (message.type === "translation-progress") {
|
|
266
409
|
// Only track progress for jobs in this batch
|
|
267
410
|
if (message.payload.batchId === batchId) {
|
|
268
|
-
const itemId = (message.payload.itemId
|
|
411
|
+
const itemId = normalizeJobItemId(message.payload.itemId);
|
|
269
412
|
const language = message.payload.language;
|
|
270
|
-
const progressKey =
|
|
413
|
+
const progressKey = getJobKey(itemId, language);
|
|
271
414
|
// Ensure a row exists; if not, add a placeholder "In Progress" job
|
|
272
|
-
setBatchJobs(prevJobs => {
|
|
273
|
-
const idx = prevJobs.findIndex(j => j.itemId === itemId &&
|
|
415
|
+
setBatchJobs((prevJobs) => {
|
|
416
|
+
const idx = prevJobs.findIndex((j) => normalizeJobItemId(j.itemId) === itemId &&
|
|
417
|
+
j.targetLanguage === language);
|
|
274
418
|
if (idx >= 0)
|
|
275
419
|
return prevJobs;
|
|
276
420
|
const newJob = {
|
|
@@ -280,7 +424,7 @@ export function BatchTranslationView({ batchId, onBack }) {
|
|
|
280
424
|
status: "In Progress",
|
|
281
425
|
timestamp: new Date().toISOString(),
|
|
282
426
|
batchId: batchId,
|
|
283
|
-
message: message.payload.message || ""
|
|
427
|
+
message: message.payload.message || "",
|
|
284
428
|
};
|
|
285
429
|
return [...prevJobs, newJob];
|
|
286
430
|
});
|
|
@@ -288,14 +432,15 @@ export function BatchTranslationView({ batchId, onBack }) {
|
|
|
288
432
|
const next = new Map(prev);
|
|
289
433
|
next.set(progressKey, {
|
|
290
434
|
progress: message.payload.progress || 0,
|
|
291
|
-
message: message.payload.message || ""
|
|
435
|
+
message: message.payload.message || "",
|
|
292
436
|
});
|
|
293
437
|
return next;
|
|
294
438
|
});
|
|
295
439
|
}
|
|
296
440
|
}
|
|
297
441
|
// Handle batch-completed messages for this specific batch
|
|
298
|
-
if (message.type === "batch-completed" &&
|
|
442
|
+
if (message.type === "batch-completed" &&
|
|
443
|
+
message.payload.batchId === batchId) {
|
|
299
444
|
// Refresh header info only (jobs are already updated via websocket)
|
|
300
445
|
(async () => {
|
|
301
446
|
try {
|
|
@@ -318,7 +463,7 @@ export function BatchTranslationView({ batchId, onBack }) {
|
|
|
318
463
|
}, [editContext, batchId]);
|
|
319
464
|
// Adaptive polling while batch is in progress - only as fallback when websockets are silent
|
|
320
465
|
useEffect(() => {
|
|
321
|
-
const isRunning = batchInfo?.status ===
|
|
466
|
+
const isRunning = batchInfo?.status === "In Progress";
|
|
322
467
|
if (!isRunning)
|
|
323
468
|
return;
|
|
324
469
|
const pollBatchInfo = async () => {
|
|
@@ -356,7 +501,7 @@ export function BatchTranslationView({ batchId, onBack }) {
|
|
|
356
501
|
}
|
|
357
502
|
const fetchItemNames = async () => {
|
|
358
503
|
// Extract unique item IDs from batchJobs
|
|
359
|
-
const uniqueItemIds = Array.from(new Set(batchJobs.map(job => job.itemId)));
|
|
504
|
+
const uniqueItemIds = Array.from(new Set(batchJobs.map((job) => job.itemId)));
|
|
360
505
|
if (uniqueItemIds.length === 0) {
|
|
361
506
|
return;
|
|
362
507
|
}
|
|
@@ -364,7 +509,7 @@ export function BatchTranslationView({ batchId, onBack }) {
|
|
|
364
509
|
const language = editContext.currentItemDescriptor?.language || "en";
|
|
365
510
|
try {
|
|
366
511
|
// Create ItemDescriptors for fetching
|
|
367
|
-
const itemDescriptors = uniqueItemIds.map(itemId => ({
|
|
512
|
+
const itemDescriptors = uniqueItemIds.map((itemId) => ({
|
|
368
513
|
id: itemId,
|
|
369
514
|
language: language,
|
|
370
515
|
version: 0, // Latest version
|
|
@@ -378,7 +523,7 @@ export function BatchTranslationView({ batchId, onBack }) {
|
|
|
378
523
|
namesMap.set(stub.id.toLowerCase(), stub.name);
|
|
379
524
|
}
|
|
380
525
|
}
|
|
381
|
-
setItemNames(prevNames => {
|
|
526
|
+
setItemNames((prevNames) => {
|
|
382
527
|
// Merge with existing names, only update if there are changes
|
|
383
528
|
let hasChanges = false;
|
|
384
529
|
const merged = new Map(prevNames);
|
|
@@ -397,7 +542,11 @@ export function BatchTranslationView({ batchId, onBack }) {
|
|
|
397
542
|
}
|
|
398
543
|
};
|
|
399
544
|
fetchItemNames();
|
|
400
|
-
}, [
|
|
545
|
+
}, [
|
|
546
|
+
batchJobs,
|
|
547
|
+
editContext?.itemsRepository,
|
|
548
|
+
editContext?.currentItemDescriptor?.language,
|
|
549
|
+
]);
|
|
401
550
|
// Group jobs by item for better organization
|
|
402
551
|
const itemGroups = useMemo(() => {
|
|
403
552
|
const groups = {};
|
|
@@ -415,9 +564,9 @@ export function BatchTranslationView({ batchId, onBack }) {
|
|
|
415
564
|
return entries.sort(([, jobsA], [, jobsB]) => {
|
|
416
565
|
// Calculate status priority for each item group
|
|
417
566
|
const getStatusPriority = (jobs) => {
|
|
418
|
-
const hasInProgress = jobs.some(j => j.status === "In Progress");
|
|
419
|
-
const hasError = jobs.some(j => j.status === "Error");
|
|
420
|
-
const allCompleted = jobs.every(j => j.status === "Completed");
|
|
567
|
+
const hasInProgress = jobs.some((j) => j.status === "In Progress");
|
|
568
|
+
const hasError = jobs.some((j) => j.status === "Error");
|
|
569
|
+
const allCompleted = jobs.every((j) => j.status === "Completed");
|
|
421
570
|
if (hasInProgress)
|
|
422
571
|
return 1; // Highest priority - show first
|
|
423
572
|
if (hasError)
|
|
@@ -434,7 +583,7 @@ export function BatchTranslationView({ batchId, onBack }) {
|
|
|
434
583
|
// Calculate overall progress segments including in-progress job progress.
|
|
435
584
|
// Incorporate server-side expected/completed/error counts so pending server jobs are reflected.
|
|
436
585
|
const progressSegments = useMemo(() => {
|
|
437
|
-
const expectedTotal = typeof batchInfo?.expectedJobs ===
|
|
586
|
+
const expectedTotal = typeof batchInfo?.expectedJobs === "number" && batchInfo.expectedJobs > 0
|
|
438
587
|
? batchInfo.expectedJobs
|
|
439
588
|
: batchJobs.length;
|
|
440
589
|
if (expectedTotal === 0) {
|
|
@@ -448,18 +597,22 @@ export function BatchTranslationView({ batchId, onBack }) {
|
|
|
448
597
|
errorCount: 0,
|
|
449
598
|
pendingCount: 0,
|
|
450
599
|
total: 0,
|
|
451
|
-
actualProgress: 0
|
|
600
|
+
actualProgress: 0,
|
|
452
601
|
};
|
|
453
602
|
}
|
|
454
|
-
const localCompleted = batchJobs.filter(job => job.status ===
|
|
455
|
-
const localErrors = batchJobs.filter(job => job.status ===
|
|
456
|
-
const serverCompleted = typeof batchInfo?.completedJobs ===
|
|
457
|
-
|
|
603
|
+
const localCompleted = batchJobs.filter((job) => job.status === "Completed").length;
|
|
604
|
+
const localErrors = batchJobs.filter((job) => job.status === "Error").length;
|
|
605
|
+
const serverCompleted = typeof batchInfo?.completedJobs === "number"
|
|
606
|
+
? batchInfo.completedJobs
|
|
607
|
+
: undefined;
|
|
608
|
+
const serverErrors = typeof batchInfo?.errorJobs === "number"
|
|
609
|
+
? batchInfo.errorJobs
|
|
610
|
+
: undefined;
|
|
458
611
|
// Prefer the larger of local/server so UI can progress smoothly with local updates
|
|
459
612
|
const completedCount = Math.max(0, Math.min(expectedTotal, Math.max(localCompleted, serverCompleted ?? 0)));
|
|
460
613
|
const errorCount = Math.max(0, Math.min(expectedTotal - completedCount, Math.max(localErrors, serverErrors ?? 0)));
|
|
461
614
|
// Known in-progress from client state
|
|
462
|
-
const knownInProgressCount = batchJobs.filter(job => job.status ===
|
|
615
|
+
const knownInProgressCount = batchJobs.filter((job) => job.status === "In Progress").length;
|
|
463
616
|
// Cap to not exceed remaining after completed+error
|
|
464
617
|
const inProgressCount = Math.max(0, Math.min(knownInProgressCount, expectedTotal - completedCount - errorCount));
|
|
465
618
|
const pendingCount = Math.max(0, expectedTotal - completedCount - errorCount - inProgressCount);
|
|
@@ -467,8 +620,8 @@ export function BatchTranslationView({ batchId, onBack }) {
|
|
|
467
620
|
// Actual progress: completed=100 each, in-progress use live progress if available, others 0
|
|
468
621
|
let totalActualProgress = completedCount * 100;
|
|
469
622
|
for (const job of batchJobs) {
|
|
470
|
-
if (job.status ===
|
|
471
|
-
const progressKey =
|
|
623
|
+
if (job.status === "In Progress") {
|
|
624
|
+
const progressKey = getJobKey(job.itemId, job.targetLanguage);
|
|
472
625
|
const jobProgress = translationProgress.get(progressKey);
|
|
473
626
|
totalActualProgress += jobProgress?.progress || 0;
|
|
474
627
|
}
|
|
@@ -489,6 +642,7 @@ export function BatchTranslationView({ batchId, onBack }) {
|
|
|
489
642
|
return segments;
|
|
490
643
|
}, [batchJobs, batchInfo, translationProgress]);
|
|
491
644
|
const overallProgress = progressSegments.actualProgress;
|
|
645
|
+
const isMobile = editContext?.isMobile ?? false;
|
|
492
646
|
// Treat the batch as Completed if all jobs are completed and there are no errors/in-progress,
|
|
493
647
|
// even if the backend status is still "In Progress".
|
|
494
648
|
const effectiveStatus = batchInfo?.status === "In Progress" &&
|
|
@@ -517,14 +671,38 @@ export function BatchTranslationView({ batchId, onBack }) {
|
|
|
517
671
|
catch { }
|
|
518
672
|
})();
|
|
519
673
|
}, [effectiveStatus, batchId, loadBatchJobs]);
|
|
520
|
-
return (_jsxs("div", { className: "flex h-full flex-col min-h-0 bg-
|
|
521
|
-
?
|
|
522
|
-
: effectiveStatus ===
|
|
523
|
-
?
|
|
524
|
-
: effectiveStatus ===
|
|
525
|
-
?
|
|
526
|
-
:
|
|
527
|
-
|
|
674
|
+
return (_jsxs("div", { className: "flex h-full flex-col min-h-0 bg-gray-5", "data-testid": "batch-translation-view", children: [_jsxs("div", { className: "shrink-0 border-b border-gray-3 bg-background p-4 md:p-6", children: [_jsxs("div", { className: `flex ${isMobile ? "flex-col items-start gap-4" : "items-center justify-between"}`, children: [_jsxs("div", { className: `flex ${isMobile ? "w-full" : "items-center"} gap-3`, children: [onBack && (_jsxs(Button, { size: "sm", variant: "outline", onClick: onBack, className: isMobile ? "shrink-0 self-start p-0 w-8 h-8" : "", children: [_jsx(ArrowLeftIcon, { className: "h-4 w-4" }), isMobile ? "" : "Back"] })), _jsxs("div", { className: "flex-1 min-w-0", children: [_jsx("h1", { className: "text-lg md:text-xl font-semibold text-(--color-dark) truncate", children: "Translation Batch" }), _jsxs("p", { className: "text-xs md:text-sm text-gray-2 mt-0.5 md:mt-1", children: ["Batch ID:", " ", _jsx("span", { className: "font-mono text-[10px] md:text-xs bg-gray-4 px-2 py-0.5 rounded-md break-all", children: batchId })] }), batchInfo && !isMobile && (_jsxs("div", { className: "mt-2 flex flex-wrap gap-2 items-center text-xs", children: [effectiveStatus && (_jsx("span", { "data-testid": "translation-job-status", className: `inline-flex items-center rounded-full px-2.5 py-1 font-medium ${effectiveStatus === "Completed"
|
|
675
|
+
? "bg-green-100 text-green-700"
|
|
676
|
+
: effectiveStatus === "In Progress"
|
|
677
|
+
? ""
|
|
678
|
+
: effectiveStatus === "Error"
|
|
679
|
+
? "bg-red-100 text-red-600"
|
|
680
|
+
: "bg-gray-4 text-(--color-gray-1)"}`, style: effectiveStatus === "In Progress"
|
|
681
|
+
? { backgroundColor: "#f6eeff", color: "#9650fb" }
|
|
682
|
+
: undefined, children: effectiveStatus })), batchInfo.provider && (_jsxs("span", { className: "text-(--color-gray-1)", children: ["Provider:", " ", _jsx("span", { className: "font-medium", children: getProviderDisplayName(batchInfo.provider) })] })), batchInfo.initiatedByUser && (_jsxs("span", { className: "text-(--color-gray-1)", children: ["By:", " ", _jsx("span", { className: "font-medium", children: batchInfo.initiatedByUser })] })), (batchInfo.startedAtUtc ||
|
|
683
|
+
batchInfo.completedAtUtc ||
|
|
684
|
+
batchInfo.lastUpdatedUtc) && (_jsxs("span", { className: "text-gray-2", children: [batchInfo.startedAtUtc && (_jsxs(_Fragment, { children: ["Started:", " ", new Date(batchInfo.startedAtUtc).toLocaleString()] })), batchInfo.completedAtUtc && (_jsxs(_Fragment, { children: [batchInfo.startedAtUtc ? " • " : "", "Completed:", " ", new Date(batchInfo.completedAtUtc).toLocaleString()] })), !batchInfo.completedAtUtc &&
|
|
685
|
+
batchInfo.lastUpdatedUtc && (_jsxs(_Fragment, { children: [batchInfo.startedAtUtc ? " • " : "", "Updated:", " ", new Date(batchInfo.lastUpdatedUtc).toLocaleString()] }))] }))] }))] })] }), isMobile && batchInfo && (_jsxs("div", { className: "grid grid-cols-2 gap-x-4 gap-y-3 w-full mt-1 border-t border-gray-3 pt-3", children: [_jsxs("div", { className: "flex flex-col gap-1", children: [_jsx("span", { className: "text-[10px] text-gray-2 uppercase font-bold tracking-wider", children: "Status" }), effectiveStatus && (_jsx("span", { "data-testid": "translation-job-status", className: `inline-flex items-center self-start rounded-full px-2 py-0.5 text-[10px] font-medium ${effectiveStatus === "Completed"
|
|
686
|
+
? "bg-green-100 text-green-700"
|
|
687
|
+
: effectiveStatus === "In Progress"
|
|
688
|
+
? ""
|
|
689
|
+
: effectiveStatus === "Error"
|
|
690
|
+
? "bg-red-100 text-red-600"
|
|
691
|
+
: "bg-gray-4 text-(--color-gray-1)"}`, style: effectiveStatus === "In Progress"
|
|
692
|
+
? { backgroundColor: "#f6eeff", color: "#9650fb" }
|
|
693
|
+
: undefined, children: effectiveStatus }))] }), _jsxs("div", { className: "flex flex-col gap-1", children: [_jsx("span", { className: "text-[10px] text-gray-2 uppercase font-bold tracking-wider", children: "Provider" }), _jsx("span", { className: "text-xs font-medium text-(--color-gray-1) truncate", children: getProviderDisplayName(batchInfo.provider) })] }), _jsxs("div", { className: "flex flex-col gap-1", children: [_jsx("span", { className: "text-[10px] text-gray-2 uppercase font-bold tracking-wider", children: "Initiated By" }), _jsx("span", { className: "text-xs font-medium text-(--color-gray-1) truncate", children: batchInfo.initiatedByUser || "System" })] }), _jsxs("div", { className: "flex flex-col gap-1", children: [_jsx("span", { className: "text-[10px] text-gray-2 uppercase font-bold tracking-wider", children: "Activity" }), _jsx("span", { className: "text-[10px] text-gray-2 leading-tight", children: batchInfo.completedAtUtc ? (_jsxs(_Fragment, { children: ["Done:", " ", new Date(batchInfo.completedAtUtc).toLocaleTimeString()] })) : batchInfo.startedAtUtc ? (_jsxs(_Fragment, { children: ["Started:", " ", new Date(batchInfo.startedAtUtc).toLocaleTimeString()] })) : batchInfo.lastUpdatedUtc ? (_jsxs(_Fragment, { children: ["Updated:", " ", new Date(batchInfo.lastUpdatedUtc).toLocaleTimeString()] })) : (_jsx(_Fragment, { children: "Created" })) })] })] })), _jsxs("div", { className: `flex items-center ${isMobile ? "w-full justify-between border-t border-gray-3 pt-3 mt-1" : "gap-3"}`, children: [_jsxs("div", { className: "text-xs md:text-sm text-gray-2", children: [_jsx("span", { className: "font-semibold text-(--color-dark)", children: progressSegments.completedCount }), "/", progressSegments.total, " completed", progressSegments.errorCount
|
|
694
|
+
? `, ${progressSegments.errorCount} errors`
|
|
695
|
+
: ""] }), _jsxs("div", { className: "flex items-center gap-2", children: [progressSegments.errorCount > 0 && (_jsxs(Button, { size: "sm", variant: "outline", onClick: () => void handleRetryJobs(batchJobs.filter((job) => job.status === "Error"), true), disabled: isRetryingAll || isLoading, className: "md:w-auto h-8 px-3", children: [_jsx(RefreshIcon, { className: `h-4 w-4 ${isRetryingAll ? "animate-spin" : ""}` }), _jsx("span", { children: "Retry All Failed" })] })), _jsxs(Button, { size: "sm", variant: "outline", onClick: loadBatchJobs, disabled: isLoading || isRetryingAll, title: "Manual refresh - normally updates come through websocket subscriptions", className: "md:w-auto w-8 h-8 md:h-8 p-0 md:px-3", children: [_jsx(RefreshIcon, { className: `h-4 w-4 ${isLoading ? "animate-spin" : ""}` }), _jsx("span", { className: "hidden md:inline", children: "Refresh" })] })] })] })] }), parsedMetadata && (_jsxs("div", { className: `border-t border-gray-3 bg-gray-5 mt-4 rounded-b-lg ${isMobile ? "-mx-4 border-x-0" : ""}`, children: [_jsxs("button", { onClick: () => setIsMetadataExpanded(!isMetadataExpanded), className: `flex w-full cursor-pointer items-center justify-between ${isMobile ? "px-4 py-3" : "px-4 py-2.5"} text-left transition-colors hover:bg-gray-4`, children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx(InfoIcon, { className: "h-4 w-4 text-gray-2" }), _jsx("span", { className: "text-xs font-medium text-(--color-gray-1)", children: "Batch Metadata" })] }), isMetadataExpanded ? (_jsx(ChevronUpIcon, { className: "h-4 w-4 text-gray-2", strokeWidth: 1 })) : (_jsx(ChevronDownIcon, { className: "h-4 w-4 text-gray-2", strokeWidth: 1 }))] }), isMetadataExpanded && (_jsx("div", { className: "max-h-96 overflow-y-auto px-4 pb-3", children: _jsx("div", { className: "rounded-lg border border-gray-3 bg-background p-3 text-xs shadow-sm mt-2", children: _jsx(MemoizedJsonView, { data: parsedMetadata }) }) }))] })), _jsxs("div", { className: "mt-6 md:mt-4", children: [_jsxs("div", { className: "flex items-center justify-between mb-2", children: [_jsx("span", { className: "text-sm font-medium text-(--color-dark)", children: "Overall Progress" }), _jsxs("span", { className: "text-sm text-gray-2", children: [_jsx("span", { className: "font-semibold text-(--color-dark)", children: progressSegments.completedCount }), " ", "/ ", progressSegments.total, " completed"] })] }), _jsxs("div", { className: `relative ${isMobile ? "h-5" : "h-4"} rounded-full overflow-hidden bg-gray-3`, children: [_jsxs("div", { className: "absolute inset-0 flex", children: [progressSegments.completed > 0 && (_jsx("div", { className: "h-full transition-all duration-300", style: {
|
|
696
|
+
width: `${progressSegments.completed}%`,
|
|
697
|
+
backgroundColor: "#8ae048",
|
|
698
|
+
} })), progressSegments.inProgress > 0 && (_jsx("div", { className: "h-full transition-all duration-300", style: {
|
|
699
|
+
width: `${progressSegments.inProgress}%`,
|
|
700
|
+
backgroundColor: "#9650fb",
|
|
701
|
+
} })), progressSegments.error > 0 && (_jsx("div", { className: "bg-destructive h-full transition-all duration-300", style: { width: `${progressSegments.error}%` } })), progressSegments.pending > 0 && (_jsx("div", { className: "h-full transition-all duration-300", style: {
|
|
702
|
+
width: `${progressSegments.pending}%`,
|
|
703
|
+
backgroundColor: "var(--color-gray-4)",
|
|
704
|
+
} }))] }), _jsx("div", { className: "absolute inset-0 flex items-center justify-center pointer-events-none", children: _jsxs("span", { className: `text-xs font-bold text-white drop-shadow-sm ${isMobile ? "-mt-1" : ""}`, children: [overallProgress, "%"] }) })] }), _jsxs("div", { className: `flex flex-wrap ${isMobile ? "gap-x-4 gap-y-2" : "gap-3"} mt-3 md:mt-2 text-[10px] md:text-xs`, children: [progressSegments.completedCount > 0 && (_jsxs("div", { className: "flex items-center gap-1.5", children: [_jsx("div", { className: "w-2.5 h-2.5 rounded-sm", style: { backgroundColor: "#8ae048" } }), _jsxs("span", { className: "text-(--color-gray-1)", children: [progressSegments.completedCount, " completed"] })] })), progressSegments.inProgressCount > 0 && (_jsxs("div", { className: "flex items-center gap-1.5", children: [_jsx("div", { className: "w-2.5 h-2.5 rounded-sm", style: { backgroundColor: "#9650fb" } }), _jsxs("span", { className: "text-(--color-gray-1)", children: [progressSegments.inProgressCount, " in progress"] })] })), progressSegments.errorCount > 0 && (_jsxs("div", { className: "flex items-center gap-1.5", children: [_jsx("div", { className: "w-2.5 h-2.5 bg-destructive rounded-sm" }), _jsxs("span", { className: "text-(--color-gray-1)", children: [progressSegments.errorCount, " errors"] })] })), progressSegments.pendingCount > 0 && (_jsxs("div", { className: "flex items-center gap-1.5", children: [_jsx("div", { className: "w-2.5 h-2.5 bg-gray-4 rounded-sm" }), _jsxs("span", { className: "text-(--color-gray-1)", children: [progressSegments.pendingCount, " pending"] })] }))] })] })] }), _jsx("div", { className: "flex-1 overflow-auto p-4 md:p-6 min-h-0", children: isLoading && batchJobs.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 batch translations..."] }) })) : batchJobs.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-2" }), _jsx("p", { className: "font-medium text-(--color-gray-1)", children: "No translations found for this batch" }), _jsx("p", { className: "text-sm mt-1", children: "The batch may not exist or no translation jobs have started yet" })] }) })) : (_jsx("div", { className: "space-y-4", children: sortedItemGroups.map(([itemId, jobs]) => {
|
|
705
|
+
const itemCompleted = jobs.filter((j) => j.status === "Completed").length;
|
|
528
706
|
// Calculate item progress including in-progress job progress
|
|
529
707
|
// Performance: item-level calculation is always fast (typically 2-10 jobs per item)
|
|
530
708
|
let totalProgress = 0;
|
|
@@ -534,7 +712,7 @@ export function BatchTranslationView({ batchId, onBack }) {
|
|
|
534
712
|
}
|
|
535
713
|
else if (job.status === "In Progress") {
|
|
536
714
|
// Get progress for this specific job
|
|
537
|
-
const progressKey =
|
|
715
|
+
const progressKey = getJobKey(job.itemId, job.targetLanguage);
|
|
538
716
|
const jobProgress = translationProgress.get(progressKey);
|
|
539
717
|
totalProgress += jobProgress?.progress || 0; // Use actual progress or 0
|
|
540
718
|
}
|
|
@@ -543,51 +721,131 @@ export function BatchTranslationView({ batchId, onBack }) {
|
|
|
543
721
|
}
|
|
544
722
|
}
|
|
545
723
|
const itemProgress = Math.round(totalProgress / jobs.length);
|
|
546
|
-
const itemInProgress = jobs.some(j => j.status === "In Progress");
|
|
547
|
-
const itemError = jobs.some(j => j.status === "Error");
|
|
548
|
-
|
|
724
|
+
const itemInProgress = jobs.some((j) => j.status === "In Progress");
|
|
725
|
+
const itemError = jobs.some((j) => j.status === "Error");
|
|
726
|
+
const itemTerminalError = itemError && !itemInProgress;
|
|
727
|
+
const itemVisualProgress = itemTerminalError ? 100 : itemProgress;
|
|
728
|
+
return (_jsxs("div", { className: `border border-gray-3 rounded-lg bg-background ${isMobile ? "p-4" : "p-6"} shadow-sm`, children: [_jsxs("div", { className: "mb-4", children: [_jsx("div", { className: "mb-3", children: (() => {
|
|
549
729
|
const itemName = itemNames.get(itemId.toLowerCase());
|
|
550
|
-
return itemName ? (_jsxs(_Fragment, { children: [_jsx("div", { className: "text-base font-
|
|
551
|
-
})() }),
|
|
552
|
-
|
|
553
|
-
|
|
730
|
+
return itemName ? (_jsxs(_Fragment, { children: [_jsx("div", { className: `${isMobile ? "text-sm" : "text-base"} font-bold text-(--color-dark) mb-1`, children: itemName }), _jsxs("div", { className: `flex items-center gap-1.5 text-[10px] md:text-xs text-gray-2 font-mono break-all`, children: [_jsxs("span", { className: "break-all", children: ["Item ID: ", itemId] }), _jsx(SimpleIconButton, { onClick: () => openItemInEditor(itemId, "en"), icon: _jsx(ExternalLinkIcon, { className: "h-3.5 w-3.5", strokeWidth: 1 }), label: "Open item in English", className: "p-0! text-gray-2 hover:text-(--color-gray-1)" })] })] })) : (_jsxs("div", { className: `flex items-center gap-1.5 text-[10px] md:text-xs text-gray-2 font-mono break-all`, children: [_jsxs("span", { className: "break-all", children: ["Item ID: ", itemId] }), _jsx(SimpleIconButton, { onClick: () => openItemInEditor(itemId, "en"), icon: _jsx(ExternalLinkIcon, { className: "h-3.5 w-3.5", strokeWidth: 1 }), label: "Open item in English", className: "p-0! text-gray-2 hover:text-(--color-gray-1)" })] }));
|
|
731
|
+
})() }), _jsxs("div", { className: "flex items-center justify-between mb-2", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsxs("span", { className: `${isMobile ? "text-xs" : "text-sm"} text-gray-2`, children: [itemCompleted, " / ", jobs.length, " languages"] }), itemInProgress && (_jsx("span", { className: `${isMobile ? "text-[10px]" : "text-xs"} px-2 py-0.5 md:px-2.5 md:py-1 rounded-full font-medium`, style: {
|
|
732
|
+
backgroundColor: "#f6eeff",
|
|
733
|
+
color: "#9650fb",
|
|
734
|
+
}, children: "In Progress" })), itemError && (_jsx("span", { className: `${isMobile ? "text-[10px]" : "text-xs"} bg-red-100 text-red-600 px-2 py-0.5 md:px-2.5 md:py-1 rounded-full font-medium`, children: "Error" }))] }), _jsxs("span", { className: `${isMobile ? "text-xs" : "text-sm"} font-bold text-(--color-dark)`, children: [itemVisualProgress, "%"] })] }), _jsx("div", { className: "relative", children: _jsx(Progress, { value: itemVisualProgress, className: `${isMobile ? "h-2" : "h-3"} mb-2`, indicatorClassName: itemError ? "bg-destructive" : undefined, indicatorStyle: itemError
|
|
735
|
+
? undefined
|
|
736
|
+
: itemProgress === 100
|
|
737
|
+
? { backgroundColor: "#8ae048" }
|
|
738
|
+
: { backgroundColor: "#9650fb" }, showValue: false }) })] }), isMobile ? (_jsx("div", { className: "space-y-3", children: jobs
|
|
739
|
+
.sort((a, b) => {
|
|
740
|
+
const getJobPriority = (job) => {
|
|
741
|
+
if (job.status === "In Progress")
|
|
742
|
+
return 1;
|
|
743
|
+
if (job.status === "Error")
|
|
744
|
+
return 2;
|
|
745
|
+
if (job.status === "Completed")
|
|
746
|
+
return 4;
|
|
747
|
+
return 3;
|
|
748
|
+
};
|
|
749
|
+
const priorityA = getJobPriority(a);
|
|
750
|
+
const priorityB = getJobPriority(b);
|
|
751
|
+
if (priorityA === priorityB) {
|
|
752
|
+
return a.targetLanguage.localeCompare(b.targetLanguage);
|
|
753
|
+
}
|
|
754
|
+
return priorityA - priorityB;
|
|
755
|
+
})
|
|
756
|
+
.map((job) => {
|
|
757
|
+
const progressKey = getJobKey(job.itemId, job.targetLanguage);
|
|
758
|
+
const progress = translationProgress.get(progressKey);
|
|
759
|
+
const date = new Date(job.timestamp);
|
|
760
|
+
const displayMessage = job.message ||
|
|
761
|
+
(job.status === "Error"
|
|
762
|
+
? progress?.message || "Translation failed"
|
|
763
|
+
: "");
|
|
764
|
+
const jobRetryKey = getRetryJobKey(job);
|
|
765
|
+
const isRetryingJob = isRetryingAll || retryingJobKeys.has(jobRetryKey);
|
|
766
|
+
return (_jsxs("div", { className: "rounded-md border border-gray-4 bg-gray-5 p-3", children: [_jsxs("div", { className: "flex items-center justify-between mb-2", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx("span", { className: "font-bold text-(--color-dark)", children: job.targetLanguage }), _jsx("span", { className: "text-gray-2", children: "\u2190" }), _jsx("span", { className: "text-xs text-(--color-gray-1)", children: job.sourceLanguage || "—" })] }), _jsx("span", { className: `inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-bold ${job.status === "Completed"
|
|
767
|
+
? "bg-green-100 text-green-700"
|
|
768
|
+
: job.status === "In Progress"
|
|
769
|
+
? ""
|
|
770
|
+
: job.status === "Error"
|
|
771
|
+
? "bg-red-100 text-red-600"
|
|
772
|
+
: "bg-gray-4 text-(--color-gray-1)"}`, style: job.status === "In Progress"
|
|
773
|
+
? {
|
|
774
|
+
backgroundColor: "#f6eeff",
|
|
775
|
+
color: "#9650fb",
|
|
776
|
+
}
|
|
777
|
+
: undefined, children: job.status })] }), displayMessage && (_jsxs("div", { className: "mb-2 flex items-start gap-1.5", children: [_jsx("div", { className: "min-w-0 flex-1 text-xs text-gray-2 break-words [overflow-wrap:anywhere] whitespace-normal leading-relaxed line-clamp-3", title: displayMessage, children: displayMessage }), _jsx(CopyButton, { textToCopy: displayMessage, iconOnly: true, className: "mt-0.5 shrink-0 text-gray-2 hover:text-(--color-gray-1)" })] })), job.status === "In Progress" && progress && (_jsxs("div", { className: "mb-2", children: [_jsxs("div", { className: "flex items-center justify-between mb-1", children: [_jsx("span", { className: "text-[10px] text-gray-2 truncate pr-2", children: progress.message }), _jsxs("span", { className: "text-[10px] font-bold text-(--color-gray-1) shrink-0", children: [Math.round(progress.progress), "%"] })] }), _jsx(Progress, { value: progress.progress, className: "h-2", indicatorStyle: {
|
|
778
|
+
backgroundColor: "#9650fb",
|
|
779
|
+
}, showValue: false })] })), job.status === "Error" && (_jsx("div", { className: "mb-2", children: _jsx(Progress, { value: 100, className: "h-2", indicatorClassName: "bg-destructive", showValue: false }) })), _jsx("div", { className: "text-[10px] text-gray-2 text-right mt-1", children: date.toLocaleTimeString([], {
|
|
780
|
+
hour: "2-digit",
|
|
781
|
+
minute: "2-digit",
|
|
782
|
+
}) }), _jsx("div", { className: "mt-2 flex justify-end", children: _jsxs("div", { className: "flex items-center gap-2", children: [job.status === "Error" && (_jsx(SimpleIconButton, { onClick: () => void handleRetryJobs([job], false), icon: _jsx(RefreshIcon, { className: `h-3.5 w-3.5 ${isRetryingJob ? "animate-spin" : ""}`, strokeWidth: 1 }), label: "Retry translation as new batch", disabled: isRetryingJob, className: "p-0! text-gray-2 hover:text-(--color-gray-1)" })), _jsx(SimpleIconButton, { onClick: () => openItemInEditor(job.itemId, job.targetLanguage), icon: _jsx(ExternalLinkIcon, { className: "h-3.5 w-3.5", strokeWidth: 1 }), label: `Open item in ${job.targetLanguage}`, disabled: isRetryingAll, className: "p-0! text-gray-2 hover:text-(--color-gray-1)" })] }) })] }, job.targetLanguage));
|
|
783
|
+
}) })) : (_jsx(SimpleTable, { columns: [
|
|
554
784
|
{
|
|
555
785
|
header: "Language",
|
|
556
786
|
className: "w-16", // Fixed width for language column
|
|
557
|
-
body: (job) => (_jsx("div", { className: "font-medium text-
|
|
787
|
+
body: (job) => (_jsx("div", { className: "font-medium text-(--color-dark)", children: job.targetLanguage })),
|
|
558
788
|
},
|
|
559
789
|
{
|
|
560
790
|
header: "Status",
|
|
561
791
|
className: "w-32", // Fixed width for status column
|
|
562
792
|
body: (job) => {
|
|
563
|
-
const progressKey =
|
|
793
|
+
const progressKey = getJobKey(job.itemId, job.targetLanguage);
|
|
564
794
|
const progress = translationProgress.get(progressKey);
|
|
795
|
+
const displayMessage = job.message ||
|
|
796
|
+
(job.status === "Error"
|
|
797
|
+
? progress?.message || "Translation failed"
|
|
798
|
+
: "");
|
|
565
799
|
return (_jsxs("div", { children: [_jsx("span", { className: `inline-flex items-center rounded-full px-2.5 py-1 text-xs font-medium ${job.status === "Completed"
|
|
566
800
|
? "bg-green-100 text-green-700"
|
|
567
801
|
: job.status === "In Progress"
|
|
568
802
|
? ""
|
|
569
803
|
: job.status === "Error"
|
|
570
804
|
? "bg-red-100 text-red-600"
|
|
571
|
-
: "bg-
|
|
572
|
-
|
|
805
|
+
: "bg-gray-4 text-(--color-gray-1)"}`, style: job.status === "In Progress"
|
|
806
|
+
? {
|
|
807
|
+
backgroundColor: "#f6eeff",
|
|
808
|
+
color: "#9650fb",
|
|
809
|
+
}
|
|
810
|
+
: undefined, children: job.status }), job.status === "In Progress" &&
|
|
811
|
+
progress &&
|
|
812
|
+
Math.round(progress.progress || 0) === 0 && (_jsx("div", { className: "mt-1 text-xs text-gray-2", children: "Version created" })), progress && job.status === "In Progress" && (_jsxs("div", { className: "mt-1.5", children: [_jsxs("div", { className: "flex items-center justify-between mb-1", children: [_jsx("span", { className: "text-xs text-gray-2 truncate", "data-testid": "translation-progress-text", children: progress.message }), _jsxs("span", { className: "text-xs font-medium text-(--color-gray-1) ml-1", children: [Math.round(progress.progress), "%"] })] }), _jsx(Progress, { value: progress.progress, className: "h-2", indicatorStyle: {
|
|
813
|
+
backgroundColor: "#9650fb",
|
|
814
|
+
}, showValue: false, "data-testid": "translation-progress-bar" })] })), job.status === "Error" && (_jsx("div", { className: "mt-1.5", children: _jsx(Progress, { value: 100, className: "h-2", indicatorClassName: "bg-destructive", showValue: false, "data-testid": "translation-progress-bar" }) }))] }));
|
|
815
|
+
},
|
|
573
816
|
},
|
|
574
817
|
{
|
|
575
818
|
header: "Source",
|
|
576
819
|
className: "w-16", // Fixed width for source column
|
|
577
|
-
body: (job) => (_jsx("div", { className: "text-sm text-
|
|
820
|
+
body: (job) => (_jsx("div", { className: "text-sm text-(--color-gray-1)", children: job.sourceLanguage || "—" })),
|
|
578
821
|
},
|
|
579
822
|
{
|
|
580
823
|
header: "Message",
|
|
581
|
-
className: "w-
|
|
582
|
-
body: (job) =>
|
|
824
|
+
className: "w-full min-w-0",
|
|
825
|
+
body: (job) => {
|
|
826
|
+
const message = job.message ||
|
|
827
|
+
(job.status === "Error"
|
|
828
|
+
? "Translation failed"
|
|
829
|
+
: "—");
|
|
830
|
+
return (_jsxs("div", { className: "flex items-start gap-1.5", children: [_jsx("div", { className: "min-w-0 w-full flex-1 text-sm text-gray-2 break-words [overflow-wrap:anywhere] whitespace-normal leading-relaxed line-clamp-3", title: message, children: message }), message !== "—" && (_jsx(CopyButton, { textToCopy: message, iconOnly: true, className: "mt-0.5 shrink-0 text-gray-2 hover:text-(--color-gray-1)" }))] }));
|
|
831
|
+
},
|
|
832
|
+
},
|
|
833
|
+
{
|
|
834
|
+
header: "Actions",
|
|
835
|
+
className: "w-24",
|
|
836
|
+
body: (job) => {
|
|
837
|
+
const jobRetryKey = getRetryJobKey(job);
|
|
838
|
+
const isRetryingJob = isRetryingAll || retryingJobKeys.has(jobRetryKey);
|
|
839
|
+
return (_jsxs("div", { className: "flex items-center justify-center gap-2", children: [job.status === "Error" && (_jsx(SimpleIconButton, { onClick: () => void handleRetryJobs([job], false), icon: _jsx(RefreshIcon, { className: `h-3.5 w-3.5 ${isRetryingJob ? "animate-spin" : ""}`, strokeWidth: 1 }), label: "Retry translation as new batch", disabled: isRetryingJob, className: "p-0! text-gray-2 hover:text-(--color-gray-1)" })), _jsx(SimpleIconButton, { onClick: () => openItemInEditor(job.itemId, job.targetLanguage), icon: _jsx(ExternalLinkIcon, { className: "h-3.5 w-3.5", strokeWidth: 1 }), label: `Open item in ${job.targetLanguage}`, disabled: isRetryingAll, className: "p-0! text-gray-2 hover:text-(--color-gray-1)" })] }));
|
|
840
|
+
},
|
|
583
841
|
},
|
|
584
842
|
{
|
|
585
843
|
header: "Updated",
|
|
586
844
|
className: "w-24", // Fixed width for updated column
|
|
587
845
|
body: (job) => {
|
|
588
846
|
const date = new Date(job.timestamp);
|
|
589
|
-
return (_jsx("div", { className: "text-sm text-
|
|
590
|
-
}
|
|
847
|
+
return (_jsx("div", { className: "text-sm text-gray-2 whitespace-nowrap", children: date.toLocaleTimeString() }));
|
|
848
|
+
},
|
|
591
849
|
},
|
|
592
850
|
], items: jobs.sort((a, b) => {
|
|
593
851
|
// Sort by status first (in-progress first, then errors, then completed)
|
|
@@ -607,6 +865,6 @@ export function BatchTranslationView({ batchId, onBack }) {
|
|
|
607
865
|
return a.targetLanguage.localeCompare(b.targetLanguage);
|
|
608
866
|
}
|
|
609
867
|
return priorityA - priorityB;
|
|
610
|
-
}) })] }, itemId));
|
|
868
|
+
}) }))] }, itemId));
|
|
611
869
|
}) })) })] }));
|
|
612
870
|
}
|