@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.
- package/README.md +294 -0
- package/dist/_chunks-cjs/constants.cjs +13 -0
- package/dist/_chunks-cjs/constants.cjs.map +1 -0
- package/dist/_chunks-cjs/index.cjs +168 -0
- package/dist/_chunks-cjs/index.cjs.map +1 -0
- package/dist/_chunks-cjs/index2.cjs +311 -0
- package/dist/_chunks-cjs/index2.cjs.map +1 -0
- package/dist/_chunks-cjs/workflow.cjs +16 -0
- package/dist/_chunks-cjs/workflow.cjs.map +1 -0
- package/dist/_chunks-es/constants.js +14 -0
- package/dist/_chunks-es/constants.js.map +1 -0
- package/dist/_chunks-es/index.js +169 -0
- package/dist/_chunks-es/index.js.map +1 -0
- package/dist/_chunks-es/index2.js +313 -0
- package/dist/_chunks-es/index2.js.map +1 -0
- package/dist/_chunks-es/workflow.js +17 -0
- package/dist/_chunks-es/workflow.js.map +1 -0
- package/dist/export.cjs +160 -0
- package/dist/export.cjs.map +1 -0
- package/dist/export.d.cts +162 -0
- package/dist/export.d.ts +162 -0
- package/dist/export.js +162 -0
- package/dist/export.js.map +1 -0
- package/dist/import.cjs +169 -0
- package/dist/import.cjs.map +1 -0
- package/dist/import.d.cts +155 -0
- package/dist/import.d.ts +155 -0
- package/dist/import.js +169 -0
- package/dist/import.js.map +1 -0
- package/dist/index.cjs +2103 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +122 -0
- package/dist/index.d.ts +122 -0
- package/dist/index.js +2110 -0
- package/dist/index.js.map +1 -0
- package/dist/locjson.cjs +12 -0
- package/dist/locjson.cjs.map +1 -0
- package/dist/locjson.d.cts +287 -0
- package/dist/locjson.d.ts +287 -0
- package/dist/locjson.js +12 -0
- package/dist/locjson.js.map +1 -0
- package/dist/progress.cjs +16 -0
- package/dist/progress.cjs.map +1 -0
- package/dist/progress.d.cts +262 -0
- package/dist/progress.d.ts +262 -0
- package/dist/progress.js +16 -0
- package/dist/progress.js.map +1 -0
- package/dist/smartcat.cjs +287 -0
- package/dist/smartcat.cjs.map +1 -0
- package/dist/smartcat.d.cts +234 -0
- package/dist/smartcat.d.ts +234 -0
- package/dist/smartcat.js +287 -0
- package/dist/smartcat.js.map +1 -0
- package/dist/templates.cjs +12 -0
- package/dist/templates.cjs.map +1 -0
- package/dist/templates.d.cts +52 -0
- package/dist/templates.d.ts +52 -0
- package/dist/templates.js +12 -0
- package/dist/templates.js.map +1 -0
- package/package.json +101 -15
- package/src/actions/AddToProjectAction.tsx +258 -0
- package/src/export/export.test.ts +537 -0
- package/src/export/index.ts +393 -0
- package/src/form/TranslationStatusInput.tsx +212 -0
- package/src/import/import.test.ts +346 -0
- package/src/import/index.ts +418 -0
- package/src/index.ts +63 -0
- package/src/lib/constants.ts +23 -0
- package/src/lib/documentTitle.test.ts +43 -0
- package/src/lib/documentTitle.ts +30 -0
- package/src/lib/languageMap.test.ts +56 -0
- package/src/lib/languageMap.ts +71 -0
- package/src/lib/linkedDocuments.test.ts +56 -0
- package/src/lib/linkedDocuments.ts +100 -0
- package/src/lib/locjson/deserialize.ts +60 -0
- package/src/lib/locjson/fields.ts +355 -0
- package/src/lib/locjson/filename.ts +27 -0
- package/src/lib/locjson/index.ts +10 -0
- package/src/lib/locjson/locjson.test.ts +545 -0
- package/src/lib/locjson/paths.test.ts +41 -0
- package/src/lib/locjson/paths.ts +73 -0
- package/src/lib/locjson/portableText.ts +98 -0
- package/src/lib/locjson/serialize.ts +124 -0
- package/src/lib/locjson/types.ts +106 -0
- package/src/lib/log.ts +33 -0
- package/src/lib/projectItems.ts +17 -0
- package/src/lib/resolveConfig.test.ts +62 -0
- package/src/lib/resolveConfig.ts +47 -0
- package/src/lib/workflow.test.ts +37 -0
- package/src/lib/workflow.ts +48 -0
- package/src/lib/zip.fixtures.ts +75 -0
- package/src/lib/zip.test.ts +110 -0
- package/src/lib/zip.ts +164 -0
- package/src/progress/core.ts +387 -0
- package/src/progress/index.ts +7 -0
- package/src/progress/progress.test.ts +268 -0
- package/src/schema/settings.ts +55 -0
- package/src/schema/translatableOptions.ts +25 -0
- package/src/schema/translationProject.ts +264 -0
- package/src/smartcat/client.test.ts +222 -0
- package/src/smartcat/client.ts +351 -0
- package/src/smartcat/index.ts +10 -0
- package/src/smartcat/types.ts +99 -0
- package/src/templates/core.ts +55 -0
- package/src/templates/index.ts +5 -0
- package/src/templates/templates.test.ts +50 -0
- package/src/tool/Dashboard.tsx +44 -0
- package/src/tool/ItemProgress.tsx +176 -0
- package/src/tool/LogPanel.tsx +207 -0
- package/src/tool/ProjectView.tsx +975 -0
- package/src/tool/ProjectsView.tsx +208 -0
- package/src/tool/StatusBadge.tsx +33 -0
- package/src/tool/StudioInit.tsx +42 -0
- package/src/tool/WorkflowSelect.tsx +41 -0
- package/src/tool/data.ts +79 -0
- package/src/tool/index.ts +19 -0
- package/src/tool/processing.test.ts +623 -0
- package/src/tool/processing.ts +766 -0
- package/src/tool/useTemplates.ts +84 -0
- package/src/tool/useWorkflowSelection.ts +65 -0
- 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
|
+
}
|