@smartcat/sanity-plugin 1.0.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. package/README.md +294 -0
  2. package/dist/_chunks-cjs/constants.cjs +13 -0
  3. package/dist/_chunks-cjs/constants.cjs.map +1 -0
  4. package/dist/_chunks-cjs/index.cjs +168 -0
  5. package/dist/_chunks-cjs/index.cjs.map +1 -0
  6. package/dist/_chunks-cjs/index2.cjs +311 -0
  7. package/dist/_chunks-cjs/index2.cjs.map +1 -0
  8. package/dist/_chunks-cjs/workflow.cjs +16 -0
  9. package/dist/_chunks-cjs/workflow.cjs.map +1 -0
  10. package/dist/_chunks-es/constants.js +14 -0
  11. package/dist/_chunks-es/constants.js.map +1 -0
  12. package/dist/_chunks-es/index.js +169 -0
  13. package/dist/_chunks-es/index.js.map +1 -0
  14. package/dist/_chunks-es/index2.js +313 -0
  15. package/dist/_chunks-es/index2.js.map +1 -0
  16. package/dist/_chunks-es/workflow.js +17 -0
  17. package/dist/_chunks-es/workflow.js.map +1 -0
  18. package/dist/export.cjs +160 -0
  19. package/dist/export.cjs.map +1 -0
  20. package/dist/export.d.cts +162 -0
  21. package/dist/export.d.ts +162 -0
  22. package/dist/export.js +162 -0
  23. package/dist/export.js.map +1 -0
  24. package/dist/import.cjs +169 -0
  25. package/dist/import.cjs.map +1 -0
  26. package/dist/import.d.cts +155 -0
  27. package/dist/import.d.ts +155 -0
  28. package/dist/import.js +169 -0
  29. package/dist/import.js.map +1 -0
  30. package/dist/index.cjs +2103 -0
  31. package/dist/index.cjs.map +1 -0
  32. package/dist/index.d.cts +122 -0
  33. package/dist/index.d.ts +122 -0
  34. package/dist/index.js +2110 -0
  35. package/dist/index.js.map +1 -0
  36. package/dist/locjson.cjs +12 -0
  37. package/dist/locjson.cjs.map +1 -0
  38. package/dist/locjson.d.cts +287 -0
  39. package/dist/locjson.d.ts +287 -0
  40. package/dist/locjson.js +12 -0
  41. package/dist/locjson.js.map +1 -0
  42. package/dist/progress.cjs +16 -0
  43. package/dist/progress.cjs.map +1 -0
  44. package/dist/progress.d.cts +262 -0
  45. package/dist/progress.d.ts +262 -0
  46. package/dist/progress.js +16 -0
  47. package/dist/progress.js.map +1 -0
  48. package/dist/smartcat.cjs +287 -0
  49. package/dist/smartcat.cjs.map +1 -0
  50. package/dist/smartcat.d.cts +234 -0
  51. package/dist/smartcat.d.ts +234 -0
  52. package/dist/smartcat.js +287 -0
  53. package/dist/smartcat.js.map +1 -0
  54. package/dist/templates.cjs +12 -0
  55. package/dist/templates.cjs.map +1 -0
  56. package/dist/templates.d.cts +52 -0
  57. package/dist/templates.d.ts +52 -0
  58. package/dist/templates.js +12 -0
  59. package/dist/templates.js.map +1 -0
  60. package/package.json +101 -15
  61. package/src/actions/AddToProjectAction.tsx +258 -0
  62. package/src/export/export.test.ts +537 -0
  63. package/src/export/index.ts +393 -0
  64. package/src/form/TranslationStatusInput.tsx +212 -0
  65. package/src/import/import.test.ts +346 -0
  66. package/src/import/index.ts +418 -0
  67. package/src/index.ts +63 -0
  68. package/src/lib/constants.ts +23 -0
  69. package/src/lib/documentTitle.test.ts +43 -0
  70. package/src/lib/documentTitle.ts +30 -0
  71. package/src/lib/languageMap.test.ts +56 -0
  72. package/src/lib/languageMap.ts +71 -0
  73. package/src/lib/linkedDocuments.test.ts +56 -0
  74. package/src/lib/linkedDocuments.ts +100 -0
  75. package/src/lib/locjson/deserialize.ts +60 -0
  76. package/src/lib/locjson/fields.ts +355 -0
  77. package/src/lib/locjson/filename.ts +27 -0
  78. package/src/lib/locjson/index.ts +10 -0
  79. package/src/lib/locjson/locjson.test.ts +545 -0
  80. package/src/lib/locjson/paths.test.ts +41 -0
  81. package/src/lib/locjson/paths.ts +73 -0
  82. package/src/lib/locjson/portableText.ts +98 -0
  83. package/src/lib/locjson/serialize.ts +124 -0
  84. package/src/lib/locjson/types.ts +106 -0
  85. package/src/lib/log.ts +33 -0
  86. package/src/lib/projectItems.ts +17 -0
  87. package/src/lib/resolveConfig.test.ts +62 -0
  88. package/src/lib/resolveConfig.ts +47 -0
  89. package/src/lib/workflow.test.ts +37 -0
  90. package/src/lib/workflow.ts +48 -0
  91. package/src/lib/zip.fixtures.ts +75 -0
  92. package/src/lib/zip.test.ts +110 -0
  93. package/src/lib/zip.ts +164 -0
  94. package/src/progress/core.ts +387 -0
  95. package/src/progress/index.ts +7 -0
  96. package/src/progress/progress.test.ts +268 -0
  97. package/src/schema/settings.ts +55 -0
  98. package/src/schema/translatableOptions.ts +25 -0
  99. package/src/schema/translationProject.ts +264 -0
  100. package/src/smartcat/client.test.ts +222 -0
  101. package/src/smartcat/client.ts +351 -0
  102. package/src/smartcat/index.ts +10 -0
  103. package/src/smartcat/types.ts +99 -0
  104. package/src/templates/core.ts +55 -0
  105. package/src/templates/index.ts +5 -0
  106. package/src/templates/templates.test.ts +50 -0
  107. package/src/tool/Dashboard.tsx +44 -0
  108. package/src/tool/ItemProgress.tsx +176 -0
  109. package/src/tool/LogPanel.tsx +207 -0
  110. package/src/tool/ProjectView.tsx +975 -0
  111. package/src/tool/ProjectsView.tsx +208 -0
  112. package/src/tool/StatusBadge.tsx +33 -0
  113. package/src/tool/StudioInit.tsx +42 -0
  114. package/src/tool/WorkflowSelect.tsx +41 -0
  115. package/src/tool/data.ts +79 -0
  116. package/src/tool/index.ts +19 -0
  117. package/src/tool/processing.test.ts +623 -0
  118. package/src/tool/processing.ts +766 -0
  119. package/src/tool/useTemplates.ts +84 -0
  120. package/src/tool/useWorkflowSelection.ts +65 -0
  121. package/src/types.ts +77 -0
@@ -0,0 +1,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.0.1",
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,258 @@
1
+ import {useCallback, useEffect, useMemo, useState} from 'react'
2
+ import {useClient, useSchema, type DocumentActionComponent, type DocumentActionProps} from 'sanity'
3
+ import {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
+
47
+ const [open, setOpen] = useState(false)
48
+ const [projects, setProjects] = useState<ProjectOption[] | null>(null)
49
+ const [selection, setSelection] = useState<string>(CREATE_NEW)
50
+ const [newName, setNewName] = useState('')
51
+ const [busy, setBusy] = useState(false)
52
+ const [includeLinked, setIncludeLinked] = useState(true)
53
+ const {templates, loaded, requestRefresh} = useTemplates()
54
+ const [workflow, setWorkflow] = useWorkflowSelection(open, templates, loaded)
55
+
56
+ const publishedId = id.replace(/^drafts\./, '')
57
+ const doc = draft ?? published
58
+ // Resolve the title natively (preview.prepare) so field-level i18n types yield
59
+ // a string, never the raw internationalized-array object (renders as React #31).
60
+ const docTitle = (doc && resolveDocumentTitle(schema, doc)) || UNTITLED
61
+
62
+ // Load existing projects when the dialog opens.
63
+ useEffect(() => {
64
+ if (!open) return
65
+ // Refresh the template list each time the dialog opens.
66
+ requestRefresh()
67
+ let cancelled = false
68
+ setProjects(null)
69
+ client
70
+ .fetch<ProjectOption[]>(PROJECTS_QUERY, {type: TRANSLATION_PROJECT_TYPE})
71
+ .then((res) => {
72
+ if (cancelled) return
73
+ setProjects(res)
74
+ // Default to the first existing project if there is one.
75
+ setSelection(res.length > 0 ? res[0]._id : CREATE_NEW)
76
+ })
77
+ .catch((err) => {
78
+ if (!cancelled) toast.push({status: 'error', title: 'Failed to load projects', description: String(err)})
79
+ })
80
+ return () => {
81
+ cancelled = true
82
+ }
83
+ }, [open, client, toast, requestRefresh])
84
+
85
+ const close = useCallback(() => {
86
+ setOpen(false)
87
+ setNewName('')
88
+ onComplete()
89
+ }, [onComplete])
90
+
91
+ const handleConfirm = useCallback(async () => {
92
+ setBusy(true)
93
+ try {
94
+ const makeItem = (docId: string, docType?: string) => ({
95
+ _type: 'smartcat.projectItem',
96
+ _key: uuid(),
97
+ docId,
98
+ docType,
99
+ })
100
+
101
+ // The root document plus, optionally, everything it links to — de-duped by id.
102
+ const collected = new Map<string, string | undefined>([[publishedId, doc?._type]])
103
+ if (includeLinked) {
104
+ const isRoot = (type: string) => config.rootTypes.includes(type)
105
+ const isTranslatable = (type: string) =>
106
+ getTranslatableFields(schema.get(type), {exclude: [config.documentI18nLanguageField]}).length > 0
107
+ const linked = await gatherLinkedDocuments({client, rootId: publishedId, isRoot, isTranslatable})
108
+ for (const {docId, docType} of linked) if (!collected.has(docId)) collected.set(docId, docType)
109
+ }
110
+
111
+ if (selection === CREATE_NEW) {
112
+ const name = newName.trim() || `Translation project ${new Date().toISOString().slice(0, 10)}`
113
+ const items = [...collected].map(([docId, docType]) => makeItem(docId, docType))
114
+ await client.create({
115
+ _type: TRANSLATION_PROJECT_TYPE,
116
+ name,
117
+ status: 'draft',
118
+ sourceLanguage,
119
+ workflow,
120
+ items,
121
+ })
122
+ toast.push({
123
+ status: 'success',
124
+ title: `Created “${name}” with ${items.length} document${items.length === 1 ? '' : 's'}`,
125
+ })
126
+ } else {
127
+ // Skip ids already in the project (normalized across old/new item shapes).
128
+ const existing = await client.fetch<{id: string}[]>(
129
+ `*[_id == $pid][0].items[]{"id": ${ITEM_ID}}`,
130
+ {pid: selection},
131
+ )
132
+ const existingIds = new Set((existing ?? []).map((e) => e.id))
133
+ const newItems = [...collected]
134
+ .filter(([docId]) => !existingIds.has(docId))
135
+ .map(([docId, docType]) => makeItem(docId, docType))
136
+ const projName = projects?.find((p) => p._id === selection)?.name ?? 'project'
137
+ if (newItems.length === 0) {
138
+ toast.push({status: 'info', title: `Already in “${projName}”`})
139
+ } else {
140
+ await client.patch(selection).setIfMissing({items: []}).insert('after', 'items[-1]', newItems).commit()
141
+ const skipped = collected.size - newItems.length
142
+ toast.push({
143
+ status: 'success',
144
+ title: `Added ${newItems.length} document${newItems.length === 1 ? '' : 's'} to “${projName}”`,
145
+ description: skipped > 0 ? `${skipped} already in the project` : undefined,
146
+ })
147
+ }
148
+ }
149
+ close()
150
+ } catch (err) {
151
+ toast.push({status: 'error', title: 'Failed to add to project', description: String(err)})
152
+ } finally {
153
+ setBusy(false)
154
+ }
155
+ }, [selection, newName, workflow, publishedId, doc, includeLinked, schema, client, projects, close])
156
+
157
+ const content = useMemo(() => {
158
+ if (projects === null) {
159
+ return (
160
+ <Flex align="center" justify="center" padding={4}>
161
+ <Spinner muted />
162
+ </Flex>
163
+ )
164
+ }
165
+ return (
166
+ <Stack space={4}>
167
+ <Stack space={3}>
168
+ <Label size={1}>Project</Label>
169
+ <Select
170
+ value={selection}
171
+ onChange={(e) => setSelection(e.currentTarget.value)}
172
+ disabled={busy}
173
+ >
174
+ {projects.map((p) => (
175
+ <option key={p._id} value={p._id}>
176
+ {p.name} ({p.count} item{p.count === 1 ? '' : 's'})
177
+ </option>
178
+ ))}
179
+ <option value={CREATE_NEW}>➕ Create new project…</option>
180
+ </Select>
181
+ </Stack>
182
+
183
+ {selection === CREATE_NEW && (
184
+ <>
185
+ <Stack space={3}>
186
+ <Label size={1}>New project name</Label>
187
+ <TextInput
188
+ value={newName}
189
+ placeholder="e.g. Spring campaign"
190
+ onChange={(e) => setNewName(e.currentTarget.value)}
191
+ disabled={busy}
192
+ />
193
+ </Stack>
194
+ <Stack space={3}>
195
+ <Label size={1}>Workflow</Label>
196
+ <WorkflowSelect
197
+ value={workflow}
198
+ onChange={setWorkflow}
199
+ templates={templates}
200
+ disabled={busy}
201
+ />
202
+ </Stack>
203
+ </>
204
+ )}
205
+
206
+ <Card padding={3} radius={2} tone="transparent" border>
207
+ <Stack space={4}>
208
+ <Text size={1} muted>
209
+ Adding <strong>{docTitle}</strong> to the selected project.
210
+ </Text>
211
+ <Flex align="flex-start" gap={3}>
212
+ <Switch
213
+ checked={includeLinked}
214
+ onChange={(e) => setIncludeLinked(e.currentTarget.checked)}
215
+ disabled={busy}
216
+ />
217
+ <Stack space={2}>
218
+ <Text size={1} weight="medium">
219
+ Include linked content
220
+ </Text>
221
+ <Text size={1} muted>
222
+ Also add documents this one references, recursively — skipping any already in the project.
223
+ </Text>
224
+ </Stack>
225
+ </Flex>
226
+ </Stack>
227
+ </Card>
228
+
229
+ <Flex justify="flex-end" gap={2}>
230
+ <Button mode="bleed" text="Cancel" onClick={close} disabled={busy} />
231
+ <Button
232
+ tone="primary"
233
+ text="Add to project"
234
+ icon={AddIcon}
235
+ onClick={handleConfirm}
236
+ loading={busy}
237
+ disabled={selection === CREATE_NEW && !workflow}
238
+ />
239
+ </Flex>
240
+ </Stack>
241
+ )
242
+ }, [projects, selection, newName, workflow, templates, busy, includeLinked, docTitle, close, handleConfirm])
243
+
244
+ return {
245
+ label: 'Add to translation project',
246
+ icon: TranslateIcon,
247
+ onHandle: () => setOpen(true),
248
+ dialog: open && {
249
+ type: 'dialog',
250
+ header: 'Add to translation project',
251
+ onClose: close,
252
+ content,
253
+ },
254
+ }
255
+ }
256
+
257
+ return AddToProjectAction
258
+ }