@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.
Files changed (57) hide show
  1. package/dist/LocalizeItemDialog.d.ts.map +1 -1
  2. package/dist/LocalizeItemDialog.js +92 -34
  3. package/dist/LocalizeItemUtils.d.ts +1 -2
  4. package/dist/LocalizeItemUtils.d.ts.map +1 -1
  5. package/dist/LocalizeItemUtils.js +44 -12
  6. package/dist/api/discovery.d.ts +25 -0
  7. package/dist/api/discovery.d.ts.map +1 -1
  8. package/dist/api/discovery.js +87 -0
  9. package/dist/index.d.ts +8 -17
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +32 -32
  12. package/dist/services/translationService.d.ts +40 -9
  13. package/dist/services/translationService.d.ts.map +1 -1
  14. package/dist/services/translationService.js +30 -4
  15. package/dist/settings/TranslationServicesPanel.d.ts.map +1 -1
  16. package/dist/settings/TranslationServicesPanel.js +18 -36
  17. package/dist/sidebar/TranslationSidebar.d.ts.map +1 -1
  18. package/dist/sidebar/TranslationSidebar.js +4 -1
  19. package/dist/steps/ItemSelectionStep.d.ts +3 -0
  20. package/dist/steps/ItemSelectionStep.d.ts.map +1 -0
  21. package/dist/steps/ItemSelectionStep.js +23 -0
  22. package/dist/steps/ItemSelectionTree.d.ts +13 -0
  23. package/dist/steps/ItemSelectionTree.d.ts.map +1 -0
  24. package/dist/steps/ItemSelectionTree.js +326 -0
  25. package/dist/steps/MetadataInputStep.d.ts.map +1 -1
  26. package/dist/steps/MetadataInputStep.js +8 -1
  27. package/dist/steps/PromptCustomizationStep.d.ts +1 -1
  28. package/dist/steps/PromptCustomizationStep.d.ts.map +1 -1
  29. package/dist/steps/PromptCustomizationStep.js +161 -56
  30. package/dist/steps/ServiceLanguageSelectionStep.d.ts +6 -1
  31. package/dist/steps/ServiceLanguageSelectionStep.d.ts.map +1 -1
  32. package/dist/steps/ServiceLanguageSelectionStep.js +53 -163
  33. package/dist/steps/WizardStepShell.d.ts +17 -0
  34. package/dist/steps/WizardStepShell.d.ts.map +1 -0
  35. package/dist/steps/WizardStepShell.js +11 -0
  36. package/dist/steps/index.d.ts +1 -0
  37. package/dist/steps/index.d.ts.map +1 -1
  38. package/dist/steps/index.js +1 -0
  39. package/dist/steps/types.d.ts +17 -1
  40. package/dist/steps/types.d.ts.map +1 -1
  41. package/dist/translation-center/TranslationBatches.d.ts +2 -0
  42. package/dist/translation-center/TranslationBatches.d.ts.map +1 -0
  43. package/dist/translation-center/TranslationBatches.js +995 -0
  44. package/dist/translation-center/TranslationManagement.d.ts.map +1 -1
  45. package/dist/translation-center/TranslationManagement.js +22 -14
  46. package/dist/translation-center/TranslationsTitlebar.d.ts +7 -0
  47. package/dist/translation-center/TranslationsTitlebar.d.ts.map +1 -0
  48. package/dist/translation-center/TranslationsTitlebar.js +16 -0
  49. package/dist/types.d.ts +1 -0
  50. package/dist/types.d.ts.map +1 -1
  51. package/package.json +1 -1
  52. package/dist/translation-center/BatchTranslationView.d.ts +0 -8
  53. package/dist/translation-center/BatchTranslationView.d.ts.map +0 -1
  54. package/dist/translation-center/BatchTranslationView.js +0 -870
  55. package/dist/translation-center/RecentTranslations.d.ts +0 -2
  56. package/dist/translation-center/RecentTranslations.d.ts.map +0 -1
  57. package/dist/translation-center/RecentTranslations.js +0 -309
@@ -1,870 +0,0 @@
1
- import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
- import React, { useEffect, useState, useMemo, useRef, useCallback, memo, } from "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";
7
- import { JsonView, defaultStyles } from "react-json-view-lite";
8
- import "react-json-view-lite/dist/index.css";
9
- // Wrapper so React 19 sees a plain function component instead of a forwardRef exotic component.
10
- const Progress = (props) => React.createElement(CoreProgress, props);
11
- // Wrappers for lucide-react icons to work with React 19
12
- const ChevronUpIcon = (props) => React.createElement(ChevronUp, props);
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);
20
- const MemoizedJsonView = memo(({ data }) => (_jsx(JsonView, { data: data, shouldExpandNode: (level) => level < 2, style: defaultStyles })), (prevProps, nextProps) => {
21
- return JSON.stringify(prevProps.data) === JSON.stringify(nextProps.data);
22
- });
23
- // Normalize API shape/casing to what the UI expects
24
- function normalizeBatchInfo(raw) {
25
- if (!raw)
26
- return null;
27
- const toNum = (v) => typeof v === "number" ? v : v != null ? parseInt(v, 10) : undefined;
28
- const info = {
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,
43
- };
44
- return info.batchId ? info : null;
45
- }
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, }) {
77
- const editContext = useEditContext();
78
- const router = useRouter();
79
- const pathname = usePathname();
80
- const searchParams = useSearchParams();
81
- const [batchJobs, setBatchJobs] = useState([]);
82
- const [batchInfo, setBatchInfo] = useState(null);
83
- const [providers, setProviders] = useState([]);
84
- const [isLoading, setIsLoading] = useState(false);
85
- const [isRetryingAll, setIsRetryingAll] = useState(false);
86
- const [retryingJobKeys, setRetryingJobKeys] = useState(new Set());
87
- const [translationProgress, setTranslationProgress] = useState(new Map());
88
- const [itemNames, setItemNames] = useState(new Map());
89
- const [isMetadataExpanded, setIsMetadataExpanded] = useState(false);
90
- const lastWsAtRef = useRef(0);
91
- const batchJobsRef = useRef([]);
92
- const hasLoadedRef = useRef(false);
93
- const isSubscribedRef = useRef(false);
94
- const initGuardRef = useRef(false);
95
- const lastBatchIdRef = useRef(null);
96
- const completionRefreshRef = useRef(null);
97
- // Helper function to get display name from service name
98
- const getProviderDisplayName = useCallback((serviceName) => {
99
- if (!serviceName)
100
- return "Unknown";
101
- const provider = providers.find((p) => p.name === serviceName);
102
- return provider?.displayName || serviceName;
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]);
188
- // Parse metadata for display
189
- const parsedMetadata = useMemo(() => {
190
- if (!batchInfo?.metadata)
191
- return null;
192
- try {
193
- return JSON.parse(batchInfo.metadata);
194
- }
195
- catch {
196
- return null;
197
- }
198
- }, [batchInfo?.metadata]);
199
- // Keep refs in sync with state
200
- useEffect(() => {
201
- batchJobsRef.current = batchJobs;
202
- }, [batchJobs]);
203
- const loadProviders = useCallback(async () => {
204
- try {
205
- const res = await getTranslationProviders();
206
- const providerData = (res?.data ??
207
- res ??
208
- []);
209
- setProviders(providerData);
210
- }
211
- catch (error) {
212
- console.error("Failed to load translation providers:", error);
213
- }
214
- }, []);
215
- const loadBatchJobs = useCallback(async () => {
216
- setIsLoading(true);
217
- try {
218
- // Use efficient batch-specific endpoint
219
- const res = await listBatchTranslationJobs(batchId);
220
- const apiJobsRaw = (res?.data ?? res ?? []);
221
- const apiJobs = apiJobsRaw.map(normalizeJobRecord);
222
- // Merge API response with existing state to prevent flickering
223
- setBatchJobs((prevJobs) => {
224
- // Create a map of existing jobs keyed by itemId-targetLanguage
225
- const existingMap = new Map();
226
- for (const job of prevJobs) {
227
- const key = getJobKey(job.itemId, job.targetLanguage);
228
- existingMap.set(key, job);
229
- }
230
- // Merge API jobs into the map (API data takes precedence)
231
- for (const apiJob of apiJobs) {
232
- const normalizedJob = normalizeJobRecord(apiJob);
233
- const key = getJobKey(normalizedJob.itemId, normalizedJob.targetLanguage);
234
- const existingJob = existingMap.get(key);
235
- if (existingJob) {
236
- // Prefer API data if timestamp is newer or status changed
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) {
245
- // Preserve sourceLanguage from API if available, otherwise keep existing
246
- existingMap.set(key, {
247
- ...normalizedJob,
248
- sourceLanguage: normalizedJob.sourceLanguage ||
249
- existingJob.sourceLanguage ||
250
- "",
251
- });
252
- }
253
- }
254
- else {
255
- // New job from API - ensure sourceLanguage is set
256
- existingMap.set(key, {
257
- ...normalizedJob,
258
- sourceLanguage: normalizedJob.sourceLanguage || "",
259
- });
260
- }
261
- }
262
- // Convert map back to array
263
- const mergedJobs = Array.from(existingMap.values());
264
- // Only update if the merged result differs from current state
265
- // Check if arrays are different (length or content)
266
- if (mergedJobs.length !== prevJobs.length) {
267
- return mergedJobs;
268
- }
269
- // Deep comparison: check if any job changed
270
- const prevMap = new Map(prevJobs.map((j) => [`${j.itemId}-${j.targetLanguage}`, j]));
271
- let hasChanges = false;
272
- for (const mergedJob of mergedJobs) {
273
- const key = `${mergedJob.itemId}-${mergedJob.targetLanguage}`;
274
- const prevJob = prevMap.get(key);
275
- if (!prevJob ||
276
- prevJob.status !== mergedJob.status ||
277
- prevJob.timestamp !== mergedJob.timestamp ||
278
- prevJob.message !== mergedJob.message) {
279
- hasChanges = true;
280
- break;
281
- }
282
- }
283
- if (hasChanges) {
284
- return mergedJobs;
285
- }
286
- // No changes detected, return previous state to avoid unnecessary re-render
287
- return prevJobs;
288
- });
289
- }
290
- catch (error) {
291
- console.error("Failed to load batch jobs:", error);
292
- }
293
- finally {
294
- setIsLoading(false);
295
- }
296
- }, [batchId, editContext]);
297
- // Load initial data and conditionally subscribe to batch updates
298
- useEffect(() => {
299
- // Reset guards if batchId changed
300
- if (lastBatchIdRef.current !== batchId) {
301
- lastBatchIdRef.current = batchId;
302
- hasLoadedRef.current = false;
303
- isSubscribedRef.current = false;
304
- initGuardRef.current = false;
305
- completionRefreshRef.current = null;
306
- }
307
- let cancelled = false;
308
- const init = async () => {
309
- if (initGuardRef.current)
310
- return; // prevent duplicate init under StrictMode
311
- initGuardRef.current = true;
312
- try {
313
- // Always load batch info first to decide on subscriptions
314
- try {
315
- const resInfo = await getBatchInfo(batchId);
316
- const raw = resInfo?.data ?? resInfo;
317
- const info = normalizeBatchInfo(raw);
318
- if (!cancelled && info)
319
- setBatchInfo(info);
320
- const isTerminal = info?.status === "Completed" || info?.status === "Error";
321
- // Conditionally subscribe only for non-terminal batches
322
- if (!isTerminal && editContext?.sessionId) {
323
- await subscribeToBatch(batchId, editContext.sessionId);
324
- isSubscribedRef.current = true;
325
- }
326
- }
327
- catch { }
328
- // Load providers for display name mapping
329
- await loadProviders();
330
- // Load jobs once per mount/batch change
331
- if (!hasLoadedRef.current) {
332
- await loadBatchJobs();
333
- hasLoadedRef.current = true;
334
- }
335
- }
336
- catch (e) {
337
- // no-op
338
- }
339
- };
340
- init();
341
- return () => {
342
- // Cleanup: unsubscribe when component unmounts or batch changes
343
- if (isSubscribedRef.current &&
344
- editContext &&
345
- editContext.sessionId &&
346
- batchId) {
347
- unsubscribeFromBatch(batchId, editContext.sessionId).catch(console.error);
348
- }
349
- };
350
- }, [batchId, editContext?.sessionId]);
351
- // Listen for translation events - only for this batch
352
- useEffect(() => {
353
- const removeSocketMessageListener = editContext?.addSocketMessageListener((message) => {
354
- // Mark last websocket activity time for adaptive polling
355
- if (message.type === "translation-started" ||
356
- message.type === "translation-finished" ||
357
- message.type === "translation-error" ||
358
- message.type === "translation-progress" ||
359
- message.type === "batch-completed") {
360
- lastWsAtRef.current = Date.now();
361
- }
362
- if (message.type === "translation-started" ||
363
- message.type === "translation-finished" ||
364
- message.type === "translation-error") {
365
- // Only update if this message is for our batch
366
- if (message.payload.batchId === batchId) {
367
- // Update the specific job in our local state instead of refetching all jobs
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 &&
372
- job.targetLanguage === message.payload.language);
373
- if (jobIndex >= 0) {
374
- // Update existing job
375
- const updatedJobs = [...prevJobs];
376
- const existingJob = updatedJobs[jobIndex];
377
- if (existingJob) {
378
- updatedJobs[jobIndex] = {
379
- ...existingJob,
380
- itemId: normalizedItemId,
381
- status: message.payload.status,
382
- timestamp: message.payload.timestamp || existingJob.timestamp,
383
- message: payloadMessage || existingJob.message,
384
- // Preserve sourceLanguage from existing job if not provided in message
385
- sourceLanguage: message.payload.sourceLanguage ||
386
- existingJob.sourceLanguage ||
387
- "",
388
- };
389
- }
390
- return updatedJobs;
391
- }
392
- else {
393
- // Add new job if it doesn't exist yet so terminal updates are still visible.
394
- const newJob = {
395
- itemId: normalizedItemId,
396
- targetLanguage: message.payload.language,
397
- sourceLanguage: message.payload.sourceLanguage || "", // Keep this, but we'll try to populate from API
398
- status: message.payload.status,
399
- timestamp: message.payload.timestamp || new Date().toISOString(),
400
- batchId: batchId,
401
- message: payloadMessage,
402
- };
403
- return [...prevJobs, newJob];
404
- }
405
- });
406
- }
407
- }
408
- if (message.type === "translation-progress") {
409
- // Only track progress for jobs in this batch
410
- if (message.payload.batchId === batchId) {
411
- const itemId = normalizeJobItemId(message.payload.itemId);
412
- const language = message.payload.language;
413
- const progressKey = getJobKey(itemId, language);
414
- // Ensure a row exists; if not, add a placeholder "In Progress" job
415
- setBatchJobs((prevJobs) => {
416
- const idx = prevJobs.findIndex((j) => normalizeJobItemId(j.itemId) === itemId &&
417
- j.targetLanguage === language);
418
- if (idx >= 0)
419
- return prevJobs;
420
- const newJob = {
421
- itemId,
422
- targetLanguage: language,
423
- sourceLanguage: message.payload.sourceLanguage || "", // Keep this
424
- status: "In Progress",
425
- timestamp: new Date().toISOString(),
426
- batchId: batchId,
427
- message: message.payload.message || "",
428
- };
429
- return [...prevJobs, newJob];
430
- });
431
- setTranslationProgress((prev) => {
432
- const next = new Map(prev);
433
- next.set(progressKey, {
434
- progress: message.payload.progress || 0,
435
- message: message.payload.message || "",
436
- });
437
- return next;
438
- });
439
- }
440
- }
441
- // Handle batch-completed messages for this specific batch
442
- if (message.type === "batch-completed" &&
443
- message.payload.batchId === batchId) {
444
- // Refresh header info only (jobs are already updated via websocket)
445
- (async () => {
446
- try {
447
- const resInfo = await getBatchInfo(batchId);
448
- const raw = resInfo?.data ?? resInfo;
449
- const info = normalizeBatchInfo(raw);
450
- if (info)
451
- setBatchInfo(info);
452
- await loadBatchJobs();
453
- }
454
- catch { }
455
- })();
456
- }
457
- });
458
- return () => {
459
- if (removeSocketMessageListener) {
460
- removeSocketMessageListener();
461
- }
462
- };
463
- }, [editContext, batchId]);
464
- // Adaptive polling while batch is in progress - only as fallback when websockets are silent
465
- useEffect(() => {
466
- const isRunning = batchInfo?.status === "In Progress";
467
- if (!isRunning)
468
- return;
469
- const pollBatchInfo = async () => {
470
- try {
471
- const resInfo = await getBatchInfo(batchId);
472
- const raw = resInfo?.data ?? resInfo;
473
- const info = normalizeBatchInfo(raw);
474
- if (info)
475
- setBatchInfo(info);
476
- }
477
- catch (error) {
478
- console.error("Failed to refresh batch info:", error);
479
- }
480
- };
481
- // Don't poll immediately - rely on websocket updates and initial load
482
- // Only poll as a fallback if websockets are completely silent
483
- const id = setInterval(() => {
484
- const timeSinceLastWs = Date.now() - lastWsAtRef.current;
485
- // Only poll if websockets have been completely silent for 30+ seconds
486
- // This is a fallback mechanism - websockets should handle all updates
487
- if (timeSinceLastWs > 30000) {
488
- void pollBatchInfo();
489
- // Only poll for jobs if websockets are silent for even longer
490
- if (timeSinceLastWs > 45000) {
491
- void loadBatchJobs();
492
- }
493
- }
494
- }, 30000); // Check every 30 seconds, but only poll if WS silent for 30+ seconds
495
- return () => clearInterval(id);
496
- }, [batchInfo?.status, loadBatchJobs, batchId]);
497
- // Fetch item names when batchJobs change
498
- useEffect(() => {
499
- if (!editContext?.itemsRepository || batchJobs.length === 0) {
500
- return;
501
- }
502
- const fetchItemNames = async () => {
503
- // Extract unique item IDs from batchJobs
504
- const uniqueItemIds = Array.from(new Set(batchJobs.map((job) => job.itemId)));
505
- if (uniqueItemIds.length === 0) {
506
- return;
507
- }
508
- // Get language from editContext or fallback to "en"
509
- const language = editContext.currentItemDescriptor?.language || "en";
510
- try {
511
- // Create ItemDescriptors for fetching
512
- const itemDescriptors = uniqueItemIds.map((itemId) => ({
513
- id: itemId,
514
- language: language,
515
- version: 0, // Latest version
516
- }));
517
- // Fetch item stubs
518
- const stubs = await editContext.itemsRepository.getItemsStubs(itemDescriptors);
519
- // Store names in Map
520
- const namesMap = new Map();
521
- for (const stub of stubs) {
522
- if (stub.name) {
523
- namesMap.set(stub.id.toLowerCase(), stub.name);
524
- }
525
- }
526
- setItemNames((prevNames) => {
527
- // Merge with existing names, only update if there are changes
528
- let hasChanges = false;
529
- const merged = new Map(prevNames);
530
- for (const [id, name] of namesMap) {
531
- if (merged.get(id) !== name) {
532
- merged.set(id, name);
533
- hasChanges = true;
534
- }
535
- }
536
- return hasChanges ? merged : prevNames;
537
- });
538
- }
539
- catch (error) {
540
- console.error("Failed to fetch item names:", error);
541
- // On error, keep existing names (don't clear them)
542
- }
543
- };
544
- fetchItemNames();
545
- }, [
546
- batchJobs,
547
- editContext?.itemsRepository,
548
- editContext?.currentItemDescriptor?.language,
549
- ]);
550
- // Group jobs by item for better organization
551
- const itemGroups = useMemo(() => {
552
- const groups = {};
553
- for (const job of batchJobs) {
554
- if (!groups[job.itemId]) {
555
- groups[job.itemId] = [];
556
- }
557
- groups[job.itemId].push(job);
558
- }
559
- return groups;
560
- }, [batchJobs]);
561
- // Sort items by status priority: in-progress first, then errors, then completed
562
- const sortedItemGroups = useMemo(() => {
563
- const entries = Object.entries(itemGroups);
564
- return entries.sort(([, jobsA], [, jobsB]) => {
565
- // Calculate status priority for each item group
566
- const getStatusPriority = (jobs) => {
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");
570
- if (hasInProgress)
571
- return 1; // Highest priority - show first
572
- if (hasError)
573
- return 2; // Second priority
574
- if (allCompleted)
575
- return 4; // Lowest priority - show last
576
- return 3; // Pending/other statuses
577
- };
578
- const priorityA = getStatusPriority(jobsA);
579
- const priorityB = getStatusPriority(jobsB);
580
- return priorityA - priorityB;
581
- });
582
- }, [itemGroups]);
583
- // Calculate overall progress segments including in-progress job progress.
584
- // Incorporate server-side expected/completed/error counts so pending server jobs are reflected.
585
- const progressSegments = useMemo(() => {
586
- const expectedTotal = typeof batchInfo?.expectedJobs === "number" && batchInfo.expectedJobs > 0
587
- ? batchInfo.expectedJobs
588
- : batchJobs.length;
589
- if (expectedTotal === 0) {
590
- return {
591
- completed: 0,
592
- inProgress: 0,
593
- error: 0,
594
- pending: 0,
595
- completedCount: 0,
596
- inProgressCount: 0,
597
- errorCount: 0,
598
- pendingCount: 0,
599
- total: 0,
600
- actualProgress: 0,
601
- };
602
- }
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;
611
- // Prefer the larger of local/server so UI can progress smoothly with local updates
612
- const completedCount = Math.max(0, Math.min(expectedTotal, Math.max(localCompleted, serverCompleted ?? 0)));
613
- const errorCount = Math.max(0, Math.min(expectedTotal - completedCount, Math.max(localErrors, serverErrors ?? 0)));
614
- // Known in-progress from client state
615
- const knownInProgressCount = batchJobs.filter((job) => job.status === "In Progress").length;
616
- // Cap to not exceed remaining after completed+error
617
- const inProgressCount = Math.max(0, Math.min(knownInProgressCount, expectedTotal - completedCount - errorCount));
618
- const pendingCount = Math.max(0, expectedTotal - completedCount - errorCount - inProgressCount);
619
- const pct = (count) => Math.max(0, Math.min(100, Math.round((count / expectedTotal) * 100)));
620
- // Actual progress: completed=100 each, in-progress use live progress if available, others 0
621
- let totalActualProgress = completedCount * 100;
622
- for (const job of batchJobs) {
623
- if (job.status === "In Progress") {
624
- const progressKey = getJobKey(job.itemId, job.targetLanguage);
625
- const jobProgress = translationProgress.get(progressKey);
626
- totalActualProgress += jobProgress?.progress || 0;
627
- }
628
- }
629
- const actualProgress = Math.max(0, Math.min(100, Math.round(totalActualProgress / expectedTotal)));
630
- const segments = {
631
- completed: pct(completedCount),
632
- inProgress: pct(inProgressCount),
633
- error: pct(errorCount),
634
- pending: pct(pendingCount),
635
- completedCount,
636
- inProgressCount,
637
- errorCount,
638
- pendingCount,
639
- total: expectedTotal,
640
- actualProgress,
641
- };
642
- return segments;
643
- }, [batchJobs, batchInfo, translationProgress]);
644
- const overallProgress = progressSegments.actualProgress;
645
- const isMobile = editContext?.isMobile ?? false;
646
- // Treat the batch as Completed if all jobs are completed and there are no errors/in-progress,
647
- // even if the backend status is still "In Progress".
648
- const effectiveStatus = batchInfo?.status === "In Progress" &&
649
- progressSegments.total > 0 &&
650
- progressSegments.completedCount === progressSegments.total &&
651
- progressSegments.errorCount === 0 &&
652
- progressSegments.inProgressCount === 0
653
- ? "Completed"
654
- : batchInfo?.status;
655
- // Ensure terminal state is reflected in UI even if a websocket message was missed.
656
- useEffect(() => {
657
- if (effectiveStatus !== "Completed" && effectiveStatus !== "Error")
658
- return;
659
- if (completionRefreshRef.current === batchId)
660
- return;
661
- completionRefreshRef.current = batchId;
662
- (async () => {
663
- try {
664
- const resInfo = await getBatchInfo(batchId);
665
- const raw = resInfo?.data ?? resInfo;
666
- const info = normalizeBatchInfo(raw);
667
- if (info)
668
- setBatchInfo(info);
669
- await loadBatchJobs();
670
- }
671
- catch { }
672
- })();
673
- }, [effectiveStatus, batchId, loadBatchJobs]);
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;
706
- // Calculate item progress including in-progress job progress
707
- // Performance: item-level calculation is always fast (typically 2-10 jobs per item)
708
- let totalProgress = 0;
709
- for (const job of jobs) {
710
- if (job.status === "Completed") {
711
- totalProgress += 100; // Full credit for completed jobs
712
- }
713
- else if (job.status === "In Progress") {
714
- // Get progress for this specific job
715
- const progressKey = getJobKey(job.itemId, job.targetLanguage);
716
- const jobProgress = translationProgress.get(progressKey);
717
- totalProgress += jobProgress?.progress || 0; // Use actual progress or 0
718
- }
719
- else {
720
- totalProgress += 0; // Pending/Error jobs contribute 0
721
- }
722
- }
723
- const itemProgress = Math.round(totalProgress / jobs.length);
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: (() => {
729
- const itemName = itemNames.get(itemId.toLowerCase());
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: [
784
- {
785
- header: "Language",
786
- className: "w-16", // Fixed width for language column
787
- body: (job) => (_jsx("div", { className: "font-medium text-(--color-dark)", children: job.targetLanguage })),
788
- },
789
- {
790
- header: "Status",
791
- className: "w-[14rem] min-w-[14rem]", // Keep status column width stable as content changes
792
- body: (job) => {
793
- const progressKey = getJobKey(job.itemId, job.targetLanguage);
794
- const progress = translationProgress.get(progressKey);
795
- const displayMessage = job.message ||
796
- (job.status === "Error"
797
- ? progress?.message || "Translation failed"
798
- : "");
799
- return (_jsxs("div", { className: "w-[14rem] min-w-[14rem]", children: [_jsx("span", { className: `inline-flex items-center rounded-full px-2.5 py-1 text-xs font-medium ${job.status === "Completed"
800
- ? "bg-green-100 text-green-700"
801
- : job.status === "In Progress"
802
- ? ""
803
- : job.status === "Error"
804
- ? "bg-red-100 text-red-600"
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
- },
816
- },
817
- {
818
- header: "Source",
819
- className: "w-16", // Fixed width for source column
820
- body: (job) => (_jsx("div", { className: "text-sm text-(--color-gray-1)", children: job.sourceLanguage || "—" })),
821
- },
822
- {
823
- header: "Message",
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
- },
841
- },
842
- {
843
- header: "Updated",
844
- className: "w-24", // Fixed width for updated column
845
- body: (job) => {
846
- const date = new Date(job.timestamp);
847
- return (_jsx("div", { className: "text-sm text-gray-2 whitespace-nowrap", children: date.toLocaleTimeString() }));
848
- },
849
- },
850
- ], items: jobs.sort((a, b) => {
851
- // Sort by status first (in-progress first, then errors, then completed)
852
- const getJobPriority = (job) => {
853
- if (job.status === "In Progress")
854
- return 1;
855
- if (job.status === "Error")
856
- return 2;
857
- if (job.status === "Completed")
858
- return 4;
859
- return 3; // Pending/other statuses
860
- };
861
- const priorityA = getJobPriority(a);
862
- const priorityB = getJobPriority(b);
863
- // If same priority, sort by language alphabetically
864
- if (priorityA === priorityB) {
865
- return a.targetLanguage.localeCompare(b.targetLanguage);
866
- }
867
- return priorityA - priorityB;
868
- }) }))] }, itemId));
869
- }) })) })] }));
870
- }