gt-sanity 0.0.5 → 0.0.6

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 (48) hide show
  1. package/LICENSE.md +1 -8
  2. package/README.md +5 -5
  3. package/dist/index.d.mts +35 -30
  4. package/dist/index.d.ts +35 -30
  5. package/dist/index.js +234 -123
  6. package/dist/index.js.map +1 -1
  7. package/dist/index.mjs +237 -124
  8. package/dist/index.mjs.map +1 -1
  9. package/package.json +5 -3
  10. package/src/adapter/core.ts +72 -7
  11. package/src/adapter/createTask.ts +1 -1
  12. package/src/adapter/types.ts +9 -0
  13. package/src/components/LanguageStatus.tsx +2 -0
  14. package/src/components/NewTask.tsx +2 -0
  15. package/src/components/ProgressBar.tsx +2 -0
  16. package/src/components/TaskView.tsx +2 -0
  17. package/src/components/TranslationContext.tsx +5 -0
  18. package/src/components/TranslationView.tsx +34 -2
  19. package/src/components/TranslationsTab.tsx +4 -0
  20. package/src/components/page/TranslationsTool.tsx +876 -0
  21. package/src/configuration/baseDocumentLevelConfig/documentLevelPatch.ts +23 -10
  22. package/src/configuration/baseDocumentLevelConfig/helpers/createI18nDocAndPatchMetadata.ts +77 -24
  23. package/src/configuration/baseDocumentLevelConfig/helpers/createTranslationMetadata.ts +2 -0
  24. package/src/configuration/baseDocumentLevelConfig/helpers/getOrCreateTranslationMetadata.ts +2 -0
  25. package/src/configuration/baseDocumentLevelConfig/helpers/getTranslationMetadata.ts +2 -0
  26. package/src/configuration/baseDocumentLevelConfig/helpers/patchI18nDoc.ts +51 -8
  27. package/src/configuration/baseDocumentLevelConfig/index.ts +6 -37
  28. package/src/configuration/baseFieldLevelConfig.ts +4 -1
  29. package/src/configuration/utils/checkSerializationVersion.ts +2 -0
  30. package/src/configuration/utils/findDocumentAtRevision.ts +2 -0
  31. package/src/configuration/utils/findLatestDraft.ts +2 -0
  32. package/src/hooks/useClient.ts +3 -1
  33. package/src/hooks/useSecrets.ts +2 -0
  34. package/src/index.ts +70 -32
  35. package/src/translation/checkTranslationStatus.ts +42 -0
  36. package/src/translation/createJobs.ts +16 -0
  37. package/src/translation/downloadTranslations.ts +68 -0
  38. package/src/translation/importDocument.ts +24 -0
  39. package/src/translation/initProject.ts +61 -0
  40. package/src/translation/uploadFiles.ts +32 -0
  41. package/src/types.ts +4 -1
  42. package/src/utils/applyDocuments.ts +72 -0
  43. package/src/utils/serialize.ts +32 -0
  44. package/src/utils/shared.ts +1 -0
  45. package/src/configuration/baseDocumentLevelConfig/helpers/index.ts +0 -5
  46. package/src/configuration/baseDocumentLevelConfig/legacyDocumentLevelPatch.ts +0 -69
  47. package/src/configuration/index.ts +0 -18
  48. package/src/configuration/utils/index.ts +0 -3
@@ -0,0 +1,876 @@
1
+ import React, { useState, useCallback, useEffect, useMemo } from 'react';
2
+ import {
3
+ Box,
4
+ Button,
5
+ Card,
6
+ Container,
7
+ Dialog,
8
+ Flex,
9
+ Heading,
10
+ Spinner,
11
+ Stack,
12
+ Switch,
13
+ Text,
14
+ ThemeProvider,
15
+ ToastProvider,
16
+ useToast,
17
+ } from '@sanity/ui';
18
+ import { DownloadIcon, CheckmarkCircleIcon } from '@sanity/icons';
19
+ import { buildTheme } from '@sanity/ui/theme';
20
+ import { Link } from 'sanity/router';
21
+ import { SanityDocument, useSchema } from 'sanity';
22
+ import { useClient } from '../../hooks/useClient';
23
+ import { useSecrets } from '../../hooks/useSecrets';
24
+ import { GTAdapter } from '../../adapter';
25
+ import {
26
+ GTFile,
27
+ Secrets,
28
+ TranslationLocale,
29
+ TranslationFunctionContext,
30
+ } from '../../types';
31
+ import { gtConfig } from '../../adapter/core';
32
+ import { LanguageStatus } from '../LanguageStatus';
33
+ import { serializeDocument } from '../../utils/serialize';
34
+ import { uploadFiles } from '../../translation/uploadFiles';
35
+ import { initProject } from '../../translation/initProject';
36
+ import { createJobs } from '../../translation/createJobs';
37
+ import {
38
+ downloadTranslations,
39
+ BatchedFiles,
40
+ } from '../../translation/downloadTranslations';
41
+ import { checkTranslationStatus } from '../../translation/checkTranslationStatus';
42
+ import { importDocument } from '../../translation/importDocument';
43
+
44
+ const theme = buildTheme();
45
+
46
+ const TranslationsTool = () => {
47
+ const [isTranslateAllDialogOpen, setIsTranslateAllDialogOpen] =
48
+ useState(false);
49
+ const [isImportAllDialogOpen, setIsImportAllDialogOpen] = useState(false);
50
+ const [isBusy, setIsBusy] = useState(false);
51
+ const [documents, setDocuments] = useState<SanityDocument[]>([]);
52
+ const [locales, setLocales] = useState<TranslationLocale[]>([]);
53
+ const [autoPublish, setAutoPublish] = useState(true);
54
+ const [autoRefresh, setAutoRefresh] = useState(false);
55
+ const [loadingDocuments, setLoadingDocuments] = useState(false);
56
+ const [importProgress, setImportProgress] = useState({
57
+ current: 0,
58
+ total: 0,
59
+ isImporting: false,
60
+ });
61
+ const [importedTranslations, setImportedTranslations] = useState<Set<string>>(
62
+ new Set()
63
+ );
64
+ const [downloadStatus, setDownloadStatus] = useState({
65
+ downloaded: new Set<string>(),
66
+ failed: new Set<string>(),
67
+ skipped: new Set<string>(),
68
+ });
69
+ const [translationStatuses, setTranslationStatuses] = useState<
70
+ Map<string, { progress: number; isReady: boolean; translationId?: string }>
71
+ >(new Map());
72
+ const [isRefreshing, setIsRefreshing] = useState(false);
73
+
74
+ const client = useClient();
75
+ const schema = useSchema();
76
+ const translationContext: TranslationFunctionContext = { client, schema };
77
+ const toast = useToast();
78
+ const { loading: loadingSecrets, secrets } = useSecrets<Secrets>(
79
+ `${gtConfig.getSecretsNamespace()}.secrets`
80
+ );
81
+
82
+ const fetchDocuments = useCallback(async () => {
83
+ setLoadingDocuments(true);
84
+ try {
85
+ const translateDocuments = gtConfig.getTranslateDocuments();
86
+
87
+ // Build filter conditions based on translateDocuments configuration
88
+ const filterConditions = translateDocuments
89
+ .map((filter) => {
90
+ if (filter.type && filter.documentId) {
91
+ // Both type and documentId must match
92
+ return `(_type == "${filter.type}" && _id == "${filter.documentId}")`;
93
+ } else if (filter.type) {
94
+ // Only type must match
95
+ return `_type == "${filter.type}"`;
96
+ } else if (filter.documentId) {
97
+ // Only documentId must match
98
+ return `_id == "${filter.documentId}"`;
99
+ }
100
+ return null;
101
+ })
102
+ .filter(Boolean);
103
+
104
+ // If no filters are configured, fall back to the original query
105
+ const languageField = gtConfig.getLanguageField();
106
+ const sourceLocale = gtConfig.getSourceLocale();
107
+ const languageFilter = `(!defined(${languageField}) || ${languageField} == "${sourceLocale}")`;
108
+
109
+ let query;
110
+ if (filterConditions.length === 0) {
111
+ query = `*[!(_type in ["system.group"]) && !(_id in path("_.**")) && ${languageFilter}]`;
112
+ } else {
113
+ const filterQuery = filterConditions.join(' || ');
114
+ query = `*[!(_type in ["system.group"]) && !(_id in path("_.**")) && (${filterQuery}) && ${languageFilter}]`;
115
+ }
116
+
117
+ const docs = await client.fetch(query);
118
+ setDocuments(docs);
119
+ } catch {
120
+ toast.push({
121
+ title: 'Error fetching documents',
122
+ status: 'error',
123
+ closable: true,
124
+ });
125
+ } finally {
126
+ setLoadingDocuments(false);
127
+ }
128
+ }, [client, toast]);
129
+
130
+ const fetchLocales = useCallback(async () => {
131
+ if (!secrets) return;
132
+ try {
133
+ const availableLocales = await GTAdapter.getLocales(secrets);
134
+ setLocales(availableLocales);
135
+ } catch {
136
+ toast.push({
137
+ title: 'Error fetching locales',
138
+ status: 'error',
139
+ closable: true,
140
+ });
141
+ }
142
+ }, [secrets, toast]);
143
+
144
+ useEffect(() => {
145
+ fetchDocuments();
146
+ }, [fetchDocuments]);
147
+
148
+ useEffect(() => {
149
+ if (secrets) {
150
+ fetchLocales();
151
+ }
152
+ }, [fetchLocales, secrets]);
153
+
154
+ const handleTranslateAll = useCallback(async () => {
155
+ if (!secrets || documents.length === 0) return;
156
+
157
+ setIsBusy(true);
158
+ setIsTranslateAllDialogOpen(false);
159
+
160
+ try {
161
+ const availableLocaleIds = locales
162
+ .filter((locale) => locale.enabled !== false)
163
+ .map((locale) => locale.localeId);
164
+
165
+ console.log('documents', documents);
166
+ // Transform documents to the required format
167
+ const transformedDocuments = documents
168
+ .map((doc) => {
169
+ delete doc[gtConfig.getLanguageField()];
170
+ const baseLanguage = gtConfig.getSourceLocale();
171
+ try {
172
+ const serialized = serializeDocument(doc, schema, baseLanguage);
173
+ return {
174
+ info: {
175
+ documentId: doc._id?.replace('drafts.', '') || doc._id,
176
+ versionId: doc._rev,
177
+ },
178
+ serializedDocument: serialized,
179
+ };
180
+ } catch (error) {
181
+ console.error('Error transforming document', doc._id, error);
182
+ }
183
+ return null;
184
+ })
185
+ .filter((doc) => doc !== null);
186
+
187
+ console.log('transformedDocuments', transformedDocuments);
188
+ console.log('transformedDocuments.length', transformedDocuments.length);
189
+ const uploadResult = await uploadFiles(transformedDocuments, secrets);
190
+ await initProject(uploadResult, { timeout: 600 }, secrets);
191
+ await createJobs(uploadResult, availableLocaleIds, secrets);
192
+
193
+ toast.push({
194
+ title: `Translation tasks created for ${documents.length} documents`,
195
+ status: 'success',
196
+ closable: true,
197
+ });
198
+ } catch {
199
+ toast.push({
200
+ title: 'Error creating translation tasks',
201
+ status: 'error',
202
+ closable: true,
203
+ });
204
+ } finally {
205
+ setIsBusy(false);
206
+ }
207
+ }, [secrets, documents, locales, toast]);
208
+
209
+ const handleImportAll = useCallback(async () => {
210
+ if (!secrets || documents.length === 0) return;
211
+
212
+ setIsBusy(true);
213
+ setIsImportAllDialogOpen(false);
214
+
215
+ try {
216
+ // Collect all ready translations
217
+ const readyFiles: BatchedFiles = [];
218
+
219
+ for (const [key, status] of translationStatuses.entries()) {
220
+ if (status.isReady && status.translationId) {
221
+ const [documentId, locale] = key.split(':');
222
+ const document = documents.find(
223
+ (doc) => (doc._id?.replace('drafts.', '') || doc._id) === documentId
224
+ );
225
+
226
+ if (document) {
227
+ readyFiles.push({
228
+ documentId,
229
+ versionId: document._rev,
230
+ translationId: status.translationId,
231
+ locale,
232
+ });
233
+ }
234
+ }
235
+ }
236
+
237
+ if (readyFiles.length === 0) {
238
+ toast.push({
239
+ title: 'No ready translations to import',
240
+ status: 'warning',
241
+ closable: true,
242
+ });
243
+ return;
244
+ }
245
+
246
+ // Download all ready translations
247
+ const downloadedFiles = await downloadTranslations(readyFiles, secrets);
248
+
249
+ // Set up progress tracking
250
+ setImportProgress({
251
+ current: 0,
252
+ total: downloadedFiles.length,
253
+ isImporting: true,
254
+ });
255
+
256
+ // Batch import in groups of 10
257
+ const batchSize = 10;
258
+ let successCount = 0;
259
+ let failureCount = 0;
260
+ const successfulImports: string[] = [];
261
+
262
+ for (let i = 0; i < downloadedFiles.length; i += batchSize) {
263
+ const batch = downloadedFiles.slice(i, i + batchSize);
264
+
265
+ // Process batch in parallel
266
+ const batchPromises = batch.map(async (file) => {
267
+ try {
268
+ const docInfo: GTFile = {
269
+ documentId: file.docData.documentId,
270
+ versionId: file.docData.versionId,
271
+ };
272
+
273
+ await importDocument(
274
+ docInfo,
275
+ file.docData.locale,
276
+ file.data,
277
+ translationContext,
278
+ autoPublish
279
+ );
280
+
281
+ const key = `${file.docData.documentId}:${file.docData.locale}`;
282
+ successfulImports.push(key);
283
+ setImportedTranslations((prev) => new Set([...prev, key]));
284
+ return { success: true, file };
285
+ } catch (error) {
286
+ console.error(
287
+ `Failed to import ${file.docData.documentId} (${file.docData.locale}):`,
288
+ error
289
+ );
290
+ return { success: false, file, error };
291
+ }
292
+ });
293
+
294
+ const batchResults = await Promise.all(batchPromises);
295
+
296
+ batchResults.forEach((result) => {
297
+ if (result.success) {
298
+ successCount++;
299
+ } else {
300
+ failureCount++;
301
+ }
302
+ });
303
+
304
+ // Update progress
305
+ setImportProgress({
306
+ current: i + batch.length,
307
+ total: downloadedFiles.length,
308
+ isImporting: true,
309
+ });
310
+ }
311
+
312
+ // Update download status for successful imports
313
+ if (successfulImports.length > 0) {
314
+ const newDownloadStatus = {
315
+ ...downloadStatus,
316
+ downloaded: new Set([
317
+ ...downloadStatus.downloaded,
318
+ ...successfulImports,
319
+ ]),
320
+ };
321
+ setDownloadStatus(newDownloadStatus);
322
+ }
323
+
324
+ toast.push({
325
+ title: `Imported ${successCount} translations${failureCount > 0 ? `, ${failureCount} failed` : ''}`,
326
+ status: successCount > 0 ? 'success' : 'error',
327
+ closable: true,
328
+ });
329
+ } catch (error) {
330
+ console.error('Error importing translations:', error);
331
+ toast.push({
332
+ title: 'Error importing translations',
333
+ status: 'error',
334
+ closable: true,
335
+ });
336
+ } finally {
337
+ setIsBusy(false);
338
+ setImportProgress({ current: 0, total: 0, isImporting: false });
339
+ }
340
+ }, [
341
+ secrets,
342
+ documents,
343
+ translationStatuses,
344
+ downloadStatus,
345
+ toast,
346
+ autoPublish,
347
+ translationContext,
348
+ ]);
349
+
350
+ const handleRefreshAll = useCallback(async () => {
351
+ if (!secrets || documents.length === 0) return;
352
+
353
+ setIsRefreshing(true);
354
+
355
+ try {
356
+ const availableLocaleIds = locales
357
+ .filter((locale) => locale.enabled !== false)
358
+ .map((locale) => locale.localeId);
359
+
360
+ // Create file query data for all document/locale combinations
361
+ const fileQueryData = [];
362
+ for (const doc of documents) {
363
+ for (const localeId of availableLocaleIds) {
364
+ const documentId = doc._id?.replace('drafts.', '') || doc._id;
365
+ fileQueryData.push({
366
+ versionId: doc._rev,
367
+ fileId: documentId,
368
+ locale: localeId,
369
+ });
370
+ }
371
+ }
372
+ // Check translation status for all files
373
+ const readyTranslations = await checkTranslationStatus(
374
+ fileQueryData,
375
+ downloadStatus,
376
+ secrets
377
+ );
378
+
379
+ // Update translation statuses
380
+ const newStatuses = new Map(translationStatuses);
381
+
382
+ // Reset all to not ready first
383
+ for (const doc of documents) {
384
+ for (const localeId of availableLocaleIds) {
385
+ const documentId = doc._id?.replace('drafts.', '') || doc._id;
386
+ const key = `${documentId}:${localeId}`;
387
+ newStatuses.set(key, { progress: 0, isReady: false });
388
+ }
389
+ }
390
+
391
+ // Update with ready translations
392
+ if (Array.isArray(readyTranslations)) {
393
+ for (const translation of readyTranslations) {
394
+ const key = `${translation.fileId}:${translation.locale}`;
395
+ newStatuses.set(key, {
396
+ progress: 100,
397
+ isReady: true,
398
+ translationId: translation.id,
399
+ });
400
+ }
401
+ }
402
+
403
+ setTranslationStatuses(newStatuses);
404
+
405
+ toast.push({
406
+ title: `Refreshed status for ${documents.length} documents`,
407
+ status: 'success',
408
+ closable: true,
409
+ });
410
+ } catch (error) {
411
+ console.error('Error refreshing translation status:', error);
412
+ toast.push({
413
+ title: 'Error refreshing translation status',
414
+ status: 'error',
415
+ closable: true,
416
+ });
417
+ } finally {
418
+ setIsRefreshing(false);
419
+ }
420
+ }, [secrets, documents, locales, downloadStatus, translationStatuses, toast]);
421
+
422
+ // Auto-refresh on page load after documents and locales are loaded
423
+ useEffect(() => {
424
+ if (
425
+ documents.length > 0 &&
426
+ locales.length > 0 &&
427
+ secrets &&
428
+ !loadingDocuments
429
+ ) {
430
+ handleRefreshAll();
431
+ }
432
+ }, [documents]);
433
+
434
+ // Auto-refresh functionality
435
+ useEffect(() => {
436
+ if (!autoRefresh || documents.length === 0 || !secrets) return;
437
+
438
+ const interval = setInterval(async () => {
439
+ await handleRefreshAll();
440
+ }, 10000); // Refresh every 10 seconds
441
+
442
+ return () => clearInterval(interval);
443
+ }, [autoRefresh, documents.length, secrets, handleRefreshAll]);
444
+
445
+ // Initialize imported translations from download status
446
+ useEffect(() => {
447
+ setImportedTranslations(new Set(downloadStatus.downloaded));
448
+ }, [downloadStatus.downloaded]);
449
+
450
+ const handleImportDocument = useCallback(
451
+ async (documentId: string, localeId: string) => {
452
+ if (!secrets) return;
453
+
454
+ try {
455
+ const key = `${documentId}:${localeId}`;
456
+ const status = translationStatuses.get(key);
457
+
458
+ if (!status?.isReady || !status.translationId) {
459
+ toast.push({
460
+ title: `Translation not ready for ${documentId} (${localeId})`,
461
+ status: 'warning',
462
+ closable: true,
463
+ });
464
+ return;
465
+ }
466
+
467
+ const document = documents.find(
468
+ (doc) => (doc._id?.replace('drafts.', '') || doc._id) === documentId
469
+ );
470
+
471
+ if (!document) {
472
+ toast.push({
473
+ title: `Document ${documentId} not found`,
474
+ status: 'error',
475
+ closable: true,
476
+ });
477
+ return;
478
+ }
479
+
480
+ // Download single translation
481
+ const downloadedFiles = await downloadTranslations(
482
+ [
483
+ {
484
+ documentId,
485
+ versionId: document._rev,
486
+ translationId: status.translationId,
487
+ locale: localeId,
488
+ },
489
+ ],
490
+ secrets
491
+ );
492
+
493
+ if (downloadedFiles.length > 0) {
494
+ try {
495
+ const docInfo: GTFile = {
496
+ documentId,
497
+ versionId: document._rev,
498
+ };
499
+
500
+ await importDocument(
501
+ docInfo,
502
+ localeId,
503
+ downloadedFiles[0].data,
504
+ translationContext,
505
+ autoPublish
506
+ );
507
+
508
+ // Update download status and imported translations
509
+ const newDownloadStatus = {
510
+ ...downloadStatus,
511
+ downloaded: new Set([...downloadStatus.downloaded, key]),
512
+ };
513
+ setDownloadStatus(newDownloadStatus);
514
+ setImportedTranslations((prev) => new Set([...prev, key]));
515
+
516
+ toast.push({
517
+ title: `Successfully imported translation for ${documentId} (${localeId})`,
518
+ status: 'success',
519
+ closable: true,
520
+ });
521
+ } catch (importError) {
522
+ console.error('Failed to import translation:', importError);
523
+ toast.push({
524
+ title: `Failed to import translation for ${documentId} (${localeId})`,
525
+ status: 'error',
526
+ closable: true,
527
+ });
528
+ }
529
+ } else {
530
+ toast.push({
531
+ title: `No translation content received for ${documentId}`,
532
+ status: 'warning',
533
+ closable: true,
534
+ });
535
+ }
536
+ } catch (error) {
537
+ console.error('Error importing translation:', error);
538
+ toast.push({
539
+ title: `Error importing translation for ${documentId}`,
540
+ status: 'error',
541
+ closable: true,
542
+ });
543
+ }
544
+ },
545
+ [
546
+ secrets,
547
+ translationStatuses,
548
+ documents,
549
+ downloadStatus,
550
+ toast,
551
+ autoPublish,
552
+ translationContext,
553
+ ]
554
+ );
555
+
556
+ if (loadingSecrets) {
557
+ return (
558
+ <ThemeProvider theme={theme}>
559
+ <Container width={2}>
560
+ <Flex padding={5} align='center' justify='center'>
561
+ <Spinner />
562
+ </Flex>
563
+ </Container>
564
+ </ThemeProvider>
565
+ );
566
+ }
567
+
568
+ if (!secrets) {
569
+ return (
570
+ <ThemeProvider theme={theme}>
571
+ <Container width={2}>
572
+ <Box padding={4} marginTop={5}>
573
+ <Card tone='caution' padding={[2, 3, 4, 4]} shadow={1} radius={2}>
574
+ <Text>
575
+ Can't find secrets for your translation service. Did you load
576
+ them into this dataset?
577
+ </Text>
578
+ </Card>
579
+ </Box>
580
+ </Container>
581
+ </ThemeProvider>
582
+ );
583
+ }
584
+
585
+ return (
586
+ <ThemeProvider theme={theme}>
587
+ <ToastProvider paddingY={7}>
588
+ <Container width={2}>
589
+ <Box padding={4} marginTop={5}>
590
+ <Stack space={4}>
591
+ <Flex align='center' justify='space-between'>
592
+ <Stack space={2}>
593
+ <Heading as='h2' size={3}>
594
+ Translations
595
+ </Heading>
596
+ <Text size={2}>
597
+ Manage your document translations from this centralized
598
+ location.
599
+ </Text>
600
+ </Stack>
601
+
602
+ <Flex gap={3} align='center'>
603
+ <Flex gap={2} align='center'>
604
+ <Text size={1}>Auto-refresh</Text>
605
+ <Switch
606
+ checked={autoRefresh}
607
+ onChange={() => setAutoRefresh(!autoRefresh)}
608
+ />
609
+ </Flex>
610
+ <Button
611
+ fontSize={1}
612
+ padding={2}
613
+ text={isRefreshing ? 'Refreshing...' : 'Refresh Status'}
614
+ onClick={handleRefreshAll}
615
+ disabled={
616
+ isRefreshing ||
617
+ isBusy ||
618
+ loadingDocuments ||
619
+ documents.length === 0
620
+ }
621
+ />
622
+ </Flex>
623
+ </Flex>
624
+
625
+ <Stack space={4}>
626
+ <Box>
627
+ <Text size={1} muted>
628
+ {loadingDocuments
629
+ ? 'Loading documents...'
630
+ : `Found ${documents.length} documents available for translation`}
631
+ </Text>
632
+ </Box>
633
+
634
+ <Flex justify='center'>
635
+ <Button
636
+ style={{ width: '200px' }}
637
+ tone='critical'
638
+ text={isBusy ? 'Processing...' : 'Translate All'}
639
+ onClick={() => setIsTranslateAllDialogOpen(true)}
640
+ disabled={
641
+ isBusy || loadingDocuments || documents.length === 0
642
+ }
643
+ />
644
+ </Flex>
645
+
646
+ {loadingDocuments ? (
647
+ <Flex align='center' justify='center' padding={4}>
648
+ <Spinner />
649
+ </Flex>
650
+ ) : (
651
+ <Box style={{ maxHeight: '60vh', overflowY: 'auto' }}>
652
+ <Stack space={2}>
653
+ {documents.map((document) => (
654
+ <Card key={document._id} shadow={1} padding={3}>
655
+ <Stack space={3}>
656
+ <Flex justify='space-between' align='flex-start'>
657
+ <Box flex={1}>
658
+ <Text weight='semibold' size={1}>
659
+ {document._id?.replace('drafts.', '') ||
660
+ document._id}
661
+ </Text>
662
+ <Text
663
+ size={0}
664
+ muted
665
+ style={{ marginTop: '2px' }}
666
+ >
667
+ {document._type}
668
+ </Text>
669
+ </Box>
670
+ </Flex>
671
+
672
+ <Stack space={2}>
673
+ {locales.length > 0 ? (
674
+ locales
675
+ .filter((locale) => locale.enabled !== false)
676
+ .map((locale) => {
677
+ const documentId =
678
+ document._id?.replace('drafts.', '') ||
679
+ document._id;
680
+ const key = `${documentId}:${locale.localeId}`;
681
+ const status = translationStatuses.get(key);
682
+ const isDownloaded =
683
+ downloadStatus.downloaded.has(key);
684
+ const isImported =
685
+ importedTranslations.has(key);
686
+
687
+ return (
688
+ <LanguageStatus
689
+ key={`${document._id}-${locale.localeId}`}
690
+ title={
691
+ locale.description || locale.localeId
692
+ }
693
+ progress={status?.progress || 0}
694
+ isImported={isImported || isDownloaded}
695
+ importFile={async () => {
696
+ await handleImportDocument(
697
+ documentId,
698
+ locale.localeId
699
+ );
700
+ }}
701
+ />
702
+ );
703
+ })
704
+ ) : (
705
+ <Text size={1} muted>
706
+ No locales configured
707
+ </Text>
708
+ )}
709
+ </Stack>
710
+ </Stack>
711
+ </Card>
712
+ ))}
713
+ </Stack>
714
+ </Box>
715
+ )}
716
+
717
+ <Stack space={3}>
718
+ <Flex gap={3} align='center' justify='space-between'>
719
+ <Flex gap={2} align='center'>
720
+ <Button
721
+ mode='ghost'
722
+ onClick={() => setIsImportAllDialogOpen(true)}
723
+ text={isBusy ? 'Importing...' : 'Import All'}
724
+ icon={isBusy ? null : DownloadIcon}
725
+ disabled={
726
+ isBusy || loadingDocuments || documents.length === 0
727
+ }
728
+ />
729
+ {importedTranslations.size ===
730
+ documents.length *
731
+ locales.filter((l) => l.enabled !== false).length &&
732
+ documents.length > 0 &&
733
+ locales.length > 0 && (
734
+ <Flex
735
+ gap={2}
736
+ align='center'
737
+ style={{ color: 'green' }}
738
+ >
739
+ <CheckmarkCircleIcon />
740
+ <Text size={1}>All translations imported</Text>
741
+ </Flex>
742
+ )}
743
+ {importedTranslations.size > 0 &&
744
+ importedTranslations.size <
745
+ documents.length *
746
+ locales.filter((l) => l.enabled !== false)
747
+ .length && (
748
+ <Text size={1} style={{ color: '#666' }}>
749
+ {importedTranslations.size}/
750
+ {documents.length *
751
+ locales.filter((l) => l.enabled !== false)
752
+ .length}{' '}
753
+ imported
754
+ </Text>
755
+ )}
756
+ </Flex>
757
+ <Flex
758
+ gap={2}
759
+ align='center'
760
+ style={{ whiteSpace: 'nowrap' }}
761
+ >
762
+ <Text size={1}>Auto-Publish</Text>
763
+ <Switch
764
+ checked={autoPublish}
765
+ onChange={() => setAutoPublish(!autoPublish)}
766
+ disabled={isBusy}
767
+ />
768
+ </Flex>
769
+ </Flex>
770
+
771
+ {/* Import Progress UI */}
772
+ {importProgress.isImporting && (
773
+ <Flex justify='center' align='center' gap={3}>
774
+ <Spinner size={1} />
775
+ <Text size={1}>
776
+ Importing {importProgress.current} of{' '}
777
+ {importProgress.total} translations...
778
+ </Text>
779
+ </Flex>
780
+ )}
781
+ </Stack>
782
+ </Stack>
783
+
784
+ <Text size={2}>
785
+ For more information, see the{' '}
786
+ <Link href='https://dash.generaltranslation.com'>
787
+ General Translation Dashboard
788
+ </Link>
789
+ .
790
+ </Text>
791
+ </Stack>
792
+ </Box>
793
+
794
+ {/* Translate All Confirmation Dialog */}
795
+ {isTranslateAllDialogOpen && (
796
+ <Dialog
797
+ header='Confirm Translation'
798
+ id='translate-all-dialog'
799
+ onClose={() => setIsTranslateAllDialogOpen(false)}
800
+ footer={
801
+ <Box padding={3}>
802
+ <Flex gap={2}>
803
+ <Button
804
+ text='Cancel'
805
+ mode='ghost'
806
+ onClick={() => setIsTranslateAllDialogOpen(false)}
807
+ />
808
+ <Button
809
+ text='Translate All'
810
+ tone='critical'
811
+ onClick={handleTranslateAll}
812
+ />
813
+ </Flex>
814
+ </Box>
815
+ }
816
+ >
817
+ <Box padding={4}>
818
+ <Stack space={3}>
819
+ <Text>
820
+ Are you sure you want to create translation tasks for all{' '}
821
+ {documents.length} documents?
822
+ </Text>
823
+ <Text size={1} muted>
824
+ This will submit all documents to General Translation for
825
+ processing.
826
+ </Text>
827
+ </Stack>
828
+ </Box>
829
+ </Dialog>
830
+ )}
831
+
832
+ {/* Import All Confirmation Dialog */}
833
+ {isImportAllDialogOpen && (
834
+ <Dialog
835
+ header='Confirm Import'
836
+ id='import-all-dialog'
837
+ onClose={() => setIsImportAllDialogOpen(false)}
838
+ footer={
839
+ <Box padding={3}>
840
+ <Flex gap={2}>
841
+ <Button
842
+ text='Cancel'
843
+ mode='ghost'
844
+ onClick={() => setIsImportAllDialogOpen(false)}
845
+ />
846
+ <Button
847
+ text='Import All'
848
+ tone='primary'
849
+ onClick={handleImportAll}
850
+ />
851
+ </Flex>
852
+ </Box>
853
+ }
854
+ >
855
+ <Box padding={4}>
856
+ <Stack space={3}>
857
+ <Text>
858
+ Are you sure you want to import translations for all{' '}
859
+ {documents.length} documents?
860
+ </Text>
861
+ <Text size={1} muted>
862
+ This will download and apply translated content to your
863
+ documents. Note that this will overwrite any existing
864
+ translations!
865
+ </Text>
866
+ </Stack>
867
+ </Box>
868
+ </Dialog>
869
+ )}
870
+ </Container>
871
+ </ToastProvider>
872
+ </ThemeProvider>
873
+ );
874
+ };
875
+
876
+ export default TranslationsTool;