@smartcat/sanity-plugin 1.0.0 → 1.1.0

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