@smartcat/sanity-plugin 1.0.0 → 1.0.1

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 (121) hide show
  1. package/README.md +294 -0
  2. package/dist/_chunks-cjs/constants.cjs +13 -0
  3. package/dist/_chunks-cjs/constants.cjs.map +1 -0
  4. package/dist/_chunks-cjs/index.cjs +168 -0
  5. package/dist/_chunks-cjs/index.cjs.map +1 -0
  6. package/dist/_chunks-cjs/index2.cjs +311 -0
  7. package/dist/_chunks-cjs/index2.cjs.map +1 -0
  8. package/dist/_chunks-cjs/workflow.cjs +16 -0
  9. package/dist/_chunks-cjs/workflow.cjs.map +1 -0
  10. package/dist/_chunks-es/constants.js +14 -0
  11. package/dist/_chunks-es/constants.js.map +1 -0
  12. package/dist/_chunks-es/index.js +169 -0
  13. package/dist/_chunks-es/index.js.map +1 -0
  14. package/dist/_chunks-es/index2.js +313 -0
  15. package/dist/_chunks-es/index2.js.map +1 -0
  16. package/dist/_chunks-es/workflow.js +17 -0
  17. package/dist/_chunks-es/workflow.js.map +1 -0
  18. package/dist/export.cjs +160 -0
  19. package/dist/export.cjs.map +1 -0
  20. package/dist/export.d.cts +162 -0
  21. package/dist/export.d.ts +162 -0
  22. package/dist/export.js +162 -0
  23. package/dist/export.js.map +1 -0
  24. package/dist/import.cjs +169 -0
  25. package/dist/import.cjs.map +1 -0
  26. package/dist/import.d.cts +155 -0
  27. package/dist/import.d.ts +155 -0
  28. package/dist/import.js +169 -0
  29. package/dist/import.js.map +1 -0
  30. package/dist/index.cjs +2103 -0
  31. package/dist/index.cjs.map +1 -0
  32. package/dist/index.d.cts +122 -0
  33. package/dist/index.d.ts +122 -0
  34. package/dist/index.js +2110 -0
  35. package/dist/index.js.map +1 -0
  36. package/dist/locjson.cjs +12 -0
  37. package/dist/locjson.cjs.map +1 -0
  38. package/dist/locjson.d.cts +287 -0
  39. package/dist/locjson.d.ts +287 -0
  40. package/dist/locjson.js +12 -0
  41. package/dist/locjson.js.map +1 -0
  42. package/dist/progress.cjs +16 -0
  43. package/dist/progress.cjs.map +1 -0
  44. package/dist/progress.d.cts +262 -0
  45. package/dist/progress.d.ts +262 -0
  46. package/dist/progress.js +16 -0
  47. package/dist/progress.js.map +1 -0
  48. package/dist/smartcat.cjs +287 -0
  49. package/dist/smartcat.cjs.map +1 -0
  50. package/dist/smartcat.d.cts +234 -0
  51. package/dist/smartcat.d.ts +234 -0
  52. package/dist/smartcat.js +287 -0
  53. package/dist/smartcat.js.map +1 -0
  54. package/dist/templates.cjs +12 -0
  55. package/dist/templates.cjs.map +1 -0
  56. package/dist/templates.d.cts +52 -0
  57. package/dist/templates.d.ts +52 -0
  58. package/dist/templates.js +12 -0
  59. package/dist/templates.js.map +1 -0
  60. package/package.json +101 -15
  61. package/src/actions/AddToProjectAction.tsx +258 -0
  62. package/src/export/export.test.ts +537 -0
  63. package/src/export/index.ts +393 -0
  64. package/src/form/TranslationStatusInput.tsx +212 -0
  65. package/src/import/import.test.ts +346 -0
  66. package/src/import/index.ts +418 -0
  67. package/src/index.ts +63 -0
  68. package/src/lib/constants.ts +23 -0
  69. package/src/lib/documentTitle.test.ts +43 -0
  70. package/src/lib/documentTitle.ts +30 -0
  71. package/src/lib/languageMap.test.ts +56 -0
  72. package/src/lib/languageMap.ts +71 -0
  73. package/src/lib/linkedDocuments.test.ts +56 -0
  74. package/src/lib/linkedDocuments.ts +100 -0
  75. package/src/lib/locjson/deserialize.ts +60 -0
  76. package/src/lib/locjson/fields.ts +355 -0
  77. package/src/lib/locjson/filename.ts +27 -0
  78. package/src/lib/locjson/index.ts +10 -0
  79. package/src/lib/locjson/locjson.test.ts +545 -0
  80. package/src/lib/locjson/paths.test.ts +41 -0
  81. package/src/lib/locjson/paths.ts +73 -0
  82. package/src/lib/locjson/portableText.ts +98 -0
  83. package/src/lib/locjson/serialize.ts +124 -0
  84. package/src/lib/locjson/types.ts +106 -0
  85. package/src/lib/log.ts +33 -0
  86. package/src/lib/projectItems.ts +17 -0
  87. package/src/lib/resolveConfig.test.ts +62 -0
  88. package/src/lib/resolveConfig.ts +47 -0
  89. package/src/lib/workflow.test.ts +37 -0
  90. package/src/lib/workflow.ts +48 -0
  91. package/src/lib/zip.fixtures.ts +75 -0
  92. package/src/lib/zip.test.ts +110 -0
  93. package/src/lib/zip.ts +164 -0
  94. package/src/progress/core.ts +387 -0
  95. package/src/progress/index.ts +7 -0
  96. package/src/progress/progress.test.ts +268 -0
  97. package/src/schema/settings.ts +55 -0
  98. package/src/schema/translatableOptions.ts +25 -0
  99. package/src/schema/translationProject.ts +264 -0
  100. package/src/smartcat/client.test.ts +222 -0
  101. package/src/smartcat/client.ts +351 -0
  102. package/src/smartcat/index.ts +10 -0
  103. package/src/smartcat/types.ts +99 -0
  104. package/src/templates/core.ts +55 -0
  105. package/src/templates/index.ts +5 -0
  106. package/src/templates/templates.test.ts +50 -0
  107. package/src/tool/Dashboard.tsx +44 -0
  108. package/src/tool/ItemProgress.tsx +176 -0
  109. package/src/tool/LogPanel.tsx +207 -0
  110. package/src/tool/ProjectView.tsx +975 -0
  111. package/src/tool/ProjectsView.tsx +208 -0
  112. package/src/tool/StatusBadge.tsx +33 -0
  113. package/src/tool/StudioInit.tsx +42 -0
  114. package/src/tool/WorkflowSelect.tsx +41 -0
  115. package/src/tool/data.ts +79 -0
  116. package/src/tool/index.ts +19 -0
  117. package/src/tool/processing.test.ts +623 -0
  118. package/src/tool/processing.ts +766 -0
  119. package/src/tool/useTemplates.ts +84 -0
  120. package/src/tool/useWorkflowSelection.ts +65 -0
  121. package/src/types.ts +77 -0
@@ -0,0 +1,975 @@
1
+ import {useCallback, useEffect, useRef, useState} from 'react'
2
+ import {useClient, useRelativeTime, useSchema, useValuePreview} from 'sanity'
3
+ import {applyImportedTranslations, prepareExport, type PendingDeletion} from './processing'
4
+ import {getTranslatableFields, localizationMode} from '../lib/locjson/fields'
5
+ import {serializeToLocjson} from '../lib/locjson/serialize'
6
+ import {buildLocjsonFilename} from '../lib/locjson/filename'
7
+ import {resolveDocumentTitle} from '../lib/documentTitle'
8
+ import type {LocalizationMode} from '../lib/locjson/types'
9
+ import {useIntentLink} from 'sanity/router'
10
+ import {
11
+ Badge,
12
+ Box,
13
+ Button,
14
+ Card,
15
+ Checkbox,
16
+ Dialog,
17
+ Flex,
18
+ Heading,
19
+ Inline,
20
+ Spinner,
21
+ Stack,
22
+ Text,
23
+ TextInput,
24
+ useToast,
25
+ } from '@sanity/ui'
26
+ import {
27
+ ArrowLeftIcon,
28
+ LaunchIcon,
29
+ TrashIcon,
30
+ PublishIcon,
31
+ CloseIcon,
32
+ DownloadIcon,
33
+ SyncIcon,
34
+ RemoveCircleIcon,
35
+ JsonIcon,
36
+ } from '@sanity/icons'
37
+ import styled, {css, keyframes} from 'styled-components'
38
+ import {API_VERSION} from '../lib/constants'
39
+ import {UNTITLED} from '../lib/documentTitle'
40
+ import {PROJECT_DETAIL_QUERY, type ProjectDetail, type ProjectItemRef} from './data'
41
+ import {StatusBadge, statusLabel} from './StatusBadge'
42
+ import {ItemProgress, languageTitle} from './ItemProgress'
43
+ import {LogPanel} from './LogPanel'
44
+ import {type DocProgress} from '../progress/core'
45
+ import {parseFunctionLog, type LogLine} from '../lib/log'
46
+ import type {SmartcatLanguage, TranslationProjectStatus} from '../types'
47
+
48
+ interface ProjectViewProps {
49
+ projectId: string
50
+ onBack: () => void
51
+ languages: SmartcatLanguage[]
52
+ sourceLanguage: string
53
+ /** document-internationalization `languageField` (document locale field name). */
54
+ documentI18nLanguageField: string
55
+ /** internationalized-array locale key: field name (`'language'`) or `'_key'`. */
56
+ fieldI18nLanguageField: string
57
+ }
58
+
59
+ /**
60
+ * Shows how a language maps to Smartcat: `(fr)` when the codes match, or
61
+ * `(PL-PL → pl)` when the Sanity id differs from the Smartcat code.
62
+ */
63
+ function languageMappingLabel(language: SmartcatLanguage): string {
64
+ const code = language.smartcatLanguage || language.id
65
+ return code === language.id ? language.id : `${language.id} → ${code}`
66
+ }
67
+
68
+ /**
69
+ * Resolve an item's title the way the Studio does — by running the schema type's
70
+ * `preview.prepare` — so types without a literal `title` field (e.g. field-level
71
+ * localized docs) show the same label as in the structure tree, not 'Untitled'.
72
+ */
73
+ function DocTitle({doc}: {doc: ProjectItemRef['doc']}) {
74
+ const schema = useSchema()
75
+ const schemaType = doc ? schema.get(doc._type) : undefined
76
+ const preview = useValuePreview({
77
+ enabled: Boolean(doc && schemaType),
78
+ schemaType,
79
+ value: doc ? {_id: doc._id, _type: doc._type} : undefined,
80
+ })
81
+ // String-only: never fall back to the raw projected value, which is an
82
+ // internationalized-array object for field-level i18n types (renders as React #31).
83
+ const title = typeof preview.value?.title === 'string' ? preview.value.title : undefined
84
+ // Placeholder while the async preview resolves, so a real title isn't briefly mislabeled.
85
+ return <>{title ?? (preview.isLoading ? '…' : UNTITLED)}</>
86
+ }
87
+
88
+ /**
89
+ * Statuses during which a Function is actively running, so the project shouldn't
90
+ * be re-sent until it settles. `translating`/`sent`/`completed` are resting
91
+ * states (work is happening in Smartcat, or it's done) — re-syncing and importing
92
+ * are allowed there, so they are deliberately excluded.
93
+ */
94
+ const IN_FLIGHT: TranslationProjectStatus[] = ['queued', 'importing', 'downloaded']
95
+
96
+ /**
97
+ * Statuses during which importing is allowed: the Smartcat project exists and
98
+ * we're not mid export/import. Import pulls only the targets that are 100%
99
+ * complete, so it's useful any time translation is underway or finished.
100
+ */
101
+ const CAN_IMPORT: TranslationProjectStatus[] = ['sent', 'translating', 'completed']
102
+
103
+ const rotate = keyframes`
104
+ to { transform: rotate(360deg); }
105
+ `
106
+ /** SyncIcon that spins while a refresh is in flight (keeps the button label visible). */
107
+ const SpinningSyncIcon = styled(SyncIcon)<{$spinning?: boolean}>`
108
+ ${({$spinning}) =>
109
+ $spinning &&
110
+ css`
111
+ animation: ${rotate} 0.8s linear infinite;
112
+ `}
113
+ `
114
+
115
+ /** "Last update: x ago" — relative time, with the absolute timestamp on hover. */
116
+ function LastUpdate({date}: {date: string}) {
117
+ const relative = useRelativeTime(date, {useTemporalPhrase: true})
118
+ return (
119
+ <Text size={1} muted title={new Date(date).toLocaleString()}>
120
+ Last update: {relative}
121
+ </Text>
122
+ )
123
+ }
124
+
125
+ /** Opens the document in the Studio. */
126
+ function OpenInStudioButton({id, type}: {id: string; type: string}) {
127
+ const {onClick, href} = useIntentLink({intent: 'edit', params: {id, type}})
128
+ return (
129
+ <Button
130
+ as="a"
131
+ href={href}
132
+ onClick={onClick}
133
+ mode="bleed"
134
+ icon={LaunchIcon}
135
+ text="Open in Studio"
136
+ fontSize={0}
137
+ padding={2}
138
+ />
139
+ )
140
+ }
141
+
142
+ /** Read-only, monospace, full-height code view for the LocJSON modal. */
143
+ const CodeArea = styled.textarea`
144
+ width: 100%;
145
+ height: 70vh;
146
+ resize: none;
147
+ border: none;
148
+ outline: none;
149
+ padding: 0;
150
+ background: transparent;
151
+ color: inherit;
152
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
153
+ font-size: 12px;
154
+ line-height: 1.5;
155
+ white-space: pre;
156
+ tab-size: 2;
157
+ `
158
+
159
+ /**
160
+ * Builds the LocJSON the export would produce for an item — same field selection
161
+ * and serialize options as {@link prepareExport} — and shows it (pretty-printed)
162
+ * in a read-only modal, for inspecting exactly what gets sent to Smartcat.
163
+ */
164
+ function ViewLocjsonButton(props: {
165
+ docId: string
166
+ type: string
167
+ client: {fetch: (query: string, params?: Record<string, unknown>) => Promise<unknown>}
168
+ schema: {get: (typeName: string) => unknown}
169
+ sourceLanguage: string
170
+ documentI18nLanguageField: string
171
+ fieldI18nLanguageField: string
172
+ }) {
173
+ const {docId, type, client, schema, sourceLanguage, documentI18nLanguageField, fieldI18nLanguageField} = props
174
+ const [open, setOpen] = useState(false)
175
+ const [content, setContent] = useState<string | null>(null)
176
+ const [filename, setFilename] = useState<string | null>(null)
177
+ const [error, setError] = useState<string | null>(null)
178
+
179
+ const build = useCallback(async () => {
180
+ setOpen(true)
181
+ setContent(null)
182
+ setFilename(null)
183
+ setError(null)
184
+ try {
185
+ const doc = await client.fetch('*[_id == $id][0]', {id: docId})
186
+ if (!doc) throw new Error(`Document not found: ${docId}`)
187
+ const fields = getTranslatableFields(schema.get(type), {exclude: [documentI18nLanguageField]})
188
+ const locjson = serializeToLocjson(doc as never, fields, {
189
+ sourceLanguage,
190
+ fieldLanguageKey: fieldI18nLanguageField,
191
+ })
192
+ // Same name the export sends to Smartcat; the last path segment is the
193
+ // file's basename (the Smartcat "folder/" prefix can't be a local filename).
194
+ const smartcatName = buildLocjsonFilename(type, docId, resolveDocumentTitle(schema, doc as never))
195
+ setFilename(smartcatName.split('/').pop() || smartcatName)
196
+ setContent(JSON.stringify(locjson, null, 2))
197
+ } catch (err) {
198
+ setError(err instanceof Error ? err.message : String(err))
199
+ }
200
+ }, [client, schema, docId, type, sourceLanguage, documentI18nLanguageField, fieldI18nLanguageField])
201
+
202
+ const download = useCallback(() => {
203
+ if (content == null || !filename) return
204
+ const url = URL.createObjectURL(new Blob([content], {type: 'application/json'}))
205
+ const anchor = document.createElement('a')
206
+ anchor.href = url
207
+ anchor.download = filename
208
+ anchor.click()
209
+ URL.revokeObjectURL(url)
210
+ }, [content, filename])
211
+
212
+ return (
213
+ <>
214
+ <Button
215
+ mode="bleed"
216
+ icon={JsonIcon}
217
+ title="View LocJSON"
218
+ fontSize={0}
219
+ padding={2}
220
+ onClick={build}
221
+ />
222
+ {open && (
223
+ <Dialog
224
+ id={`locjson-${docId}`}
225
+ header={
226
+ <Flex align="center" gap={3}>
227
+ <Text weight="semibold">LocJSON · {type}</Text>
228
+ <Button
229
+ icon={DownloadIcon}
230
+ text="Download"
231
+ mode="ghost"
232
+ fontSize={1}
233
+ padding={2}
234
+ disabled={content == null}
235
+ onClick={download}
236
+ />
237
+ </Flex>
238
+ }
239
+ onClose={() => setOpen(false)}
240
+ width={3}
241
+ >
242
+ <Box padding={4}>
243
+ {error ? (
244
+ <Text size={1} muted>
245
+ {error}
246
+ </Text>
247
+ ) : content === null ? (
248
+ <Flex align="center" justify="center" padding={5}>
249
+ <Spinner muted />
250
+ </Flex>
251
+ ) : (
252
+ <CodeArea
253
+ readOnly
254
+ spellCheck={false}
255
+ value={content}
256
+ onFocus={(e) => e.currentTarget.select()}
257
+ />
258
+ )}
259
+ </Box>
260
+ </Dialog>
261
+ )}
262
+ </>
263
+ )
264
+ }
265
+
266
+ export function ProjectView({
267
+ projectId,
268
+ onBack,
269
+ languages,
270
+ sourceLanguage,
271
+ documentI18nLanguageField,
272
+ fieldI18nLanguageField,
273
+ }: ProjectViewProps) {
274
+ const client = useClient({apiVersion: API_VERSION})
275
+ const schema = useSchema()
276
+ const toast = useToast()
277
+ const [project, setProject] = useState<ProjectDetail | null | undefined>(undefined)
278
+ const [name, setName] = useState('')
279
+ const [busy, setBusy] = useState(false)
280
+ const [sending, setSending] = useState(false)
281
+ const [importing, setImporting] = useState(false)
282
+ const [cancelling, setCancelling] = useState(false)
283
+ const [refreshing, setRefreshing] = useState(false)
284
+ const [targets, setTargets] = useState<string[]>([])
285
+ const [confirmDelete, setConfirmDelete] = useState(false)
286
+ // Local-only: which items the editor has marked for deletion. Reset on reload;
287
+ // committed to Smartcat + the project only when the user re-syncs.
288
+ const [markedForDeletion, setMarkedForDeletion] = useState<Set<string>>(new Set())
289
+ // Session-only live trace of the current export/import run (never persisted).
290
+ const [logs, setLogs] = useState<LogLine[]>([])
291
+ // Controlled so a partial-failure export can auto-expand the panel.
292
+ const [logExpanded, setLogExpanded] = useState(false)
293
+ const appendLog = useCallback((line: LogLine) => setLogs((prev) => [...prev, line]), [])
294
+ // Merge a Function's server-side log (from the project doc) as an indented
295
+ // group under a header, mirroring how per-document client lines are grouped.
296
+ const appendServerLog = useCallback(
297
+ (raw: string | null | undefined, header: string) => {
298
+ const lines = parseFunctionLog(raw)
299
+ // Our Functions always emit ≥1 line by this point, so an empty log here
300
+ // means an outdated Function is deployed — surface that instead of nothing.
301
+ setLogs((prev) =>
302
+ lines.length === 0
303
+ ? [...prev, {level: 'info', message: `${header}: no server log returned — deploy the latest Functions to see server-side steps`}]
304
+ : [...prev, {level: 'info', message: header}, ...lines.map((l) => ({...l, indent: true}))],
305
+ )
306
+ },
307
+ [],
308
+ )
309
+ const seeded = useRef(false)
310
+
311
+ const toggleMark = useCallback((key: string) => {
312
+ setMarkedForDeletion((prev) => {
313
+ const next = new Set(prev)
314
+ if (next.has(key)) next.delete(key)
315
+ else next.add(key)
316
+ return next
317
+ })
318
+ }, [])
319
+
320
+ const load = useCallback(() => {
321
+ return client
322
+ .fetch<ProjectDetail | null>(PROJECT_DETAIL_QUERY, {id: projectId})
323
+ .then((res) => {
324
+ setProject(res)
325
+ if (res) setName(res.name)
326
+ // Seed the target-language selection once, so live updates don't clobber edits.
327
+ if (res && !seeded.current) {
328
+ setTargets(res.targetLanguages ?? [])
329
+ seeded.current = true
330
+ }
331
+ })
332
+ .catch((err) => toast.push({status: 'error', title: 'Failed to load project', description: String(err)}))
333
+ }, [client, projectId, toast])
334
+
335
+ useEffect(() => {
336
+ load()
337
+ const sub = client
338
+ .listen(PROJECT_DETAIL_QUERY, {id: projectId}, {visibility: 'query'})
339
+ .subscribe({next: () => load(), error: () => {}})
340
+ return () => sub.unsubscribe()
341
+ }, [client, projectId, load])
342
+
343
+ // When the import Function has downloaded translations (status "downloaded"),
344
+ // process them here in the browser: build locale variants + metadata links.
345
+ const processingDownload = useRef(false)
346
+ useEffect(() => {
347
+ // Each import cycle starts at "importing"; clear the guard there so the next
348
+ // "downloaded" is processed (otherwise a second import in the same session is
349
+ // blocked, leaving the project stuck at "downloaded").
350
+ if (project?.status === 'importing') processingDownload.current = false
351
+ if (project?.status !== 'downloaded' || processingDownload.current) return
352
+ processingDownload.current = true
353
+ // Targets the download Function deferred (size/time caps); after applying
354
+ // this batch we auto-continue the import so nobody has to click through.
355
+ const remaining = project.importRemaining ?? 0
356
+ appendLog({level: 'info', message: 'Translations downloaded — applying to documents'})
357
+ // The import Function's download log lands on the doc; show it before the
358
+ // browser-side apply log so the import reads top-to-bottom.
359
+ appendServerLog(project.functionLog, 'Import function')
360
+ applyImportedTranslations({
361
+ client,
362
+ schema,
363
+ projectId,
364
+ documentLanguageField: documentI18nLanguageField,
365
+ fieldLanguageKey: fieldI18nLanguageField,
366
+ onLog: appendLog,
367
+ })
368
+ .then(async ({imported, status}) => {
369
+ // Auto-continue only while each cycle makes progress — a cycle that
370
+ // applied nothing would defer the same targets forever, so it stops
371
+ // and leaves the button to the user.
372
+ if (remaining > 0 && imported > 0) {
373
+ appendLog({level: 'success', message: `Imported ${imported} localized version(s)`})
374
+ appendLog({level: 'info', message: `${remaining} target(s) still on Smartcat — continuing import automatically`})
375
+ processingDownload.current = false
376
+ await client.patch(projectId).set({status: 'importing', lastError: null}).commit()
377
+ return
378
+ }
379
+ const description =
380
+ status === 'completed'
381
+ ? 'All targets are fully translated — project completed.'
382
+ : 'Some targets are still translating — import again later to pull the rest.'
383
+ appendLog({level: 'success', message: `Imported ${imported} localized version(s)`})
384
+ appendLog({level: status === 'completed' ? 'success' : 'info', message: description})
385
+ toast.push({status: 'success', title: `Imported ${imported} localized version(s)`, description})
386
+ })
387
+ .catch((err) => {
388
+ // Leave the guard set so we don't auto-retry in a loop (status stays
389
+ // "downloaded"); a fresh import (status "importing") clears it to retry.
390
+ appendLog({level: 'error', message: `Failed to apply translations: ${String(err)}`})
391
+ toast.push({status: 'error', title: 'Failed to apply translations', description: String(err)})
392
+ })
393
+ }, [project?.status, client, schema, projectId, toast, documentI18nLanguageField, fieldI18nLanguageField, appendLog, appendServerLog])
394
+
395
+ // After handing the outbox to the export Function, log its response (observed
396
+ // via listen()): status flips to "sent" (+ project URL) on success, or "error"
397
+ // (+ lastError) on failure. Guarded by a ref so it only fires for a send we
398
+ // started this session, not on initial load of an already-sent project.
399
+ const exportInFlight = useRef(false)
400
+ useEffect(() => {
401
+ if (!exportInFlight.current) return
402
+ if (project?.status === 'sent') {
403
+ appendServerLog(project.functionLog, 'Export function')
404
+ appendLog({level: 'success', message: 'Sent to Smartcat'})
405
+ if (project.smartcatProjectUrl) appendLog({level: 'info', message: `Smartcat project: ${project.smartcatProjectUrl}`})
406
+ // Partial failure: the sync completed (status "sent") but some documents
407
+ // failed to upload/update. Flag it and open the log so the errors are seen.
408
+ if (parseFunctionLog(project.functionLog).some((l) => l.level === 'error')) {
409
+ toast.push({status: 'warning', title: 'There were errors sending content into Smartcat — check the log'})
410
+ setLogExpanded(true)
411
+ }
412
+ exportInFlight.current = false
413
+ } else if (project?.status === 'error') {
414
+ appendServerLog(project.functionLog, 'Export function')
415
+ appendLog({level: 'error', message: `Export failed: ${project.lastError ?? 'unknown error'}`})
416
+ exportInFlight.current = false
417
+ }
418
+ }, [project?.status, project?.smartcatProjectUrl, project?.lastError, project?.functionLog, appendLog, appendServerLog, toast])
419
+
420
+ // Refresh per-document progress from Smartcat. The browser only bumps the
421
+ // `progressRequestedAt` trigger; the smartcat-progress Function reacts (it
422
+ // holds the API key), fetches the Smartcat project, and writes the stage
423
+ // percentages back — which `listen()` above re-renders.
424
+ const requestProgress = useCallback(() => {
425
+ return client.patch(projectId).set({progressRequestedAt: new Date().toISOString()}).commit()
426
+ }, [client, projectId])
427
+
428
+ // Track the latest synced timestamp in a ref so a refresh can detect when
429
+ // fresh progress has actually arrived. We can't tie the spinner to the patch
430
+ // commit: on a warm client it resolves in a few ms (before paint), so the
431
+ // spinner was only visible after a cold hard-reload.
432
+ const syncedAtRef = useRef<string | null | undefined>(undefined)
433
+ useEffect(() => {
434
+ syncedAtRef.current = project?.progressSyncedAt
435
+ }, [project?.progressSyncedAt])
436
+
437
+ const refreshBaseline = useRef<string | null | undefined>(undefined)
438
+ const refreshTimeout = useRef<ReturnType<typeof setTimeout> | null>(null)
439
+ useEffect(
440
+ () => () => {
441
+ if (refreshTimeout.current) clearTimeout(refreshTimeout.current)
442
+ },
443
+ [],
444
+ )
445
+
446
+ const handleRefreshProgress = useCallback(async () => {
447
+ // Spin until the progress Function writes a new progressSyncedAt (cleared by
448
+ // the effect below), with a safety timeout in case nothing comes back.
449
+ refreshBaseline.current = syncedAtRef.current
450
+ setRefreshing(true)
451
+ if (refreshTimeout.current) clearTimeout(refreshTimeout.current)
452
+ refreshTimeout.current = setTimeout(() => setRefreshing(false), 30000)
453
+ try {
454
+ await requestProgress()
455
+ } catch (err) {
456
+ setRefreshing(false)
457
+ if (refreshTimeout.current) clearTimeout(refreshTimeout.current)
458
+ toast.push({status: 'error', title: 'Failed to refresh progress', description: String(err)})
459
+ }
460
+ }, [requestProgress, toast])
461
+
462
+ // Stop spinning once fresh progress has landed (progressSyncedAt changed).
463
+ useEffect(() => {
464
+ if (!refreshing) return
465
+ if (project?.progressSyncedAt !== refreshBaseline.current) {
466
+ setRefreshing(false)
467
+ if (refreshTimeout.current) clearTimeout(refreshTimeout.current)
468
+ // The progress Function stamps progressSyncedAt on failure too (and sets
469
+ // lastError). When it did, surface its log so a timeout/error is visible
470
+ // instead of the refresh just quietly not updating.
471
+ if (project?.lastError) {
472
+ appendServerLog(project.functionLog, 'Progress function')
473
+ appendLog({level: 'error', message: `Progress refresh failed: ${project.lastError}`})
474
+ toast.push({status: 'error', title: 'Failed to refresh progress', description: project.lastError})
475
+ setLogExpanded(true)
476
+ }
477
+ }
478
+ }, [project?.progressSyncedAt, project?.lastError, project?.functionLog, refreshing, appendServerLog, appendLog, toast])
479
+
480
+ // Auto-refresh once on page load, as soon as a Smartcat project exists. Runs
481
+ // the same handler as the button, so the loading state matches.
482
+ const progressRequested = useRef(false)
483
+ useEffect(() => {
484
+ if (progressRequested.current || !project?.smartcatProjectId) return
485
+ progressRequested.current = true
486
+ handleRefreshProgress()
487
+ }, [project?.smartcatProjectId, handleRefreshProgress])
488
+
489
+ const handleRename = useCallback(async () => {
490
+ const trimmed = name.trim()
491
+ if (!trimmed || trimmed === project?.name) return
492
+ setBusy(true)
493
+ try {
494
+ await client.patch(projectId).set({name: trimmed}).commit()
495
+ toast.push({status: 'success', title: 'Renamed project'})
496
+ } catch (err) {
497
+ toast.push({status: 'error', title: 'Failed to rename', description: String(err)})
498
+ } finally {
499
+ setBusy(false)
500
+ }
501
+ }, [name, project, client, projectId, toast])
502
+
503
+ const handleDelete = useCallback(async () => {
504
+ setBusy(true)
505
+ try {
506
+ await client.delete(projectId)
507
+ toast.push({status: 'success', title: 'Deleted project'})
508
+ onBack()
509
+ } catch (err) {
510
+ toast.push({status: 'error', title: 'Failed to delete', description: String(err)})
511
+ setBusy(false)
512
+ }
513
+ }, [client, projectId, toast, onBack])
514
+
515
+ const toggleTarget = useCallback((id: string) => {
516
+ setTargets((prev) => (prev.includes(id) ? prev.filter((t) => t !== id) : [...prev, id]))
517
+ }, [])
518
+
519
+ const handleImport = useCallback(async () => {
520
+ setImporting(true)
521
+ try {
522
+ // Clear the download-processing guard up front (race-free), so this import's
523
+ // "downloaded" state is processed even if we never observe the "importing"
524
+ // transition via listen().
525
+ processingDownload.current = false
526
+ await client.patch(projectId).set({status: 'importing', lastError: null}).commit()
527
+ // Clear here (not when the download lands) so these lines lead the import log.
528
+ setLogs([])
529
+ appendLog({level: 'info', message: 'Importing from Smartcat'})
530
+ appendLog({level: 'info', message: 'The import function will download translations and create localized versions.'})
531
+ toast.push({
532
+ status: 'success',
533
+ title: 'Importing from Smartcat',
534
+ description: 'The import function will download translations and create localized versions.',
535
+ })
536
+ } catch (err) {
537
+ toast.push({status: 'error', title: 'Failed to start import', description: String(err)})
538
+ } finally {
539
+ setImporting(false)
540
+ }
541
+ }, [client, projectId, toast, appendLog])
542
+
543
+ const handleCancel = useCallback(async () => {
544
+ setCancelling(true)
545
+ try {
546
+ // Reset to draft so the project can be edited and re-sent. This is a local
547
+ // undo — it does not delete an already-created Smartcat project.
548
+ await client.patch(projectId).set({status: 'draft'}).commit()
549
+ toast.push({status: 'success', title: 'Cancelled — project reset to draft'})
550
+ } catch (err) {
551
+ toast.push({status: 'error', title: 'Failed to cancel', description: String(err)})
552
+ } finally {
553
+ setCancelling(false)
554
+ }
555
+ }, [client, projectId, toast])
556
+
557
+ const handleSend = useCallback(async () => {
558
+ if (targets.length === 0) {
559
+ toast.push({status: 'warning', title: 'Select at least one target language'})
560
+ return
561
+ }
562
+ setSending(true)
563
+ setLogs([])
564
+ try {
565
+ // Translate marked items into deletion instructions for the export Function:
566
+ // pair each with one of its Smartcat document ids (one id deletes the whole doc).
567
+ const deletions: PendingDeletion[] = []
568
+ for (const item of project?.items ?? []) {
569
+ if (!markedForDeletion.has(item._key) || !item.doc?._id) continue
570
+ const dp = (project?.progress ?? []).find((p) => p.sourceDocId === item.doc!._id)
571
+ // Any one target id deletes the whole document, so pick the first target
572
+ // that actually has one (matches the `isLinked` check in the item list —
573
+ // targets[0] may not be the one carrying the id).
574
+ const smartcatDocumentId = dp?.targets?.find((t) => t.smartcatDocumentId)?.smartcatDocumentId
575
+ deletions.push({
576
+ itemKey: item._key,
577
+ sourceDocId: item.doc._id,
578
+ smartcatDocumentId,
579
+ })
580
+ }
581
+
582
+ // Serialize content to LocJSON here in the Studio (real schema + DOM),
583
+ // then queue it; the thin export Function uploads, updates and deletes.
584
+ const {prepared, deleting} = await prepareExport({
585
+ client,
586
+ schema,
587
+ projectId,
588
+ targetLanguages: targets,
589
+ sourceLanguage,
590
+ languages,
591
+ exclude: [documentI18nLanguageField],
592
+ fieldLanguageKey: fieldI18nLanguageField,
593
+ deletions,
594
+ onLog: appendLog,
595
+ })
596
+ setMarkedForDeletion(new Set())
597
+ // Hand-off: the outbox is queued; the export Function now uploads it. Arm
598
+ // the response watcher so its result ("sent"/"error") lands in the log.
599
+ exportInFlight.current = true
600
+ appendLog({level: 'info', message: 'Uploading to Smartcat via the export function…'})
601
+ toast.push({
602
+ status: 'success',
603
+ title: 'Queued for Smartcat',
604
+ description:
605
+ `Prepared ${prepared} file(s)` +
606
+ (deleting ? ` and ${deleting} for deletion` : '') +
607
+ '; the export function will sync them.',
608
+ })
609
+ } catch (err) {
610
+ appendLog({level: 'error', message: `Failed to queue: ${String(err)}`})
611
+ toast.push({status: 'error', title: 'Failed to queue', description: String(err)})
612
+ } finally {
613
+ setSending(false)
614
+ }
615
+ }, [targets, client, projectId, toast, schema, sourceLanguage, languages, documentI18nLanguageField, fieldI18nLanguageField, project, markedForDeletion, appendLog])
616
+
617
+ const handleRemoveItem = useCallback(
618
+ async (item: ProjectItemRef) => {
619
+ try {
620
+ await client.patch(projectId).unset([`items[_key=="${item._key}"]`]).commit()
621
+ toast.push({status: 'success', title: 'Removed item'})
622
+ } catch (err) {
623
+ toast.push({status: 'error', title: 'Failed to remove item', description: String(err)})
624
+ }
625
+ },
626
+ [client, projectId, toast],
627
+ )
628
+
629
+ if (project === undefined) {
630
+ return (
631
+ <Flex align="center" justify="center" padding={5}>
632
+ <Spinner muted />
633
+ </Flex>
634
+ )
635
+ }
636
+
637
+ if (project === null) {
638
+ return (
639
+ <Stack space={4}>
640
+ <Button icon={ArrowLeftIcon} text="Back" mode="bleed" onClick={onBack} />
641
+ <Card padding={5} radius={2} tone="critical" border>
642
+ <Text>This project no longer exists.</Text>
643
+ </Card>
644
+ </Stack>
645
+ )
646
+ }
647
+
648
+ const items = project.items ?? []
649
+ const nameChanged = name.trim() !== project.name && name.trim().length > 0
650
+ // Smartcat doesn't allow non-destructive target-language changes after a project
651
+ // exists, so lock the picker once the project has been sent.
652
+ const languagesLocked = Boolean(project.smartcatProjectId)
653
+
654
+ const progress = project.progress ?? []
655
+ const progressByDoc = new Map<string, DocProgress>()
656
+ for (const p of progress) {
657
+ if (p.sourceDocId) progressByDoc.set(p.sourceDocId, p)
658
+ }
659
+ const markedCount = items.filter((i) => markedForDeletion.has(i._key)).length
660
+
661
+ // Localization mode is per-type; compute once per distinct type for the labels.
662
+ const modeByType = new Map<string, LocalizationMode>()
663
+ for (const item of items) {
664
+ const t = item.doc?._type
665
+ if (t && !modeByType.has(t)) modeByType.set(t, localizationMode(schema.get(t)))
666
+ }
667
+
668
+ return (
669
+ <Stack space={5}>
670
+ <Flex align="center" gap={2}>
671
+ <Button icon={ArrowLeftIcon} text="Projects" mode="bleed" onClick={onBack} />
672
+ <Box flex={1} />
673
+ <StatusBadge status={project.status} />
674
+ <Button
675
+ icon={TrashIcon}
676
+ title="Delete project…"
677
+ tone="critical"
678
+ mode="bleed"
679
+ fontSize={1}
680
+ padding={2}
681
+ onClick={() => setConfirmDelete(true)}
682
+ disabled={busy}
683
+ />
684
+ </Flex>
685
+
686
+ <Card padding={4} radius={2} shadow={1}>
687
+ <Stack space={4}>
688
+ <Flex gap={2} align="flex-end">
689
+ <Box flex={1}>
690
+ <Stack space={2}>
691
+ <Text size={1} weight="semibold" muted>
692
+ Project name
693
+ </Text>
694
+ <TextInput
695
+ value={name}
696
+ onChange={(e) => setName(e.currentTarget.value)}
697
+ onKeyDown={(e) => {
698
+ if (e.key === 'Enter') handleRename()
699
+ }}
700
+ disabled={busy}
701
+ />
702
+ </Stack>
703
+ </Box>
704
+ <Button text="Save" onClick={handleRename} disabled={!nameChanged || busy} loading={busy} />
705
+ </Flex>
706
+
707
+ <Text size={1} muted>
708
+ Source: <strong>{languageTitle(languages, project.sourceLanguage || sourceLanguage)}</strong>
709
+ </Text>
710
+
711
+ <Stack space={2}>
712
+ <Text size={1} muted>
713
+ Target languages
714
+ </Text>
715
+ <Flex gap={6} align="center">
716
+ <Stack space={3} padding={3}>
717
+ {languages
718
+ .filter((l) => l.id !== (project.sourceLanguage || sourceLanguage))
719
+ .map((l) => {
720
+ const selected = targets.includes(l.id)
721
+ const disabled = sending || languagesLocked
722
+ return (
723
+ <Flex key={l.id} align="center" gap={2} as="label" style={{cursor: disabled ? 'default' : 'pointer'}}>
724
+ <Checkbox
725
+ checked={selected}
726
+ disabled={disabled}
727
+ onChange={() => toggleTarget(l.id)}
728
+ />
729
+ <Text size={1}>
730
+ {l.title}
731
+ <span
732
+ title={`Sanity language code: '${l.id}'\nSmartcat language code: '${l.smartcatLanguage || l.id}'`}
733
+ style={{color: 'var(--card-muted-fg-color)', opacity: 0.6, marginLeft: '1em'}}
734
+ >
735
+ {languageMappingLabel(l)}
736
+ </span>
737
+ </Text>
738
+ </Flex>
739
+ )
740
+ })}
741
+ </Stack>
742
+ {project.smartcatProjectUrl ? (
743
+ <Box flex={1} style={{maxWidth: 500}}>
744
+ <Stack space={4}>
745
+ <Text size={1} muted>
746
+ Smartcat project:{' '}
747
+ <a
748
+ href={project.smartcatProjectUrl}
749
+ target="_blank"
750
+ rel="noopener noreferrer"
751
+ title="Open project in Smartcat"
752
+ >
753
+ {project.name}
754
+ </a>
755
+ </Text>
756
+ {languagesLocked && (
757
+ <Text size={1} muted>
758
+ Target languages are locked once the project has been sent to Smartcat.
759
+ </Text>
760
+ )}
761
+ </Stack>
762
+ </Box>
763
+ ) : (
764
+ targets.length === 0 && (
765
+ <Box flex={1} style={{maxWidth: 500}}>
766
+ <Text size={1} muted>
767
+ Select one or more languages to translate your content into before you can
768
+ send it to Smartcat for translation.
769
+ </Text>
770
+ </Box>
771
+ )
772
+ )}
773
+ </Flex>
774
+ </Stack>
775
+ </Stack>
776
+ </Card>
777
+
778
+ {project.lastError && (
779
+ <Card padding={3} radius={2} tone="critical" border>
780
+ <Text size={1}>{project.lastError}</Text>
781
+ </Card>
782
+ )}
783
+
784
+ <Flex align="center" gap={3} wrap="wrap">
785
+ <Button
786
+ icon={PublishIcon}
787
+ text="Send to Smartcat"
788
+ // Primary only when it's actually actionable (unsent, with items to
789
+ // send); otherwise it's a secondary action (ghost, like Import) and
790
+ // "Import translations" becomes primary.
791
+ mode={!languagesLocked && items.length > 0 ? undefined : 'ghost'}
792
+ tone={!languagesLocked && items.length > 0 ? 'primary' : undefined}
793
+ onClick={handleSend}
794
+ loading={sending}
795
+ disabled={sending || items.length === 0 || targets.length === 0 || IN_FLIGHT.includes(project.status)}
796
+ />
797
+ <Button
798
+ icon={DownloadIcon}
799
+ text="Import translations"
800
+ mode={languagesLocked ? undefined : 'ghost'}
801
+ tone={languagesLocked ? 'primary' : undefined}
802
+ onClick={handleImport}
803
+ loading={importing}
804
+ disabled={importing || !project.smartcatProjectId || !CAN_IMPORT.includes(project.status)}
805
+ />
806
+ {project.smartcatProjectId && (
807
+ <Button
808
+ icon={<SpinningSyncIcon $spinning={refreshing} />}
809
+ text="Refresh progress"
810
+ mode="bleed"
811
+ onClick={handleRefreshProgress}
812
+ disabled={refreshing}
813
+ />
814
+ )}
815
+ {project.progressSyncedAt && <LastUpdate date={project.progressSyncedAt} />}
816
+ {markedCount > 0 && (
817
+ <Text size={1} muted>
818
+ {markedCount} marked for deletion — applied on re-sync
819
+ </Text>
820
+ )}
821
+ {IN_FLIGHT.includes(project.status) && (
822
+ <Inline space={2}>
823
+ <Text size={1} muted>
824
+ Project is <strong>{statusLabel(project.status)}</strong> — wait for it to finish before re-sending.
825
+ </Text>
826
+ <Button
827
+ icon={CloseIcon}
828
+ text="Cancel"
829
+ mode="bleed"
830
+ tone="critical"
831
+ fontSize={1}
832
+ padding={2}
833
+ onClick={handleCancel}
834
+ loading={cancelling}
835
+ disabled={cancelling}
836
+ />
837
+ </Inline>
838
+ )}
839
+ </Flex>
840
+
841
+ <LogPanel logs={logs} expanded={logExpanded} onExpandedChange={setLogExpanded} />
842
+
843
+ <Stack space={3}>
844
+ <Flex align="center" gap={2}>
845
+ <Heading size={1}>
846
+ {items.length} {items.length === 1 ? 'Item' : 'Items'}
847
+ </Heading>
848
+ </Flex>
849
+
850
+ {items.length > 0 && (
851
+ <Stack space={2}>
852
+ {items.map((item) => {
853
+ const docProgress = item.doc ? progressByDoc.get(item.doc._id) : undefined
854
+ // Linked = the item already has document(s) in Smartcat.
855
+ const isLinked = Boolean(docProgress?.targets?.some((t) => t.smartcatDocumentId))
856
+ const isMarked = markedForDeletion.has(item._key)
857
+ return (
858
+ <Card
859
+ key={item._key}
860
+ padding={3}
861
+ radius={2}
862
+ shadow={1}
863
+ tone={isMarked ? 'critical' : undefined}
864
+ >
865
+ <Stack space={3}>
866
+ <Flex align="center" gap={3}>
867
+ <Flex align="center" gap={2}>
868
+ <Text weight="medium">
869
+ <span style={isMarked ? {textDecoration: 'line-through'} : undefined}>
870
+ <DocTitle doc={item.doc} />
871
+ </span>
872
+ </Text>
873
+ {item.doc?._type && <Badge tone="primary">{item.doc._type}</Badge>}
874
+ {item.doc?._type && modeByType.has(item.doc._type) && (
875
+ <Badge
876
+ tone="default"
877
+ fontSize={0}
878
+ title={
879
+ modeByType.get(item.doc._type) === 'field'
880
+ ? 'This item is translated in field mode; translations will be stored as language variants of each field'
881
+ : 'This item is translated in document mode; translations will be stored as linked documents'
882
+ }
883
+ >
884
+ {modeByType.get(item.doc._type) === 'field' ? 'fields' : 'document'}
885
+ </Badge>
886
+ )}
887
+ {item.doc && <OpenInStudioButton id={item.doc._id} type={item.doc._type} />}
888
+ {item.doc && (
889
+ <ViewLocjsonButton
890
+ docId={item.doc._id}
891
+ type={item.doc._type}
892
+ client={client}
893
+ schema={schema}
894
+ sourceLanguage={sourceLanguage}
895
+ documentI18nLanguageField={documentI18nLanguageField}
896
+ fieldI18nLanguageField={fieldI18nLanguageField}
897
+ />
898
+ )}
899
+ </Flex>
900
+ <Box flex={1} />
901
+ {isLinked ? (
902
+ <Button
903
+ mode={isMarked ? 'default' : 'bleed'}
904
+ tone="critical"
905
+ icon={RemoveCircleIcon}
906
+ title={isMarked ? 'Unmark for deletion' : 'Mark for deletion'}
907
+ padding={2}
908
+ onClick={() => toggleMark(item._key)}
909
+ />
910
+ ) : (
911
+ <Button
912
+ mode="bleed"
913
+ tone="critical"
914
+ icon={TrashIcon}
915
+ title="Remove from project"
916
+ onClick={() => handleRemoveItem(item)}
917
+ />
918
+ )}
919
+ </Flex>
920
+ {docProgress && (
921
+ <ItemProgress
922
+ doc={docProgress}
923
+ languages={languages}
924
+ smartcatProjectUrl={project.smartcatProjectUrl}
925
+ variants={
926
+ item.doc
927
+ ? {
928
+ type: item.doc._type,
929
+ byLanguage: Object.fromEntries(
930
+ (item.translations ?? []).map((t) => [t.language, t.id]),
931
+ ),
932
+ }
933
+ : undefined
934
+ }
935
+ />
936
+ )}
937
+ </Stack>
938
+ </Card>
939
+ )
940
+ })}
941
+ </Stack>
942
+ )}
943
+
944
+ <Card padding={4} radius={2} tone="transparent" border>
945
+ <Text muted align="center" size={1}>
946
+ Add content from a document’s “⋯ → Add to translation project” menu.
947
+ </Text>
948
+ </Card>
949
+ </Stack>
950
+
951
+ {confirmDelete && (
952
+ <Dialog
953
+ id="confirm-delete"
954
+ header="Delete translation project?"
955
+ onClose={() => setConfirmDelete(false)}
956
+ width={1}
957
+ >
958
+ <Box padding={4}>
959
+ <Stack space={4}>
960
+ <Text>
961
+ Delete <strong>{project.name}</strong>? This removes the project only — the referenced
962
+ content documents are not deleted.
963
+ {project.smartcatProjectId && ' The Smartcat project is not deleted either and will remain in your Smartcat workspace.'}
964
+ </Text>
965
+ <Flex justify="flex-end" gap={2}>
966
+ <Button mode="bleed" text="Cancel" onClick={() => setConfirmDelete(false)} disabled={busy} />
967
+ <Button tone="critical" text="Delete" icon={TrashIcon} onClick={handleDelete} loading={busy} />
968
+ </Flex>
969
+ </Stack>
970
+ </Box>
971
+ </Dialog>
972
+ )}
973
+ </Stack>
974
+ )
975
+ }