@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,52 @@
1
+ /**
2
+ * Fetches templates from Smartcat and writes them (sorted by name) to the
3
+ * settings singleton, stamping `templatesSyncedAt`. Always writes the timestamp —
4
+ * even for an empty list — so the cache reads as initialized.
5
+ */
6
+ export declare function runTemplatesSync(
7
+ options: RunTemplatesSyncOptions,
8
+ ): Promise<{
9
+ count: number;
10
+ }>;
11
+
12
+ export declare interface RunTemplatesSyncOptions {
13
+ sanity: SanityLikeClient;
14
+ smartcat: SmartcatTemplatesClient;
15
+ /** `_id` of the settings singleton (defaults to the fixed id). */
16
+ settingsId?: string;
17
+ now?: () => string;
18
+ }
19
+
20
+ /**
21
+ * Refreshes the cached Smartcat project-template list onto the `smartcat.settings`
22
+ * singleton. Run by the thin `smartcat-templates` Function when the browser bumps
23
+ * `templatesRequestedAt` (on Studio init or when a create dialog opens).
24
+ *
25
+ * Dependency-free (no DOM, no @sanity/* runtime) so it bundles into the Function.
26
+ */
27
+ /** Minimal structural subset of @sanity/client used by the templates refresh. */
28
+ export declare interface SanityLikeClient {
29
+ patch(id: string): {
30
+ set(attrs: Record<string, unknown>): {
31
+ commit(): Promise<unknown>;
32
+ };
33
+ };
34
+ }
35
+
36
+ /** A Smartcat project template (from `GET /template`). */
37
+ declare interface SmartcatTemplate {
38
+ id: string;
39
+ name: string;
40
+ }
41
+
42
+ /** Structural subset of SmartcatClient the templates refresh needs. */
43
+ export declare interface SmartcatTemplatesClient {
44
+ getTemplates(): Promise<SmartcatTemplatesResponse>;
45
+ }
46
+
47
+ /** Response shape of `GET /template`. */
48
+ declare interface SmartcatTemplatesResponse {
49
+ templates: SmartcatTemplate[];
50
+ }
51
+
52
+ export {};
@@ -0,0 +1,12 @@
1
+ import { SETTINGS_DOC_ID } from "./_chunks-es/constants.js";
2
+ function keyify(value) {
3
+ return value.replace(/[^a-zA-Z0-9_-]/g, "-") || "x";
4
+ }
5
+ async function runTemplatesSync(options) {
6
+ const { sanity, smartcat, settingsId = SETTINGS_DOC_ID, now = () => (/* @__PURE__ */ new Date()).toISOString() } = options, { templates } = await smartcat.getTemplates(), value = [...templates ?? []].sort((a, b) => a.name.localeCompare(b.name)).map((t) => ({ _key: keyify(t.id), id: t.id, name: t.name }));
7
+ return await sanity.patch(settingsId).set({ templates: value, templatesSyncedAt: now() }).commit(), { count: value.length };
8
+ }
9
+ export {
10
+ runTemplatesSync
11
+ };
12
+ //# sourceMappingURL=templates.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"templates.js","sources":["../src/templates/core.ts"],"sourcesContent":["import type {SmartcatTemplatesResponse} from '../smartcat/types'\nimport {SETTINGS_DOC_ID} from '../lib/constants'\n\n/**\n * Refreshes the cached Smartcat project-template list onto the `smartcat.settings`\n * singleton. Run by the thin `smartcat-templates` Function when the browser bumps\n * `templatesRequestedAt` (on Studio init or when a create dialog opens).\n *\n * Dependency-free (no DOM, no @sanity/* runtime) so it bundles into the Function.\n */\n\n/** Minimal structural subset of @sanity/client used by the templates refresh. */\nexport interface SanityLikeClient {\n patch(id: string): {\n set(attrs: Record<string, unknown>): {commit(): Promise<unknown>}\n }\n}\n\n/** Structural subset of SmartcatClient the templates refresh needs. */\nexport interface SmartcatTemplatesClient {\n getTemplates(): Promise<SmartcatTemplatesResponse>\n}\n\nexport interface RunTemplatesSyncOptions {\n sanity: SanityLikeClient\n smartcat: SmartcatTemplatesClient\n /** `_id` of the settings singleton (defaults to the fixed id). */\n settingsId?: string\n now?: () => string\n}\n\n/** Sanitizes a template id into a stable, Sanity-safe array `_key`. */\nfunction keyify(value: string): string {\n return value.replace(/[^a-zA-Z0-9_-]/g, '-') || 'x'\n}\n\n/**\n * Fetches templates from Smartcat and writes them (sorted by name) to the\n * settings singleton, stamping `templatesSyncedAt`. Always writes the timestamp —\n * even for an empty list — so the cache reads as initialized.\n */\nexport async function runTemplatesSync(\n options: RunTemplatesSyncOptions,\n): Promise<{count: number}> {\n const {sanity, smartcat, settingsId = SETTINGS_DOC_ID, now = () => new Date().toISOString()} = options\n\n const {templates} = await smartcat.getTemplates()\n const value = [...(templates ?? [])]\n .sort((a, b) => a.name.localeCompare(b.name))\n .map((t) => ({_key: keyify(t.id), id: t.id, name: t.name}))\n\n await sanity.patch(settingsId).set({templates: value, templatesSyncedAt: now()}).commit()\n\n return {count: value.length}\n}\n"],"names":[],"mappings":";AAgCA,SAAS,OAAO,OAAuB;AACrC,SAAO,MAAM,QAAQ,mBAAmB,GAAG,KAAK;AAClD;AAOA,eAAsB,iBACpB,SAC0B;AAC1B,QAAM,EAAC,QAAQ,UAAU,aAAa,iBAAiB,MAAM,OAAM,oBAAI,KAAA,GAAO,YAAA,EAAY,IAAK,SAEzF,EAAC,UAAA,IAAa,MAAM,SAAS,aAAA,GAC7B,QAAQ,CAAC,GAAI,aAAa,CAAA,CAAG,EAChC,KAAK,CAAC,GAAG,MAAM,EAAE,KAAK,cAAc,EAAE,IAAI,CAAC,EAC3C,IAAI,CAAC,OAAO,EAAC,MAAM,OAAO,EAAE,EAAE,GAAG,IAAI,EAAE,IAAI,MAAM,EAAE,KAAA,EAAM;AAE5D,SAAA,MAAM,OAAO,MAAM,UAAU,EAAE,IAAI,EAAC,WAAW,OAAO,mBAAmB,IAAA,EAAI,CAAE,EAAE,OAAA,GAE1E,EAAC,OAAO,MAAM,OAAA;AACvB;"}
package/package.json CHANGED
@@ -1,23 +1,109 @@
1
1
  {
2
2
  "name": "@smartcat/sanity-plugin",
3
- "version": "1.0.0",
4
- "description": "",
5
- "homepage": "https://github.com/smartcatai/sanity-plugin#readme",
6
- "bugs": {
7
- "url": "https://github.com/smartcatai/sanity-plugin/issues"
8
- },
3
+ "version": "1.1.0",
9
4
  "publishConfig": {
10
- "access": "public"
5
+ "access": "public",
6
+ "registry": "https://registry.npmjs.org/"
11
7
  },
12
- "repository": {
13
- "type": "git",
14
- "url": "git+ssh://git@github.com/smartcatai/sanity-plugin.git"
8
+ "description": "Group Sanity content into translation projects, send them to Smartcat, and import localized variants.",
9
+ "keywords": [
10
+ "sanity",
11
+ "sanity-plugin",
12
+ "smartcat",
13
+ "translation",
14
+ "localization",
15
+ "i18n"
16
+ ],
17
+ "license": "MIT",
18
+ "author": "Smartcat",
19
+ "type": "module",
20
+ "browserslist": "extends @sanity/browserslist-config",
21
+ "sideEffects": false,
22
+ "exports": {
23
+ ".": {
24
+ "source": "./src/index.ts",
25
+ "import": "./dist/index.js",
26
+ "require": "./dist/index.cjs",
27
+ "default": "./dist/index.js"
28
+ },
29
+ "./locjson": {
30
+ "source": "./src/lib/locjson/index.ts",
31
+ "import": "./dist/locjson.js",
32
+ "require": "./dist/locjson.cjs",
33
+ "default": "./dist/locjson.js"
34
+ },
35
+ "./smartcat": {
36
+ "source": "./src/smartcat/index.ts",
37
+ "import": "./dist/smartcat.js",
38
+ "require": "./dist/smartcat.cjs",
39
+ "default": "./dist/smartcat.js"
40
+ },
41
+ "./export": {
42
+ "source": "./src/export/index.ts",
43
+ "import": "./dist/export.js",
44
+ "require": "./dist/export.cjs",
45
+ "default": "./dist/export.js"
46
+ },
47
+ "./import": {
48
+ "source": "./src/import/index.ts",
49
+ "import": "./dist/import.js",
50
+ "require": "./dist/import.cjs",
51
+ "default": "./dist/import.js"
52
+ },
53
+ "./progress": {
54
+ "source": "./src/progress/index.ts",
55
+ "import": "./dist/progress.js",
56
+ "require": "./dist/progress.cjs",
57
+ "default": "./dist/progress.js"
58
+ },
59
+ "./templates": {
60
+ "source": "./src/templates/index.ts",
61
+ "import": "./dist/templates.js",
62
+ "require": "./dist/templates.cjs",
63
+ "default": "./dist/templates.js"
64
+ },
65
+ "./package.json": "./package.json"
15
66
  },
16
- "license": "ISC",
17
- "author": "",
18
- "type": "commonjs",
19
- "main": "index.js",
67
+ "main": "./dist/index.cjs",
68
+ "module": "./dist/index.js",
69
+ "types": "./dist/index.d.ts",
70
+ "files": [
71
+ "dist",
72
+ "src",
73
+ "README.md"
74
+ ],
20
75
  "scripts": {
21
- "test": "echo \"Error: no test specified\" && exit 1"
76
+ "build": "pkg-utils build --check --clean",
77
+ "watch": "pkg-utils watch",
78
+ "test": "vitest run",
79
+ "prepublishOnly": "npm run build"
80
+ },
81
+ "dependencies": {
82
+ "@portabletext/to-html": "^5.0.2",
83
+ "@sanity/block-tools": "^3.70.0",
84
+ "@sanity/icons": "^3.7.0",
85
+ "@sanity/uuid": "^3.0.2"
86
+ },
87
+ "peerDependencies": {
88
+ "@sanity/ui": "^2 || ^3",
89
+ "react": "^18 || ^19",
90
+ "sanity": "^3 || ^4 || ^5 || ^6",
91
+ "styled-components": "^6"
92
+ },
93
+ "devDependencies": {
94
+ "@sanity/pkg-utils": "^7.2.2",
95
+ "@sanity/schema": "^6.2.0",
96
+ "@sanity/ui": "^3.0.0",
97
+ "@types/jsdom": "^28.0.3",
98
+ "@types/node": "^25.9.3",
99
+ "jsdom": "^29.1.1",
100
+ "react": "^19.2.4",
101
+ "sanity": "^6.2.0",
102
+ "styled-components": "^6.1.18",
103
+ "typescript": "^5.8.0",
104
+ "vitest": "^4.1.8"
105
+ },
106
+ "engines": {
107
+ "node": ">=18"
22
108
  }
23
109
  }
@@ -0,0 +1,274 @@
1
+ import {useCallback, useEffect, useMemo, useState} from 'react'
2
+ import {useClient, usePerspective, useSchema, type DocumentActionComponent, type DocumentActionProps} from 'sanity'
3
+ import {Badge, Box, Button, Card, Flex, Inline, Label, Select, Spinner, Stack, Switch, Text, TextInput, useToast} from '@sanity/ui'
4
+ import {AddIcon, TranslateIcon} from '@sanity/icons'
5
+ import {uuid} from '@sanity/uuid'
6
+ import {API_VERSION, TRANSLATION_PROJECT_TYPE} from '../lib/constants'
7
+ import {WorkflowSelect} from '../tool/WorkflowSelect'
8
+ import {useTemplates} from '../tool/useTemplates'
9
+ import {useWorkflowSelection} from '../tool/useWorkflowSelection'
10
+ import {resolveDocumentTitle, UNTITLED} from '../lib/documentTitle'
11
+ import {gatherLinkedDocuments} from '../lib/linkedDocuments'
12
+ import {ITEM_ID} from '../lib/projectItems'
13
+ import {getTranslatableFields} from '../lib/locjson/fields'
14
+ import type {ResolvedSmartcatConfig} from '../lib/resolveConfig'
15
+
16
+ interface ProjectOption {
17
+ _id: string
18
+ name: string
19
+ count: number
20
+ status: string
21
+ }
22
+
23
+ const PROJECTS_QUERY = `*[_type == $type] | order(name asc){
24
+ _id,
25
+ name,
26
+ status,
27
+ "count": count(items)
28
+ }`
29
+
30
+ const CREATE_NEW = '__create_new__'
31
+
32
+ /**
33
+ * Builds the "Add to translation project" document action, shown on the
34
+ * configured translatable types. Lets the editor add the current document to an
35
+ * existing project or create a new one — appending a weak reference to the
36
+ * project's `items`.
37
+ */
38
+ export function createAddToProjectAction(config: ResolvedSmartcatConfig): DocumentActionComponent {
39
+ const sourceLanguage = config.sourceLanguage
40
+
41
+ const AddToProjectAction: DocumentActionComponent = (props: DocumentActionProps) => {
42
+ const {id, draft, published, onComplete} = props
43
+ const client = useClient({apiVersion: API_VERSION})
44
+ const schema = useSchema()
45
+ const toast = useToast()
46
+ // Which version is the translation source, decided here and stored on the
47
+ // item (the pill and the export both read it — no re-resolving later): the
48
+ // published document when the published perspective is selected or there is
49
+ // no draft; otherwise the draft, which is what the editor sees and edits.
50
+ const {selectedPerspectiveName} = usePerspective()
51
+ const sourceIsPublished = selectedPerspectiveName === 'published' || !draft
52
+
53
+ const [open, setOpen] = useState(false)
54
+ const [projects, setProjects] = useState<ProjectOption[] | null>(null)
55
+ const [selection, setSelection] = useState<string>(CREATE_NEW)
56
+ const [newName, setNewName] = useState('')
57
+ const [busy, setBusy] = useState(false)
58
+ const [includeLinked, setIncludeLinked] = useState(true)
59
+ const {templates, loaded, requestRefresh} = useTemplates()
60
+ const [workflow, setWorkflow] = useWorkflowSelection(open, templates, loaded)
61
+
62
+ const publishedId = id.replace(/^drafts\./, '')
63
+ const doc = draft ?? published
64
+ // Resolve the title natively (preview.prepare) so field-level i18n types yield
65
+ // a string, never the raw internationalized-array object (renders as React #31).
66
+ const docTitle = (doc && resolveDocumentTitle(schema, doc)) || UNTITLED
67
+
68
+ // Load existing projects when the dialog opens.
69
+ useEffect(() => {
70
+ if (!open) return
71
+ // Refresh the template list each time the dialog opens.
72
+ requestRefresh()
73
+ let cancelled = false
74
+ setProjects(null)
75
+ client
76
+ .fetch<ProjectOption[]>(PROJECTS_QUERY, {type: TRANSLATION_PROJECT_TYPE})
77
+ .then((res) => {
78
+ if (cancelled) return
79
+ setProjects(res)
80
+ // Default to the first existing project if there is one.
81
+ setSelection(res.length > 0 ? res[0]._id : CREATE_NEW)
82
+ })
83
+ .catch((err) => {
84
+ if (!cancelled) toast.push({status: 'error', title: 'Failed to load projects', description: String(err)})
85
+ })
86
+ return () => {
87
+ cancelled = true
88
+ }
89
+ }, [open, client, toast, requestRefresh])
90
+
91
+ const close = useCallback(() => {
92
+ setOpen(false)
93
+ setNewName('')
94
+ onComplete()
95
+ }, [onComplete])
96
+
97
+ const handleConfirm = useCallback(async () => {
98
+ setBusy(true)
99
+ try {
100
+ const makeItem = (docId: string, docType?: string) => ({
101
+ _type: 'smartcat.projectItem',
102
+ _key: uuid(),
103
+ docId,
104
+ docType,
105
+ sourceIsPublished,
106
+ })
107
+
108
+ // The root document plus, optionally, everything it links to — de-duped by id.
109
+ const collected = new Map<string, string | undefined>([[publishedId, doc?._type]])
110
+ if (includeLinked) {
111
+ const isRoot = (type: string) => config.rootTypes.includes(type)
112
+ const isTranslatable = (type: string) =>
113
+ getTranslatableFields(schema.get(type), {exclude: [config.documentI18nLanguageField]}).length > 0
114
+ const linked = await gatherLinkedDocuments({client, rootId: publishedId, isRoot, isTranslatable})
115
+ for (const {docId, docType} of linked) if (!collected.has(docId)) collected.set(docId, docType)
116
+ }
117
+
118
+ if (selection === CREATE_NEW) {
119
+ const name = newName.trim() || `Translation project ${new Date().toISOString().slice(0, 10)}`
120
+ const items = [...collected].map(([docId, docType]) => makeItem(docId, docType))
121
+ await client.create({
122
+ _type: TRANSLATION_PROJECT_TYPE,
123
+ name,
124
+ status: 'draft',
125
+ sourceLanguage,
126
+ workflow,
127
+ items,
128
+ })
129
+ toast.push({
130
+ status: 'success',
131
+ title: `Created “${name}” with ${items.length} document${items.length === 1 ? '' : 's'}`,
132
+ })
133
+ } else {
134
+ // Skip ids already in the project (normalized across old/new item shapes).
135
+ const existing = await client.fetch<{id: string}[]>(
136
+ `*[_id == $pid][0].items[]{"id": ${ITEM_ID}}`,
137
+ {pid: selection},
138
+ )
139
+ const existingIds = new Set((existing ?? []).map((e) => e.id))
140
+ const newItems = [...collected]
141
+ .filter(([docId]) => !existingIds.has(docId))
142
+ .map(([docId, docType]) => makeItem(docId, docType))
143
+ const projName = projects?.find((p) => p._id === selection)?.name ?? 'project'
144
+ if (newItems.length === 0) {
145
+ toast.push({status: 'info', title: `Already in “${projName}”`})
146
+ } else {
147
+ await client.patch(selection).setIfMissing({items: []}).insert('after', 'items[-1]', newItems).commit()
148
+ const skipped = collected.size - newItems.length
149
+ toast.push({
150
+ status: 'success',
151
+ title: `Added ${newItems.length} document${newItems.length === 1 ? '' : 's'} to “${projName}”`,
152
+ description: skipped > 0 ? `${skipped} already in the project` : undefined,
153
+ })
154
+ }
155
+ }
156
+ close()
157
+ } catch (err) {
158
+ toast.push({status: 'error', title: 'Failed to add to project', description: String(err)})
159
+ } finally {
160
+ setBusy(false)
161
+ }
162
+ }, [selection, newName, workflow, publishedId, doc, includeLinked, sourceIsPublished, schema, client, projects, close])
163
+
164
+ const content = useMemo(() => {
165
+ if (projects === null) {
166
+ return (
167
+ <Flex align="center" justify="center" padding={4}>
168
+ <Spinner muted />
169
+ </Flex>
170
+ )
171
+ }
172
+ return (
173
+ <Stack space={4}>
174
+ <Stack space={3}>
175
+ <Label size={1}>Project</Label>
176
+ <Select
177
+ value={selection}
178
+ onChange={(e) => setSelection(e.currentTarget.value)}
179
+ disabled={busy}
180
+ >
181
+ {projects.map((p) => (
182
+ <option key={p._id} value={p._id}>
183
+ {p.name} ({p.count} item{p.count === 1 ? '' : 's'})
184
+ </option>
185
+ ))}
186
+ <option value={CREATE_NEW}>➕ Create new project…</option>
187
+ </Select>
188
+ </Stack>
189
+
190
+ {selection === CREATE_NEW && (
191
+ <>
192
+ <Stack space={3}>
193
+ <Label size={1}>New project name</Label>
194
+ <TextInput
195
+ value={newName}
196
+ placeholder="e.g. Spring campaign"
197
+ onChange={(e) => setNewName(e.currentTarget.value)}
198
+ disabled={busy}
199
+ />
200
+ </Stack>
201
+ <Stack space={3}>
202
+ <Label size={1}>Workflow</Label>
203
+ <WorkflowSelect
204
+ value={workflow}
205
+ onChange={setWorkflow}
206
+ templates={templates}
207
+ disabled={busy}
208
+ />
209
+ </Stack>
210
+ </>
211
+ )}
212
+
213
+ <Card padding={3} radius={2} tone="transparent" border>
214
+ <Stack space={4}>
215
+ <Text size={1} muted>
216
+ Adding{' '}
217
+ <Badge
218
+ tone={sourceIsPublished ? 'positive' : 'caution'}
219
+ fontSize={1}
220
+ paddingX={2}
221
+ style={{verticalAlign: 'middle'}}
222
+ >
223
+ {sourceIsPublished ? 'Published' : 'Draft'}
224
+ </Badge>{' '}
225
+ version of <strong>{docTitle}</strong> to the selected project.
226
+ </Text>
227
+ <Flex align="flex-start" gap={3}>
228
+ <Switch
229
+ checked={includeLinked}
230
+ onChange={(e) => setIncludeLinked(e.currentTarget.checked)}
231
+ disabled={busy}
232
+ />
233
+ <Stack space={2}>
234
+ <Text size={1} weight="medium">
235
+ Include linked content
236
+ </Text>
237
+ <Text size={1} muted>
238
+ Also add documents this one references, recursively — skipping any already in the project.
239
+ </Text>
240
+ </Stack>
241
+ </Flex>
242
+ </Stack>
243
+ </Card>
244
+
245
+ <Flex justify="flex-end" gap={2}>
246
+ <Button mode="bleed" text="Cancel" onClick={close} disabled={busy} />
247
+ <Button
248
+ tone="primary"
249
+ text="Add to project"
250
+ icon={AddIcon}
251
+ onClick={handleConfirm}
252
+ loading={busy}
253
+ disabled={selection === CREATE_NEW && !workflow}
254
+ />
255
+ </Flex>
256
+ </Stack>
257
+ )
258
+ }, [projects, selection, newName, workflow, templates, busy, includeLinked, docTitle, sourceIsPublished, close, handleConfirm])
259
+
260
+ return {
261
+ label: 'Add to translation project',
262
+ icon: TranslateIcon,
263
+ onHandle: () => setOpen(true),
264
+ dialog: open && {
265
+ type: 'dialog',
266
+ header: 'Add to translation project',
267
+ onClose: close,
268
+ content,
269
+ },
270
+ }
271
+ }
272
+
273
+ return AddToProjectAction
274
+ }