@byline/ui 1.9.1 → 1.10.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.
@@ -1,6 +1,48 @@
1
1
  import type { PublishedVersionInfo } from './form-renderer';
2
- export declare function DocumentActions({ publishedVersion, onUnpublish, onDelete, }: {
2
+ /**
3
+ * Shape of a content-locale option as consumed by the Copy-to-Locale
4
+ * modal. Matches the host adapter's `ContentLocaleOption`; declared
5
+ * locally so this package does not take a dependency on host code.
6
+ */
7
+ export interface DocumentActionsLocaleOption {
8
+ code: string;
9
+ label: string;
10
+ }
11
+ export declare function DocumentActions({ publishedVersion, onUnpublish, onDelete, onDuplicate, sourceTitle, onCopyToLocale, sourceLocale, contentLocales, }: {
3
12
  publishedVersion?: PublishedVersionInfo | null;
4
13
  onUnpublish?: () => Promise<void>;
5
14
  onDelete?: () => Promise<void>;
15
+ /**
16
+ * Called when the editor confirms the duplicate modal. The parent runs
17
+ * the server fn, surfaces a toast, and navigates to the new document.
18
+ */
19
+ onDuplicate?: () => Promise<void>;
20
+ /**
21
+ * The current (saved) value of the source document's `useAsTitle`
22
+ * field, used to render the suffix preview inside the duplicate modal.
23
+ * Sourced from the form's `initialData`, not live form state, so the
24
+ * preview reflects what will actually be duplicated.
25
+ */
26
+ sourceTitle?: string | null;
27
+ /**
28
+ * Called when the editor confirms the Copy-to-Locale modal. The
29
+ * parent runs the server fn, surfaces a toast, and navigates to the
30
+ * target locale view. Menu item is hidden when omitted, or when fewer
31
+ * than two content locales are configured.
32
+ */
33
+ onCopyToLocale?: (args: {
34
+ targetLocale: string;
35
+ overwrite: boolean;
36
+ }) => Promise<void>;
37
+ /**
38
+ * The locale the form is currently displaying. Used as the read-only
39
+ * "From" label in the Copy-to-Locale modal and excluded from the
40
+ * target Select.
41
+ */
42
+ sourceLocale?: string;
43
+ /**
44
+ * All configured content locales (code + display label). The
45
+ * Copy-to-Locale Select lists every locale except `sourceLocale`.
46
+ */
47
+ contentLocales?: ReadonlyArray<DocumentActionsLocaleOption>;
6
48
  }): import("react").JSX.Element;
@@ -2,14 +2,54 @@
2
2
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
3
3
  import { useState } from "react";
4
4
  import classnames from "classnames";
5
- import { Button, CloseIcon, DeleteIcon, Dropdown, EllipsisIcon, IconButton, Modal } from "../uikit.js";
5
+ import { Button, Checkbox, CloseIcon, DeleteIcon, Dropdown, EllipsisIcon, IconButton, Modal, Select } from "../uikit.js";
6
6
  import document_actions_module from "./document-actions.module.js";
7
- function DocumentActions({ publishedVersion, onUnpublish, onDelete }) {
7
+ const DUPLICATE_TITLE_SUFFIX = ' (copy)';
8
+ function DocumentActions({ publishedVersion, onUnpublish, onDelete, onDuplicate, sourceTitle, onCopyToLocale, sourceLocale, contentLocales }) {
8
9
  const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
10
+ const [showDuplicateConfirm, setShowDuplicateConfirm] = useState(false);
11
+ const [duplicateBusy, setDuplicateBusy] = useState(false);
12
+ const availableTargetLocales = (contentLocales ?? []).filter((loc)=>loc.code !== sourceLocale);
13
+ const copyToLocaleAvailable = null != onCopyToLocale && availableTargetLocales.length > 0;
14
+ const [showCopyToLocaleConfirm, setShowCopyToLocaleConfirm] = useState(false);
15
+ const [copyToLocaleBusy, setCopyToLocaleBusy] = useState(false);
16
+ const [copyTargetLocale, setCopyTargetLocale] = useState(availableTargetLocales[0]?.code ?? '');
17
+ const [copyOverwrite, setCopyOverwrite] = useState(false);
9
18
  const handleOnDelete = ()=>{
10
19
  setShowDeleteConfirm(false);
11
20
  if (onDelete) onDelete();
12
21
  };
22
+ const handleOnDuplicate = async ()=>{
23
+ if (!onDuplicate) return;
24
+ setDuplicateBusy(true);
25
+ try {
26
+ await onDuplicate();
27
+ setShowDuplicateConfirm(false);
28
+ } finally{
29
+ setDuplicateBusy(false);
30
+ }
31
+ };
32
+ const handleOpenCopyToLocale = ()=>{
33
+ setCopyTargetLocale(availableTargetLocales[0]?.code ?? '');
34
+ setCopyOverwrite(false);
35
+ setShowCopyToLocaleConfirm(true);
36
+ };
37
+ const handleOnCopyToLocale = async ()=>{
38
+ if (!onCopyToLocale || !copyTargetLocale) return;
39
+ setCopyToLocaleBusy(true);
40
+ try {
41
+ await onCopyToLocale({
42
+ targetLocale: copyTargetLocale,
43
+ overwrite: copyOverwrite
44
+ });
45
+ setShowCopyToLocaleConfirm(false);
46
+ } finally{
47
+ setCopyToLocaleBusy(false);
48
+ }
49
+ };
50
+ const duplicatePreviewBefore = sourceTitle ?? '';
51
+ const duplicatePreviewAfter = (sourceTitle ?? '') + DUPLICATE_TITLE_SUFFIX;
52
+ const sourceLocaleLabel = contentLocales?.find((loc)=>loc.code === sourceLocale)?.label ?? sourceLocale ?? '';
13
53
  return /*#__PURE__*/ jsxs(Fragment, {
14
54
  children: [
15
55
  /*#__PURE__*/ jsxs(Dropdown.Root, {
@@ -33,26 +73,35 @@ function DocumentActions({ publishedVersion, onUnpublish, onDelete }) {
33
73
  "data-side": "top",
34
74
  sideOffset: 10,
35
75
  children: [
36
- publishedVersion && /*#__PURE__*/ jsxs(Fragment, {
37
- children: [
38
- /*#__PURE__*/ jsx(Dropdown.Item, {
39
- onClick: onUnpublish,
40
- children: /*#__PURE__*/ jsxs("div", {
41
- className: classnames('byline-form-actions-item', document_actions_module.item),
42
- children: [
43
- /*#__PURE__*/ jsx("span", {
44
- className: classnames('byline-form-actions-item-icon', document_actions_module["item-icon"])
45
- }),
46
- /*#__PURE__*/ jsx("span", {
47
- className: classnames('byline-form-actions-item-text', document_actions_module["item-text"]),
48
- children: "Unpublish"
49
- })
50
- ]
76
+ copyToLocaleAvailable && /*#__PURE__*/ jsx(Dropdown.Item, {
77
+ onClick: handleOpenCopyToLocale,
78
+ children: /*#__PURE__*/ jsx("div", {
79
+ className: classnames('byline-form-actions-item', document_actions_module.item),
80
+ children: /*#__PURE__*/ jsx("span", {
81
+ className: classnames('byline-form-actions-item-text', document_actions_module["item-text"]),
82
+ children: /*#__PURE__*/ jsx("button", {
83
+ type: "button",
84
+ children: "Copy to Locale"
51
85
  })
52
- }),
53
- /*#__PURE__*/ jsx(Dropdown.Separator, {})
54
- ]
86
+ })
87
+ })
55
88
  }),
89
+ onDuplicate && /*#__PURE__*/ jsx(Dropdown.Item, {
90
+ onClick: ()=>{
91
+ setShowDuplicateConfirm(true);
92
+ },
93
+ children: /*#__PURE__*/ jsx("div", {
94
+ className: classnames('byline-form-actions-item', document_actions_module.item),
95
+ children: /*#__PURE__*/ jsx("span", {
96
+ className: classnames('byline-form-actions-item-text', document_actions_module["item-text"]),
97
+ children: /*#__PURE__*/ jsx("button", {
98
+ type: "button",
99
+ children: "Duplicate"
100
+ })
101
+ })
102
+ })
103
+ }),
104
+ /*#__PURE__*/ jsx(Dropdown.Separator, {}),
56
105
  /*#__PURE__*/ jsx(Dropdown.Item, {
57
106
  onClick: ()=>{
58
107
  setShowDeleteConfirm(true);
@@ -131,6 +180,9 @@ function DocumentActions({ publishedVersion, onUnpublish, onDelete }) {
131
180
  }),
132
181
  /*#__PURE__*/ jsx(Button, {
133
182
  size: "sm",
183
+ style: {
184
+ minWidth: '80px'
185
+ },
134
186
  intent: "noeffect",
135
187
  onClick: ()=>{
136
188
  setShowDeleteConfirm(false);
@@ -139,6 +191,9 @@ function DocumentActions({ publishedVersion, onUnpublish, onDelete }) {
139
191
  }),
140
192
  /*#__PURE__*/ jsx(Button, {
141
193
  size: "sm",
194
+ style: {
195
+ minWidth: '80px'
196
+ },
142
197
  intent: "danger",
143
198
  onClick: handleOnDelete,
144
199
  children: "Delete"
@@ -147,6 +202,266 @@ function DocumentActions({ publishedVersion, onUnpublish, onDelete }) {
147
202
  })
148
203
  ]
149
204
  })
205
+ }),
206
+ /*#__PURE__*/ jsx(Modal, {
207
+ isOpen: showDuplicateConfirm,
208
+ closeOnOverlayClick: !duplicateBusy,
209
+ onDismiss: ()=>{
210
+ if (!duplicateBusy) setShowDuplicateConfirm(false);
211
+ },
212
+ children: /*#__PURE__*/ jsxs(Modal.Container, {
213
+ style: {
214
+ maxWidth: '560px'
215
+ },
216
+ children: [
217
+ /*#__PURE__*/ jsxs(Modal.Header, {
218
+ className: classnames('byline-form-actions-modal-head', document_actions_module["modal-head"]),
219
+ children: [
220
+ /*#__PURE__*/ jsx("h3", {
221
+ className: classnames('byline-form-actions-modal-title', document_actions_module["modal-title"]),
222
+ children: "Duplicate Document"
223
+ }),
224
+ /*#__PURE__*/ jsx(IconButton, {
225
+ "arial-label": "Close",
226
+ size: "xs",
227
+ onClick: ()=>{
228
+ if (!duplicateBusy) setShowDuplicateConfirm(false);
229
+ },
230
+ children: /*#__PURE__*/ jsx(CloseIcon, {
231
+ width: "16px",
232
+ height: "16px",
233
+ svgClassName: "white-icon"
234
+ })
235
+ })
236
+ ]
237
+ }),
238
+ /*#__PURE__*/ jsxs(Modal.Content, {
239
+ className: "prose",
240
+ children: [
241
+ /*#__PURE__*/ jsx("p", {
242
+ className: "m-0",
243
+ children: "A new document will be created with all translations (if any) cloned from this one. After the duplicate is created you should:"
244
+ }),
245
+ /*#__PURE__*/ jsxs("ul", {
246
+ className: classnames('byline-form-actions-list', document_actions_module.list),
247
+ children: [
248
+ /*#__PURE__*/ jsxs("li", {
249
+ children: [
250
+ "Update the title of the docuiment (including any translated versions). The title is currently suffixed with ",
251
+ /*#__PURE__*/ jsx("code", {
252
+ children: DUPLICATE_TITLE_SUFFIX.trim()
253
+ }),
254
+ "."
255
+ ]
256
+ }),
257
+ /*#__PURE__*/ jsx("li", {
258
+ children: "Review the system path in the path widget — the auto-generated path will reflect the suffixed title and is unlikely to be what you want long-term."
259
+ })
260
+ ]
261
+ }),
262
+ null != sourceTitle && sourceTitle.length > 0 && /*#__PURE__*/ jsxs("div", {
263
+ className: classnames('byline-form-actions-preview', document_actions_module.preview),
264
+ children: [
265
+ /*#__PURE__*/ jsx("div", {
266
+ className: classnames('byline-form-actions-preview-label', document_actions_module["preview-label"]),
267
+ children: "Preview (current locale):"
268
+ }),
269
+ /*#__PURE__*/ jsxs("div", {
270
+ className: classnames('byline-form-actions-preview-row', document_actions_module["preview-row"]),
271
+ children: [
272
+ /*#__PURE__*/ jsx("span", {
273
+ className: classnames('byline-form-actions-preview-before', document_actions_module["preview-before"]),
274
+ children: duplicatePreviewBefore
275
+ }),
276
+ /*#__PURE__*/ jsx("span", {
277
+ className: classnames('byline-form-actions-preview-arrow', document_actions_module["preview-arrow"]),
278
+ children: "→"
279
+ }),
280
+ /*#__PURE__*/ jsx("span", {
281
+ className: classnames('byline-form-actions-preview-after', document_actions_module["preview-after"]),
282
+ children: duplicatePreviewAfter
283
+ })
284
+ ]
285
+ })
286
+ ]
287
+ })
288
+ ]
289
+ }),
290
+ /*#__PURE__*/ jsxs(Modal.Actions, {
291
+ children: [
292
+ /*#__PURE__*/ jsx("button", {
293
+ "data-autofocus": true,
294
+ type: "button",
295
+ tabIndex: 0,
296
+ className: classnames('byline-form-actions-sr-only', document_actions_module["sr-only"]),
297
+ children: "no action"
298
+ }),
299
+ /*#__PURE__*/ jsx(Button, {
300
+ size: "sm",
301
+ style: {
302
+ minWidth: '80px'
303
+ },
304
+ intent: "noeffect",
305
+ onClick: ()=>{
306
+ if (!duplicateBusy) setShowDuplicateConfirm(false);
307
+ },
308
+ disabled: duplicateBusy,
309
+ children: "Cancel"
310
+ }),
311
+ /*#__PURE__*/ jsx(Button, {
312
+ size: "sm",
313
+ style: {
314
+ minWidth: '80px'
315
+ },
316
+ intent: "primary",
317
+ onClick: handleOnDuplicate,
318
+ disabled: duplicateBusy,
319
+ children: duplicateBusy ? 'Duplicating...' : 'Duplicate'
320
+ })
321
+ ]
322
+ })
323
+ ]
324
+ })
325
+ }),
326
+ /*#__PURE__*/ jsx(Modal, {
327
+ isOpen: showCopyToLocaleConfirm,
328
+ closeOnOverlayClick: !copyToLocaleBusy,
329
+ onDismiss: ()=>{
330
+ if (!copyToLocaleBusy) setShowCopyToLocaleConfirm(false);
331
+ },
332
+ children: /*#__PURE__*/ jsxs(Modal.Container, {
333
+ style: {
334
+ maxWidth: '560px'
335
+ },
336
+ children: [
337
+ /*#__PURE__*/ jsxs(Modal.Header, {
338
+ className: classnames('byline-form-actions-modal-head', document_actions_module["modal-head"]),
339
+ children: [
340
+ /*#__PURE__*/ jsx("h3", {
341
+ className: classnames('byline-form-actions-modal-title', document_actions_module["modal-title"]),
342
+ children: "Copy to Locale"
343
+ }),
344
+ /*#__PURE__*/ jsx(IconButton, {
345
+ "arial-label": "Close",
346
+ size: "xs",
347
+ onClick: ()=>{
348
+ if (!copyToLocaleBusy) setShowCopyToLocaleConfirm(false);
349
+ },
350
+ children: /*#__PURE__*/ jsx(CloseIcon, {
351
+ width: "16px",
352
+ height: "16px",
353
+ svgClassName: "white-icon"
354
+ })
355
+ })
356
+ ]
357
+ }),
358
+ /*#__PURE__*/ jsxs(Modal.Content, {
359
+ children: [
360
+ /*#__PURE__*/ jsx("p", {
361
+ children: "Copy this document's content from one locale to another. Non-localized fields are shared across locales and will not change."
362
+ }),
363
+ /*#__PURE__*/ jsxs("div", {
364
+ className: classnames('byline-form-actions-copy-row', document_actions_module["copy-row"]),
365
+ style: {
366
+ marginTop: 'var(--spacing-12)'
367
+ },
368
+ children: [
369
+ /*#__PURE__*/ jsx("span", {
370
+ className: classnames('byline-form-actions-copy-label', document_actions_module["copy-label"]),
371
+ style: {
372
+ fontWeight: 500
373
+ },
374
+ children: "From:\xa0"
375
+ }),
376
+ /*#__PURE__*/ jsx("span", {
377
+ className: classnames('byline-form-actions-copy-source', document_actions_module["copy-source"]),
378
+ children: sourceLocaleLabel
379
+ })
380
+ ]
381
+ }),
382
+ /*#__PURE__*/ jsxs("div", {
383
+ className: classnames('byline-form-actions-copy-row', document_actions_module["copy-row"]),
384
+ style: {
385
+ marginTop: 'var(--spacing-12)'
386
+ },
387
+ children: [
388
+ /*#__PURE__*/ jsx("span", {
389
+ className: classnames('byline-form-actions-copy-label', document_actions_module["copy-label"]),
390
+ style: {
391
+ fontWeight: 500,
392
+ marginRight: 'var(--spacing-8)'
393
+ },
394
+ children: "To:"
395
+ }),
396
+ /*#__PURE__*/ jsx(Select, {
397
+ size: "sm",
398
+ ariaLabel: "Target locale",
399
+ value: copyTargetLocale,
400
+ items: availableTargetLocales.map((loc)=>({
401
+ value: loc.code,
402
+ label: loc.label
403
+ })),
404
+ onValueChange: (value)=>{
405
+ if (null != value) setCopyTargetLocale(value);
406
+ },
407
+ disabled: copyToLocaleBusy
408
+ })
409
+ ]
410
+ }),
411
+ /*#__PURE__*/ jsx("div", {
412
+ className: classnames('byline-form-actions-copy-row', document_actions_module["copy-row"]),
413
+ style: {
414
+ marginTop: 'var(--spacing-16)'
415
+ },
416
+ children: /*#__PURE__*/ jsx(Checkbox, {
417
+ id: "copy-to-locale-overwrite",
418
+ name: "overwrite",
419
+ label: "Overwrite existing field data in target locale",
420
+ checked: copyOverwrite,
421
+ disabled: copyToLocaleBusy,
422
+ helpText: "Unchecked: only fill in target fields that are currently empty. Checked: replace every translated field with the source's value.",
423
+ onCheckedChange: (value)=>{
424
+ setCopyOverwrite(true === value);
425
+ }
426
+ })
427
+ })
428
+ ]
429
+ }),
430
+ /*#__PURE__*/ jsxs(Modal.Actions, {
431
+ children: [
432
+ /*#__PURE__*/ jsx("button", {
433
+ "data-autofocus": true,
434
+ type: "button",
435
+ tabIndex: 0,
436
+ className: classnames('byline-form-actions-sr-only', document_actions_module["sr-only"]),
437
+ children: "no action"
438
+ }),
439
+ /*#__PURE__*/ jsx(Button, {
440
+ size: "sm",
441
+ style: {
442
+ minWidth: '80px'
443
+ },
444
+ intent: "noeffect",
445
+ onClick: ()=>{
446
+ if (!copyToLocaleBusy) setShowCopyToLocaleConfirm(false);
447
+ },
448
+ disabled: copyToLocaleBusy,
449
+ children: "Cancel"
450
+ }),
451
+ /*#__PURE__*/ jsx(Button, {
452
+ size: "sm",
453
+ style: {
454
+ minWidth: '80px'
455
+ },
456
+ intent: "primary",
457
+ onClick: handleOnCopyToLocale,
458
+ disabled: copyToLocaleBusy || !copyTargetLocale,
459
+ children: copyToLocaleBusy ? 'Copying...' : 'Copy'
460
+ })
461
+ ]
462
+ })
463
+ ]
464
+ })
150
465
  })
151
466
  ]
152
467
  });
@@ -13,6 +13,22 @@ const document_actions_module = {
13
13
  "modal-title": "modal-title-e2sqE2",
14
14
  modalTitle: "modal-title-e2sqE2",
15
15
  "sr-only": "sr-only-VaKvc6",
16
- srOnly: "sr-only-VaKvc6"
16
+ srOnly: "sr-only-VaKvc6",
17
+ list: "list-TuqTao",
18
+ preview: "preview-oePPZR",
19
+ "preview-label": "preview-label-bvWDqG",
20
+ previewLabel: "preview-label-bvWDqG",
21
+ "preview-row": "preview-row-NyFKjX",
22
+ previewRow: "preview-row-NyFKjX",
23
+ "preview-before": "preview-before-tmxeNQ",
24
+ previewBefore: "preview-before-tmxeNQ",
25
+ "preview-arrow": "preview-arrow-plq330",
26
+ previewArrow: "preview-arrow-plq330",
27
+ "preview-after": "preview-after-pYlSUr",
28
+ previewAfter: "preview-after-pYlSUr",
29
+ "copy-row": "copy-row-EGqeNw",
30
+ copyRow: "copy-row-EGqeNw",
31
+ "copy-source": "copy-source-IV71b6",
32
+ copySource: "copy-source-IV71b6"
17
33
  };
18
34
  export default document_actions_module;
@@ -4,7 +4,7 @@
4
4
  }
5
5
 
6
6
  :is(.menu-IxqAyg, .byline-form-actions-menu) {
7
- min-width: 110px;
7
+ min-width: 140px;
8
8
  }
9
9
 
10
10
  :is(.item-k3szmy, .byline-form-actions-item) {
@@ -60,6 +60,58 @@
60
60
  overflow: hidden;
61
61
  }
62
62
 
63
+ :is(.list-TuqTao, .byline-form-actions-list) {
64
+ margin: var(--spacing-8) 0;
65
+ font-size: var(--font-size-sm);
66
+ padding-left: 1.25rem;
67
+ }
68
+
69
+ :is(.preview-oePPZR, .byline-form-actions-preview) {
70
+ margin-top: var(--spacing-16);
71
+ padding: var(--spacing-8) var(--spacing-12);
72
+ background: var(--surface-2, #00000008);
73
+ border: 1px solid var(--border-subtle, #00000014);
74
+ border-radius: var(--radius-md, 4px);
75
+ font-size: var(--font-size-sm);
76
+ }
77
+
78
+ :is(.preview-label-bvWDqG, .byline-form-actions-preview-label) {
79
+ margin-bottom: var(--spacing-4);
80
+ opacity: .75;
81
+ font-weight: 500;
82
+ }
83
+
84
+ :is(.preview-row-NyFKjX, .byline-form-actions-preview-row) {
85
+ align-items: center;
86
+ gap: var(--spacing-8);
87
+ flex-wrap: wrap;
88
+ display: flex;
89
+ }
90
+
91
+ :is(.preview-before-tmxeNQ, .byline-form-actions-preview-before) {
92
+ font-family: var(--font-mono, monospace);
93
+ opacity: .7;
94
+ }
95
+
96
+ :is(.preview-arrow-plq330, .byline-form-actions-preview-arrow) {
97
+ opacity: .5;
98
+ }
99
+
100
+ :is(.preview-after-pYlSUr, .byline-form-actions-preview-after) {
101
+ font-family: var(--font-mono, monospace);
102
+ font-weight: 500;
103
+ }
104
+
105
+ :is(.copy-row-EGqeNw, .byline-form-actions-copy-row) {
106
+ flex-wrap: wrap;
107
+ align-items: center;
108
+ display: flex;
109
+ }
110
+
111
+ :is(.copy-source-IV71b6, .byline-form-actions-copy-source) {
112
+ font-family: var(--font-mono, monospace);
113
+ }
114
+
63
115
  :is(:is([data-theme="dark"], .dark) .delete-xmSRLN, :is([data-theme="dark"], .dark) .byline-form-actions-delete) {
64
116
  color: var(--red-400);
65
117
  }
@@ -7,6 +7,7 @@
7
7
  */
8
8
  import { type ReactNode } from 'react';
9
9
  import type { CollectionAdminConfig, Field, WorkflowStatus } from '@byline/core';
10
+ import { type DocumentActionsLocaleOption } from './document-actions';
10
11
  import type { UseNavigationGuard } from './navigation-guard';
11
12
  /** Metadata about a previously published version that is still live. */
12
13
  export interface PublishedVersionInfo {
@@ -25,6 +26,30 @@ export interface FormRendererProps {
25
26
  onStatusChange?: (nextStatus: string) => Promise<void>;
26
27
  onUnpublish?: () => Promise<void>;
27
28
  onDelete?: () => Promise<void>;
29
+ /**
30
+ * Called when the editor confirms the duplicate modal in
31
+ * `DocumentActions`. Edit views provide a handler that invokes the
32
+ * `duplicateCollectionDocument` server fn and navigates to the new doc.
33
+ * When omitted, the Duplicate menu item is hidden.
34
+ */
35
+ onDuplicate?: () => Promise<void>;
36
+ /**
37
+ * Called when the editor confirms the Copy-to-Locale modal in
38
+ * `DocumentActions`. Edit views provide a handler that invokes the
39
+ * `copyDocumentToLocale` server fn and navigates to the target-locale
40
+ * view. When omitted (or when fewer than two `contentLocales` are
41
+ * configured), the Copy-to-Locale menu item is hidden.
42
+ */
43
+ onCopyToLocale?: (args: {
44
+ targetLocale: string;
45
+ overwrite: boolean;
46
+ }) => Promise<void>;
47
+ /**
48
+ * All configured content locales (code + display label) — required for
49
+ * the Copy-to-Locale modal's target Select. Threaded as an opaque list
50
+ * through to `DocumentActions`.
51
+ */
52
+ contentLocales?: ReadonlyArray<DocumentActionsLocaleOption>;
28
53
  nextStatus?: WorkflowStatus;
29
54
  workflowStatuses?: WorkflowStatus[];
30
55
  publishedVersion?: PublishedVersionInfo | null;
@@ -70,4 +95,4 @@ export interface FormRendererProps {
70
95
  */
71
96
  useNavigationGuard?: UseNavigationGuard;
72
97
  }
73
- export declare const FormRenderer: ({ mode, fields, onSubmit, onCancel, onStatusChange, onUnpublish, onDelete, nextStatus, workflowStatuses, publishedVersion, initialData, adminConfig, useAsTitle, useAsPath, headingLabel, headerSlot, collectionPath, initialLocale, onLocaleChange, defaultLocale, useNavigationGuard, restoreWarnings, }: FormRendererProps) => import("react").JSX.Element;
98
+ export declare const FormRenderer: ({ mode, fields, onSubmit, onCancel, onStatusChange, onUnpublish, onDelete, onDuplicate, onCopyToLocale, contentLocales, nextStatus, workflowStatuses, publishedVersion, initialData, adminConfig, useAsTitle, useAsPath, headingLabel, headerSlot, collectionPath, initialLocale, onLocaleChange, defaultLocale, useNavigationGuard, restoreWarnings, }: FormRendererProps) => import("react").JSX.Element;
@@ -139,7 +139,7 @@ function computeStatusTransitions(currentStatus, workflowStatuses, nextStatus) {
139
139
  secondaryStatuses
140
140
  };
141
141
  }
142
- const FormContent = ({ mode, fields, onSubmit, onCancel, onStatusChange, onUnpublish, onDelete, nextStatus, workflowStatuses, publishedVersion, initialData, adminConfig, useAsTitle, useAsPath, headingLabel, headerSlot, collectionPath, initialLocale, onLocaleChange, defaultLocale = 'en', useNavigationGuard: useNavigationGuardProp, restoreWarnings, _activeTabBySet, _onTabChange })=>{
142
+ const FormContent = ({ mode, fields, onSubmit, onCancel, onStatusChange, onUnpublish, onDelete, onDuplicate, onCopyToLocale, contentLocales, nextStatus, workflowStatuses, publishedVersion, initialData, adminConfig, useAsTitle, useAsPath, headingLabel, headerSlot, collectionPath, initialLocale, onLocaleChange, defaultLocale = 'en', useNavigationGuard: useNavigationGuardProp, restoreWarnings, _activeTabBySet, _onTabChange })=>{
143
143
  const { getFieldValues, runFieldHooks, validateForm, errors: initialErrors, hasChanges: hasChangesFn, resetHasChanges, getPatches, getSystemPath, subscribeErrors, subscribeMeta, setFieldValue, setFieldError, getPendingUploads, clearPendingUploads } = useFormContext();
144
144
  const [errors, setErrors] = useState(initialErrors);
145
145
  const [hasChanges, setHasChanges] = useState(hasChangesFn());
@@ -445,7 +445,12 @@ const FormContent = ({ mode, fields, onSubmit, onCancel, onStatusChange, onUnpub
445
445
  /*#__PURE__*/ jsx(DocumentActions, {
446
446
  publishedVersion: publishedVersion,
447
447
  onUnpublish: onUnpublish,
448
- onDelete: onDelete
448
+ onDelete: onDelete,
449
+ onDuplicate: onDuplicate,
450
+ sourceTitle: null != useAsTitle && null != initialData ? initialData[useAsTitle] : null,
451
+ onCopyToLocale: onCopyToLocale,
452
+ sourceLocale: contentLocale,
453
+ contentLocales: contentLocales
449
454
  })
450
455
  ]
451
456
  })
@@ -542,7 +547,7 @@ const FormContent = ({ mode, fields, onSubmit, onCancel, onStatusChange, onUnpub
542
547
  ]
543
548
  });
544
549
  };
545
- const FormRenderer = ({ mode, fields, onSubmit, onCancel, onStatusChange, onUnpublish, onDelete, nextStatus, workflowStatuses, publishedVersion, initialData, adminConfig, useAsTitle, useAsPath, headingLabel, headerSlot, collectionPath, initialLocale, onLocaleChange, defaultLocale, useNavigationGuard, restoreWarnings })=>{
550
+ const FormRenderer = ({ mode, fields, onSubmit, onCancel, onStatusChange, onUnpublish, onDelete, onDuplicate, onCopyToLocale, contentLocales, nextStatus, workflowStatuses, publishedVersion, initialData, adminConfig, useAsTitle, useAsPath, headingLabel, headerSlot, collectionPath, initialLocale, onLocaleChange, defaultLocale, useNavigationGuard, restoreWarnings })=>{
546
551
  const savedTabsRef = useRef({});
547
552
  return /*#__PURE__*/ jsx(FormProvider, {
548
553
  initialData: initialData,
@@ -554,6 +559,9 @@ const FormRenderer = ({ mode, fields, onSubmit, onCancel, onStatusChange, onUnpu
554
559
  onStatusChange: onStatusChange,
555
560
  onUnpublish: onUnpublish,
556
561
  onDelete: onDelete,
562
+ onDuplicate: onDuplicate,
563
+ onCopyToLocale: onCopyToLocale,
564
+ contentLocales: contentLocales,
557
565
  nextStatus: nextStatus,
558
566
  workflowStatuses: workflowStatuses,
559
567
  publishedVersion: publishedVersion,
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "private": false,
4
4
  "type": "module",
5
5
  "license": "MPL-2.0",
6
- "version": "1.9.1",
6
+ "version": "1.10.1",
7
7
  "engines": {
8
8
  "node": ">=20.9.0"
9
9
  },
@@ -65,9 +65,9 @@
65
65
  "react-diff-viewer-continued": "^4.2.2",
66
66
  "zod": "^4.4.2",
67
67
  "zod-form-data": "^3.0.1",
68
- "@byline/client": "1.9.1",
69
- "@byline/core": "1.9.1",
70
- "@byline/admin": "1.9.1"
68
+ "@byline/client": "1.10.1",
69
+ "@byline/core": "1.10.1",
70
+ "@byline/admin": "1.10.1"
71
71
  },
72
72
  "peerDependencies": {
73
73
  "react": "^19.0.0",
@@ -21,7 +21,7 @@
21
21
 
22
22
  .menu,
23
23
  :global(.byline-form-actions-menu) {
24
- min-width: 110px;
24
+ min-width: 140px;
25
25
  }
26
26
 
27
27
  .item,
@@ -84,6 +84,72 @@
84
84
  border: 0;
85
85
  }
86
86
 
87
+ .list,
88
+ :global(.byline-form-actions-list) {
89
+ margin: var(--spacing-8) 0;
90
+ padding-left: 1.25rem;
91
+ font-size: var(--font-size-sm);
92
+ }
93
+
94
+ .preview,
95
+ :global(.byline-form-actions-preview) {
96
+ margin-top: var(--spacing-16);
97
+ padding: var(--spacing-8) var(--spacing-12);
98
+ background: var(--surface-2, rgba(0, 0, 0, 0.03));
99
+ border: 1px solid var(--border-subtle, rgba(0, 0, 0, 0.08));
100
+ border-radius: var(--radius-md, 4px);
101
+ font-size: var(--font-size-sm);
102
+ }
103
+
104
+ .preview-label,
105
+ :global(.byline-form-actions-preview-label) {
106
+ font-weight: 500;
107
+ margin-bottom: var(--spacing-4);
108
+ opacity: 0.75;
109
+ }
110
+
111
+ .preview-row,
112
+ :global(.byline-form-actions-preview-row) {
113
+ display: flex;
114
+ align-items: center;
115
+ gap: var(--spacing-8);
116
+ flex-wrap: wrap;
117
+ }
118
+
119
+ .preview-before,
120
+ :global(.byline-form-actions-preview-before) {
121
+ font-family: var(--font-mono, monospace);
122
+ opacity: 0.7;
123
+ }
124
+
125
+ .preview-arrow,
126
+ :global(.byline-form-actions-preview-arrow) {
127
+ opacity: 0.5;
128
+ }
129
+
130
+ .preview-after,
131
+ :global(.byline-form-actions-preview-after) {
132
+ font-family: var(--font-mono, monospace);
133
+ font-weight: 500;
134
+ }
135
+
136
+ .copy-row,
137
+ :global(.byline-form-actions-copy-row) {
138
+ display: flex;
139
+ align-items: center;
140
+ flex-wrap: wrap;
141
+ }
142
+
143
+ .copy-label,
144
+ :global(.byline-form-actions-copy-label) {
145
+ /* Layout-only handle; spacing is set inline alongside the field. */
146
+ }
147
+
148
+ .copy-source,
149
+ :global(.byline-form-actions-copy-source) {
150
+ font-family: var(--font-mono, monospace);
151
+ }
152
+
87
153
  /* ─── Dark theme variants ───────────────────────────────────── */
88
154
 
89
155
  :is([data-theme="dark"], :global(.dark)) {
@@ -14,26 +14,89 @@ import cx from 'classnames'
14
14
 
15
15
  import {
16
16
  Button,
17
+ Checkbox,
17
18
  CloseIcon,
18
19
  DeleteIcon,
19
20
  Dropdown as DropdownComponent,
20
21
  EllipsisIcon,
21
22
  IconButton,
22
23
  Modal,
24
+ Select,
23
25
  } from '../uikit.js'
24
26
  import styles from './document-actions.module.css'
25
27
  import type { PublishedVersionInfo } from './form-renderer'
26
28
 
29
+ const DUPLICATE_TITLE_SUFFIX = ' (copy)'
30
+
31
+ /**
32
+ * Shape of a content-locale option as consumed by the Copy-to-Locale
33
+ * modal. Matches the host adapter's `ContentLocaleOption`; declared
34
+ * locally so this package does not take a dependency on host code.
35
+ */
36
+ export interface DocumentActionsLocaleOption {
37
+ code: string
38
+ label: string
39
+ }
40
+
27
41
  export function DocumentActions({
28
42
  publishedVersion,
29
43
  onUnpublish,
30
44
  onDelete,
45
+ onDuplicate,
46
+ sourceTitle,
47
+ onCopyToLocale,
48
+ sourceLocale,
49
+ contentLocales,
31
50
  }: {
32
51
  publishedVersion?: PublishedVersionInfo | null
33
52
  onUnpublish?: () => Promise<void>
34
53
  onDelete?: () => Promise<void>
54
+ /**
55
+ * Called when the editor confirms the duplicate modal. The parent runs
56
+ * the server fn, surfaces a toast, and navigates to the new document.
57
+ */
58
+ onDuplicate?: () => Promise<void>
59
+ /**
60
+ * The current (saved) value of the source document's `useAsTitle`
61
+ * field, used to render the suffix preview inside the duplicate modal.
62
+ * Sourced from the form's `initialData`, not live form state, so the
63
+ * preview reflects what will actually be duplicated.
64
+ */
65
+ sourceTitle?: string | null
66
+ /**
67
+ * Called when the editor confirms the Copy-to-Locale modal. The
68
+ * parent runs the server fn, surfaces a toast, and navigates to the
69
+ * target locale view. Menu item is hidden when omitted, or when fewer
70
+ * than two content locales are configured.
71
+ */
72
+ onCopyToLocale?: (args: { targetLocale: string; overwrite: boolean }) => Promise<void>
73
+ /**
74
+ * The locale the form is currently displaying. Used as the read-only
75
+ * "From" label in the Copy-to-Locale modal and excluded from the
76
+ * target Select.
77
+ */
78
+ sourceLocale?: string
79
+ /**
80
+ * All configured content locales (code + display label). The
81
+ * Copy-to-Locale Select lists every locale except `sourceLocale`.
82
+ */
83
+ contentLocales?: ReadonlyArray<DocumentActionsLocaleOption>
35
84
  }) {
36
85
  const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
86
+ const [showDuplicateConfirm, setShowDuplicateConfirm] = useState(false)
87
+ const [duplicateBusy, setDuplicateBusy] = useState(false)
88
+
89
+ // Copy-to-Locale modal state. The menu item is hidden entirely unless
90
+ // the host has supplied a handler AND there is at least one *other*
91
+ // locale to copy into.
92
+ const availableTargetLocales = (contentLocales ?? []).filter((loc) => loc.code !== sourceLocale)
93
+ const copyToLocaleAvailable = onCopyToLocale != null && availableTargetLocales.length > 0
94
+ const [showCopyToLocaleConfirm, setShowCopyToLocaleConfirm] = useState(false)
95
+ const [copyToLocaleBusy, setCopyToLocaleBusy] = useState(false)
96
+ const [copyTargetLocale, setCopyTargetLocale] = useState<string>(
97
+ availableTargetLocales[0]?.code ?? ''
98
+ )
99
+ const [copyOverwrite, setCopyOverwrite] = useState(false)
37
100
 
38
101
  const handleOnDelete = () => {
39
102
  setShowDeleteConfirm(false)
@@ -42,6 +105,45 @@ export function DocumentActions({
42
105
  }
43
106
  }
44
107
 
108
+ const handleOnDuplicate = async () => {
109
+ if (!onDuplicate) return
110
+ setDuplicateBusy(true)
111
+ try {
112
+ await onDuplicate()
113
+ setShowDuplicateConfirm(false)
114
+ } finally {
115
+ setDuplicateBusy(false)
116
+ }
117
+ }
118
+
119
+ const handleOpenCopyToLocale = () => {
120
+ // Reset on open: pick the first available target and clear the
121
+ // overwrite checkbox so a previous-session "overwrite=true" choice
122
+ // is not silently sticky.
123
+ setCopyTargetLocale(availableTargetLocales[0]?.code ?? '')
124
+ setCopyOverwrite(false)
125
+ setShowCopyToLocaleConfirm(true)
126
+ }
127
+
128
+ const handleOnCopyToLocale = async () => {
129
+ if (!onCopyToLocale || !copyTargetLocale) return
130
+ setCopyToLocaleBusy(true)
131
+ try {
132
+ await onCopyToLocale({ targetLocale: copyTargetLocale, overwrite: copyOverwrite })
133
+ setShowCopyToLocaleConfirm(false)
134
+ } finally {
135
+ setCopyToLocaleBusy(false)
136
+ }
137
+ }
138
+
139
+ // Preview text shown inside the modal. Falls back to the literal suffix
140
+ // when no source title is supplied (collections without `useAsTitle`).
141
+ const duplicatePreviewBefore = sourceTitle ?? ''
142
+ const duplicatePreviewAfter = (sourceTitle ?? '') + DUPLICATE_TITLE_SUFFIX
143
+
144
+ const sourceLocaleLabel =
145
+ contentLocales?.find((loc) => loc.code === sourceLocale)?.label ?? sourceLocale ?? ''
146
+
45
147
  return (
46
148
  <>
47
149
  <DropdownComponent.Root>
@@ -62,7 +164,7 @@ export function DocumentActions({
62
164
  data-side="top"
63
165
  sideOffset={10}
64
166
  >
65
- {publishedVersion && (
167
+ {/*{publishedVersion && (
66
168
  <>
67
169
  <DropdownComponent.Item onClick={onUnpublish}>
68
170
  <div className={cx('byline-form-actions-item', styles.item)}>
@@ -74,7 +176,30 @@ export function DocumentActions({
74
176
  </DropdownComponent.Item>
75
177
  <DropdownComponent.Separator />
76
178
  </>
179
+ )}*/}
180
+ {copyToLocaleAvailable && (
181
+ <DropdownComponent.Item onClick={handleOpenCopyToLocale}>
182
+ <div className={cx('byline-form-actions-item', styles.item)}>
183
+ <span className={cx('byline-form-actions-item-text', styles['item-text'])}>
184
+ <button type="button">Copy to Locale</button>
185
+ </span>
186
+ </div>
187
+ </DropdownComponent.Item>
188
+ )}
189
+ {onDuplicate && (
190
+ <DropdownComponent.Item
191
+ onClick={() => {
192
+ setShowDuplicateConfirm(true)
193
+ }}
194
+ >
195
+ <div className={cx('byline-form-actions-item', styles.item)}>
196
+ <span className={cx('byline-form-actions-item-text', styles['item-text'])}>
197
+ <button type="button">Duplicate</button>
198
+ </span>
199
+ </div>
200
+ </DropdownComponent.Item>
77
201
  )}
202
+ <DropdownComponent.Separator />
78
203
  <DropdownComponent.Item
79
204
  onClick={() => {
80
205
  setShowDeleteConfirm(true)
@@ -133,6 +258,7 @@ export function DocumentActions({
133
258
  </button>
134
259
  <Button
135
260
  size="sm"
261
+ style={{ minWidth: '80px' }}
136
262
  intent="noeffect"
137
263
  onClick={() => {
138
264
  setShowDeleteConfirm(false)
@@ -140,12 +266,222 @@ export function DocumentActions({
140
266
  >
141
267
  Cancel
142
268
  </Button>
143
- <Button size="sm" intent="danger" onClick={handleOnDelete}>
269
+ <Button size="sm" style={{ minWidth: '80px' }} intent="danger" onClick={handleOnDelete}>
144
270
  Delete
145
271
  </Button>
146
272
  </Modal.Actions>
147
273
  </Modal.Container>
148
274
  </Modal>
275
+
276
+ <Modal
277
+ isOpen={showDuplicateConfirm}
278
+ closeOnOverlayClick={!duplicateBusy}
279
+ onDismiss={() => {
280
+ if (!duplicateBusy) setShowDuplicateConfirm(false)
281
+ }}
282
+ >
283
+ <Modal.Container style={{ maxWidth: '560px' }}>
284
+ <Modal.Header className={cx('byline-form-actions-modal-head', styles['modal-head'])}>
285
+ <h3 className={cx('byline-form-actions-modal-title', styles['modal-title'])}>
286
+ Duplicate Document
287
+ </h3>
288
+ <IconButton
289
+ arial-label="Close"
290
+ size="xs"
291
+ onClick={() => {
292
+ if (!duplicateBusy) setShowDuplicateConfirm(false)
293
+ }}
294
+ >
295
+ <CloseIcon width="16px" height="16px" svgClassName="white-icon" />
296
+ </IconButton>
297
+ </Modal.Header>
298
+ <Modal.Content className="prose">
299
+ <p className="m-0">
300
+ A new document will be created with all translations (if any) cloned from this one.
301
+ After the duplicate is created you should:
302
+ </p>
303
+ <ul className={cx('byline-form-actions-list', styles.list)}>
304
+ <li>
305
+ Update the title of the docuiment (including any translated versions). The title is
306
+ currently suffixed with <code>{DUPLICATE_TITLE_SUFFIX.trim()}</code>.
307
+ </li>
308
+ <li>
309
+ Review the system path in the path widget — the auto-generated path will reflect the
310
+ suffixed title and is unlikely to be what you want long-term.
311
+ </li>
312
+ </ul>
313
+ {sourceTitle != null && sourceTitle.length > 0 && (
314
+ <div className={cx('byline-form-actions-preview', styles.preview)}>
315
+ <div className={cx('byline-form-actions-preview-label', styles['preview-label'])}>
316
+ Preview (current locale):
317
+ </div>
318
+ <div className={cx('byline-form-actions-preview-row', styles['preview-row'])}>
319
+ <span
320
+ className={cx('byline-form-actions-preview-before', styles['preview-before'])}
321
+ >
322
+ {duplicatePreviewBefore}
323
+ </span>
324
+ <span
325
+ className={cx('byline-form-actions-preview-arrow', styles['preview-arrow'])}
326
+ >
327
+
328
+ </span>
329
+ <span
330
+ className={cx('byline-form-actions-preview-after', styles['preview-after'])}
331
+ >
332
+ {duplicatePreviewAfter}
333
+ </span>
334
+ </div>
335
+ </div>
336
+ )}
337
+ </Modal.Content>
338
+ <Modal.Actions>
339
+ <button
340
+ data-autofocus
341
+ type="button"
342
+ tabIndex={0}
343
+ className={cx('byline-form-actions-sr-only', styles['sr-only'])}
344
+ >
345
+ no action
346
+ </button>
347
+ <Button
348
+ size="sm"
349
+ style={{ minWidth: '80px' }}
350
+ intent="noeffect"
351
+ onClick={() => {
352
+ if (!duplicateBusy) setShowDuplicateConfirm(false)
353
+ }}
354
+ disabled={duplicateBusy}
355
+ >
356
+ Cancel
357
+ </Button>
358
+ <Button
359
+ size="sm"
360
+ style={{ minWidth: '80px' }}
361
+ intent="primary"
362
+ onClick={handleOnDuplicate}
363
+ disabled={duplicateBusy}
364
+ >
365
+ {duplicateBusy ? 'Duplicating...' : 'Duplicate'}
366
+ </Button>
367
+ </Modal.Actions>
368
+ </Modal.Container>
369
+ </Modal>
370
+
371
+ <Modal
372
+ isOpen={showCopyToLocaleConfirm}
373
+ closeOnOverlayClick={!copyToLocaleBusy}
374
+ onDismiss={() => {
375
+ if (!copyToLocaleBusy) setShowCopyToLocaleConfirm(false)
376
+ }}
377
+ >
378
+ <Modal.Container style={{ maxWidth: '560px' }}>
379
+ <Modal.Header className={cx('byline-form-actions-modal-head', styles['modal-head'])}>
380
+ <h3 className={cx('byline-form-actions-modal-title', styles['modal-title'])}>
381
+ Copy to Locale
382
+ </h3>
383
+ <IconButton
384
+ arial-label="Close"
385
+ size="xs"
386
+ onClick={() => {
387
+ if (!copyToLocaleBusy) setShowCopyToLocaleConfirm(false)
388
+ }}
389
+ >
390
+ <CloseIcon width="16px" height="16px" svgClassName="white-icon" />
391
+ </IconButton>
392
+ </Modal.Header>
393
+ <Modal.Content>
394
+ <p>
395
+ Copy this document's content from one locale to another. Non-localized fields are
396
+ shared across locales and will not change.
397
+ </p>
398
+ <div
399
+ className={cx('byline-form-actions-copy-row', styles['copy-row'])}
400
+ style={{ marginTop: 'var(--spacing-12)' }}
401
+ >
402
+ <span
403
+ className={cx('byline-form-actions-copy-label', styles['copy-label'])}
404
+ style={{ fontWeight: 500 }}
405
+ >
406
+ From:&nbsp;
407
+ </span>
408
+ <span className={cx('byline-form-actions-copy-source', styles['copy-source'])}>
409
+ {sourceLocaleLabel}
410
+ </span>
411
+ </div>
412
+ <div
413
+ className={cx('byline-form-actions-copy-row', styles['copy-row'])}
414
+ style={{ marginTop: 'var(--spacing-12)' }}
415
+ >
416
+ <span
417
+ className={cx('byline-form-actions-copy-label', styles['copy-label'])}
418
+ style={{ fontWeight: 500, marginRight: 'var(--spacing-8)' }}
419
+ >
420
+ To:
421
+ </span>
422
+ <Select<string>
423
+ size="sm"
424
+ ariaLabel="Target locale"
425
+ value={copyTargetLocale}
426
+ items={availableTargetLocales.map((loc) => ({
427
+ value: loc.code,
428
+ label: loc.label,
429
+ }))}
430
+ onValueChange={(value) => {
431
+ if (value != null) setCopyTargetLocale(value)
432
+ }}
433
+ disabled={copyToLocaleBusy}
434
+ />
435
+ </div>
436
+ <div
437
+ className={cx('byline-form-actions-copy-row', styles['copy-row'])}
438
+ style={{ marginTop: 'var(--spacing-16)' }}
439
+ >
440
+ <Checkbox
441
+ id="copy-to-locale-overwrite"
442
+ name="overwrite"
443
+ label="Overwrite existing field data in target locale"
444
+ checked={copyOverwrite}
445
+ disabled={copyToLocaleBusy}
446
+ helpText="Unchecked: only fill in target fields that are currently empty. Checked: replace every translated field with the source's value."
447
+ onCheckedChange={(value) => {
448
+ setCopyOverwrite(value === true)
449
+ }}
450
+ />
451
+ </div>
452
+ </Modal.Content>
453
+ <Modal.Actions>
454
+ <button
455
+ data-autofocus
456
+ type="button"
457
+ tabIndex={0}
458
+ className={cx('byline-form-actions-sr-only', styles['sr-only'])}
459
+ >
460
+ no action
461
+ </button>
462
+ <Button
463
+ size="sm"
464
+ style={{ minWidth: '80px' }}
465
+ intent="noeffect"
466
+ onClick={() => {
467
+ if (!copyToLocaleBusy) setShowCopyToLocaleConfirm(false)
468
+ }}
469
+ disabled={copyToLocaleBusy}
470
+ >
471
+ Cancel
472
+ </Button>
473
+ <Button
474
+ size="sm"
475
+ style={{ minWidth: '80px' }}
476
+ intent="primary"
477
+ onClick={handleOnCopyToLocale}
478
+ disabled={copyToLocaleBusy || !copyTargetLocale}
479
+ >
480
+ {copyToLocaleBusy ? 'Copying...' : 'Copy'}
481
+ </Button>
482
+ </Modal.Actions>
483
+ </Modal.Container>
484
+ </Modal>
149
485
  </>
150
486
  )
151
487
  }
@@ -27,7 +27,7 @@ import { FieldRenderer } from '../fields/field-renderer'
27
27
  import { LocalDateTime } from '../fields/local-date-time'
28
28
  import { useBylineFieldServices } from '../services/field-services-context'
29
29
  import { Alert, Button, ComboButton, Modal } from '../uikit.js'
30
- import { DocumentActions } from './document-actions'
30
+ import { DocumentActions, type DocumentActionsLocaleOption } from './document-actions'
31
31
  import { FormProvider, useFieldValue, useFormContext } from './form-context'
32
32
  import styles from './form-renderer.module.css'
33
33
  import { useNavigationGuardAdapter } from './navigation-guard'
@@ -53,6 +53,27 @@ export interface FormRendererProps {
53
53
  onStatusChange?: (nextStatus: string) => Promise<void>
54
54
  onUnpublish?: () => Promise<void>
55
55
  onDelete?: () => Promise<void>
56
+ /**
57
+ * Called when the editor confirms the duplicate modal in
58
+ * `DocumentActions`. Edit views provide a handler that invokes the
59
+ * `duplicateCollectionDocument` server fn and navigates to the new doc.
60
+ * When omitted, the Duplicate menu item is hidden.
61
+ */
62
+ onDuplicate?: () => Promise<void>
63
+ /**
64
+ * Called when the editor confirms the Copy-to-Locale modal in
65
+ * `DocumentActions`. Edit views provide a handler that invokes the
66
+ * `copyDocumentToLocale` server fn and navigates to the target-locale
67
+ * view. When omitted (or when fewer than two `contentLocales` are
68
+ * configured), the Copy-to-Locale menu item is hidden.
69
+ */
70
+ onCopyToLocale?: (args: { targetLocale: string; overwrite: boolean }) => Promise<void>
71
+ /**
72
+ * All configured content locales (code + display label) — required for
73
+ * the Copy-to-Locale modal's target Select. Threaded as an opaque list
74
+ * through to `DocumentActions`.
75
+ */
76
+ contentLocales?: ReadonlyArray<DocumentActionsLocaleOption>
56
77
  nextStatus?: WorkflowStatus
57
78
  workflowStatuses?: WorkflowStatus[]
58
79
  publishedVersion?: PublishedVersionInfo | null
@@ -254,6 +275,9 @@ const FormContent = ({
254
275
  onStatusChange,
255
276
  onUnpublish,
256
277
  onDelete,
278
+ onDuplicate,
279
+ onCopyToLocale,
280
+ contentLocales,
257
281
  nextStatus,
258
282
  workflowStatuses,
259
283
  publishedVersion,
@@ -684,6 +708,18 @@ const FormContent = ({
684
708
  publishedVersion={publishedVersion}
685
709
  onUnpublish={onUnpublish}
686
710
  onDelete={onDelete}
711
+ onDuplicate={onDuplicate}
712
+ sourceTitle={
713
+ useAsTitle != null && initialData != null
714
+ ? ((initialData as Record<string, unknown>)[useAsTitle] as
715
+ | string
716
+ | null
717
+ | undefined)
718
+ : null
719
+ }
720
+ onCopyToLocale={onCopyToLocale}
721
+ sourceLocale={contentLocale}
722
+ contentLocales={contentLocales}
687
723
  />
688
724
  </div>
689
725
  </div>
@@ -767,6 +803,9 @@ export const FormRenderer = ({
767
803
  onStatusChange,
768
804
  onUnpublish,
769
805
  onDelete,
806
+ onDuplicate,
807
+ onCopyToLocale,
808
+ contentLocales,
770
809
  nextStatus,
771
810
  workflowStatuses,
772
811
  publishedVersion,
@@ -800,6 +839,9 @@ export const FormRenderer = ({
800
839
  onStatusChange={onStatusChange}
801
840
  onUnpublish={onUnpublish}
802
841
  onDelete={onDelete}
842
+ onDuplicate={onDuplicate}
843
+ onCopyToLocale={onCopyToLocale}
844
+ contentLocales={contentLocales}
803
845
  nextStatus={nextStatus}
804
846
  workflowStatuses={workflowStatuses}
805
847
  publishedVersion={publishedVersion}