@prenta/admin 2.1.0 → 2.3.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 (50) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/__tests__/components/batch-ai-actions.test.d.ts +2 -0
  3. package/dist/__tests__/components/batch-ai-actions.test.d.ts.map +1 -0
  4. package/dist/__tests__/components/batch-ai-actions.test.js +42 -0
  5. package/dist/__tests__/components/batch-ai-actions.test.js.map +1 -0
  6. package/dist/__tests__/components/document-locale-switcher.test.d.ts +2 -0
  7. package/dist/__tests__/components/document-locale-switcher.test.d.ts.map +1 -0
  8. package/dist/__tests__/components/document-locale-switcher.test.js +78 -0
  9. package/dist/__tests__/components/document-locale-switcher.test.js.map +1 -0
  10. package/dist/components/BatchAiActions.d.ts +15 -0
  11. package/dist/components/BatchAiActions.d.ts.map +1 -0
  12. package/dist/components/BatchAiActions.js +47 -0
  13. package/dist/components/BatchAiActions.js.map +1 -0
  14. package/dist/components/DocumentLocaleSwitcher.d.ts +15 -0
  15. package/dist/components/DocumentLocaleSwitcher.d.ts.map +1 -0
  16. package/dist/components/DocumentLocaleSwitcher.js +66 -0
  17. package/dist/components/DocumentLocaleSwitcher.js.map +1 -0
  18. package/dist/components/ui/Badge.d.ts +1 -1
  19. package/dist/components/ui/Button.d.ts +1 -1
  20. package/dist/prenta-admin.css +1 -1
  21. package/dist/views/CollectionList.d.ts.map +1 -1
  22. package/dist/views/CollectionList.js +2 -1
  23. package/dist/views/CollectionList.js.map +1 -1
  24. package/dist/views/Pages/PagesListView.d.ts.map +1 -1
  25. package/dist/views/Pages/PagesListView.js +4 -3
  26. package/dist/views/Pages/PagesListView.js.map +1 -1
  27. package/dist/views/Posts/PostsListView.d.ts.map +1 -1
  28. package/dist/views/Posts/PostsListView.js +4 -3
  29. package/dist/views/Posts/PostsListView.js.map +1 -1
  30. package/dist/views/page-editor/EditorTopBar.d.ts +4 -1
  31. package/dist/views/page-editor/EditorTopBar.d.ts.map +1 -1
  32. package/dist/views/page-editor/EditorTopBar.js +2 -2
  33. package/dist/views/page-editor/EditorTopBar.js.map +1 -1
  34. package/dist/views/page-editor/PageSectionEditor.d.ts.map +1 -1
  35. package/dist/views/page-editor/PageSectionEditor.js +2 -1
  36. package/dist/views/page-editor/PageSectionEditor.js.map +1 -1
  37. package/dist/views/post-editor/PostSectionEditor.d.ts.map +1 -1
  38. package/dist/views/post-editor/PostSectionEditor.js +2 -1
  39. package/dist/views/post-editor/PostSectionEditor.js.map +1 -1
  40. package/package.json +3 -3
  41. package/src/__tests__/components/batch-ai-actions.test.tsx +52 -0
  42. package/src/__tests__/components/document-locale-switcher.test.tsx +111 -0
  43. package/src/components/BatchAiActions.tsx +83 -0
  44. package/src/components/DocumentLocaleSwitcher.tsx +140 -0
  45. package/src/views/CollectionList.tsx +6 -0
  46. package/src/views/Pages/PagesListView.tsx +12 -0
  47. package/src/views/Posts/PostsListView.tsx +8 -0
  48. package/src/views/page-editor/EditorTopBar.tsx +4 -0
  49. package/src/views/page-editor/PageSectionEditor.tsx +12 -0
  50. package/src/views/post-editor/PostSectionEditor.tsx +12 -0
@@ -0,0 +1,111 @@
1
+ // @vitest-environment happy-dom
2
+ import { fireEvent, render, screen, waitFor } from '@testing-library/react'
3
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
4
+
5
+ import { DocumentLocaleSwitcher } from '../../components/DocumentLocaleSwitcher.js'
6
+ import { LocaleProvider } from '../../components/LocaleProvider.js'
7
+ import { cmsApi } from '../../lib/api.js'
8
+
9
+ vi.mock('../../lib/api.js', () => ({
10
+ cmsApi: vi.fn(),
11
+ }))
12
+
13
+ const cmsApiMock = vi.mocked(cmsApi)
14
+
15
+ const locales = {
16
+ i18n: {
17
+ defaultLocale: 'en',
18
+ locales: [
19
+ { code: 'en', label: 'English' },
20
+ { code: 'es', label: 'Spanish' },
21
+ ],
22
+ },
23
+ }
24
+
25
+ function renderSwitcher(onNavigate = vi.fn()) {
26
+ render(
27
+ <LocaleProvider config={locales}>
28
+ <DocumentLocaleSwitcher
29
+ collection="pages"
30
+ documentId="page-1"
31
+ canCreate
32
+ editorPath={(id) => `/pages/${id}/edit`}
33
+ onNavigate={onNavigate}
34
+ />
35
+ </LocaleProvider>,
36
+ )
37
+ return onNavigate
38
+ }
39
+
40
+ describe('DocumentLocaleSwitcher', () => {
41
+ beforeEach(() => {
42
+ cmsApiMock.mockReset()
43
+ cmsApiMock.mockResolvedValue({
44
+ data: {
45
+ locale: 'en',
46
+ translations: [{ id: 'page-1', locale: 'en', status: 'PUBLISHED', title: 'About' }],
47
+ },
48
+ status: 200,
49
+ })
50
+ })
51
+
52
+ it('hides itself when the site has a single locale', () => {
53
+ render(
54
+ <LocaleProvider
55
+ config={{ i18n: { defaultLocale: 'en', locales: [{ code: 'en', label: 'English' }] } }}
56
+ >
57
+ <DocumentLocaleSwitcher
58
+ collection="pages"
59
+ documentId="page-1"
60
+ canCreate
61
+ editorPath={(id) => `/pages/${id}/edit`}
62
+ onNavigate={vi.fn()}
63
+ />
64
+ </LocaleProvider>,
65
+ )
66
+ expect(screen.queryByRole('button', { name: /Translations/ })).toBeNull()
67
+ })
68
+
69
+ it('opens an existing translation', async () => {
70
+ cmsApiMock.mockResolvedValue({
71
+ data: {
72
+ locale: 'en',
73
+ translations: [
74
+ { id: 'page-1', locale: 'en', status: 'PUBLISHED', title: 'About' },
75
+ { id: 'page-es', locale: 'es', status: 'DRAFT', title: 'About' },
76
+ ],
77
+ },
78
+ status: 200,
79
+ })
80
+ const onNavigate = renderSwitcher()
81
+ await waitFor(() =>
82
+ expect(screen.getByRole('button', { name: 'Translations, English' })).toBeTruthy(),
83
+ )
84
+ fireEvent.keyDown(screen.getByRole('button', { name: 'Translations, English' }), {
85
+ key: 'Enter',
86
+ })
87
+ fireEvent.click(screen.getByRole('menuitem', { name: /Spanish/ }))
88
+ expect(onNavigate).toHaveBeenCalledWith('/pages/page-es/edit')
89
+ expect(cmsApiMock).toHaveBeenCalledTimes(1)
90
+ })
91
+
92
+ it('creates a draft and opens it', async () => {
93
+ const onNavigate = renderSwitcher()
94
+ await waitFor(() =>
95
+ expect(screen.getByRole('button', { name: 'Translations, English' })).toBeTruthy(),
96
+ )
97
+ cmsApiMock.mockResolvedValueOnce({
98
+ data: { id: 'page-es', locale: 'es', created: true },
99
+ status: 201,
100
+ })
101
+ fireEvent.keyDown(screen.getByRole('button', { name: 'Translations, English' }), {
102
+ key: 'Enter',
103
+ })
104
+ fireEvent.click(screen.getByRole('menuitem', { name: /Spanish/ }))
105
+ await waitFor(() => expect(onNavigate).toHaveBeenCalledWith('/pages/page-es/edit'))
106
+ expect(cmsApiMock).toHaveBeenLastCalledWith('/collections/pages/page-1/translations', {
107
+ method: 'POST',
108
+ body: JSON.stringify({ locale: 'es' }),
109
+ })
110
+ })
111
+ })
@@ -0,0 +1,83 @@
1
+ 'use client'
2
+
3
+ import { useState } from 'react'
4
+ import { toast } from 'sonner'
5
+ import { cmsApi } from '../lib/api.js'
6
+ import { ConfirmDialog } from './ui/ConfirmDialog.js'
7
+
8
+ type BatchOperation = 'meta' | 'alt-text' | 'summarize'
9
+
10
+ const LABELS: Record<BatchOperation, string> = {
11
+ meta: 'Meta descriptions',
12
+ 'alt-text': 'Alt text',
13
+ summarize: 'Summaries',
14
+ }
15
+
16
+ interface BatchAiActionsProps {
17
+ documents: Array<{ collection: string; id: string }>
18
+ onQueued?: () => void
19
+ buttonClassName?: string
20
+ }
21
+
22
+ /**
23
+ * Queues a list-view AI batch after a confirm that names the daily cap.
24
+ * The server writes field drafts and does not publish.
25
+ */
26
+ export function BatchAiActions({ documents, onQueued, buttonClassName }: BatchAiActionsProps) {
27
+ const [operation, setOperation] = useState<BatchOperation | null>(null)
28
+ const [busy, setBusy] = useState(false)
29
+ const buttonClass =
30
+ buttonClassName ??
31
+ 'text-foreground hover:bg-accent rounded-md px-2 py-1 text-sm transition-colors'
32
+
33
+ async function confirm() {
34
+ if (!operation || busy) return
35
+ setBusy(true)
36
+ const result = await cmsApi<{ jobId: string; count: number }>('/ai/batch', {
37
+ method: 'POST',
38
+ body: JSON.stringify({ operation, documents }),
39
+ })
40
+ setBusy(false)
41
+ setOperation(null)
42
+ if (result.error || !result.data?.jobId) {
43
+ toast.error(result.error ?? 'Could not queue the batch')
44
+ return
45
+ }
46
+ toast.success(
47
+ `Queued ${LABELS[operation].toLowerCase()} for ${result.data.count} ${result.data.count === 1 ? 'document' : 'documents'}`,
48
+ )
49
+ onQueued?.()
50
+ }
51
+
52
+ return (
53
+ <>
54
+ {(Object.keys(LABELS) as BatchOperation[]).map((key) => (
55
+ <button
56
+ key={key}
57
+ type="button"
58
+ disabled={busy || documents.length === 0}
59
+ onClick={() => setOperation(key)}
60
+ className={buttonClass}
61
+ >
62
+ {LABELS[key]}
63
+ </button>
64
+ ))}
65
+ <ConfirmDialog
66
+ open={operation !== null}
67
+ onClose={() => {
68
+ if (!busy) setOperation(null)
69
+ }}
70
+ onConfirm={() => {
71
+ void confirm()
72
+ }}
73
+ title={operation ? `Write ${LABELS[operation].toLowerCase()}?` : 'Write drafts?'}
74
+ description={
75
+ operation
76
+ ? `This queues ${LABELS[operation].toLowerCase()} for ${documents.length} selected ${documents.length === 1 ? 'document' : 'documents'}. Each document uses one request against the daily AI cap. Nothing is published.`
77
+ : ''
78
+ }
79
+ confirmLabel={busy ? 'Queuing…' : 'Queue drafts'}
80
+ />
81
+ </>
82
+ )
83
+ }
@@ -0,0 +1,140 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useState } from 'react'
4
+ import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
5
+ import { ChevronDown, Globe } from 'lucide-react'
6
+ import { toast } from 'sonner'
7
+ import { cmsApi } from '../lib/api.js'
8
+ import { adminPortalContainer } from '../lib/portal-container.js'
9
+ import { useLocale } from './LocaleProvider.js'
10
+
11
+ interface TranslationSibling {
12
+ id: string
13
+ locale: string | null
14
+ status: string
15
+ title: string | null
16
+ }
17
+
18
+ interface TranslationList {
19
+ locale: string | null
20
+ translations: TranslationSibling[]
21
+ }
22
+
23
+ interface TranslationDraft {
24
+ id: string
25
+ locale: string
26
+ created: boolean
27
+ }
28
+
29
+ interface DocumentLocaleSwitcherProps {
30
+ collection: string
31
+ documentId: string
32
+ canCreate: boolean
33
+ editorPath: (id: string) => string
34
+ onNavigate: (path: string) => void
35
+ }
36
+
37
+ /**
38
+ * Switches the open document to another locale, creating a draft translation
39
+ * when that locale does not exist yet. Hidden when the site has fewer than
40
+ * two locales — the header language control is a different setting.
41
+ */
42
+ export function DocumentLocaleSwitcher({
43
+ collection,
44
+ documentId,
45
+ canCreate,
46
+ editorPath,
47
+ onNavigate,
48
+ }: DocumentLocaleSwitcherProps) {
49
+ const { locales } = useLocale()
50
+ const enabled = locales.length > 1
51
+ const [currentLocale, setCurrentLocale] = useState<string | null>(null)
52
+ const [siblings, setSiblings] = useState<TranslationSibling[]>([])
53
+ const [busy, setBusy] = useState(false)
54
+
55
+ useEffect(() => {
56
+ if (!enabled) return
57
+ let cancelled = false
58
+ cmsApi<TranslationList>(
59
+ `/collections/${encodeURIComponent(collection)}/${encodeURIComponent(documentId)}/translations`,
60
+ ).then((result) => {
61
+ if (cancelled || !result.data) return
62
+ setCurrentLocale(result.data.locale)
63
+ setSiblings(result.data.translations)
64
+ })
65
+ return () => {
66
+ cancelled = true
67
+ }
68
+ }, [collection, documentId, enabled])
69
+
70
+ if (!enabled) return null
71
+
72
+ const current = locales.find((locale) => locale.code === currentLocale)
73
+ const label = current?.code.toUpperCase() ?? 'Locale'
74
+
75
+ async function choose(code: string) {
76
+ const sibling = siblings.find((row) => row.locale === code)
77
+ if (sibling) {
78
+ if (sibling.id !== documentId) onNavigate(editorPath(sibling.id))
79
+ return
80
+ }
81
+ if (!canCreate || busy) return
82
+ setBusy(true)
83
+ const result = await cmsApi<TranslationDraft>(
84
+ `/collections/${encodeURIComponent(collection)}/${encodeURIComponent(documentId)}/translations`,
85
+ { method: 'POST', body: JSON.stringify({ locale: code }) },
86
+ )
87
+ setBusy(false)
88
+ if (result.error || !result.data?.id) {
89
+ toast.error(result.error ?? 'Could not create the translation')
90
+ return
91
+ }
92
+ onNavigate(editorPath(result.data.id))
93
+ }
94
+
95
+ return (
96
+ <DropdownMenu.Root>
97
+ <DropdownMenu.Trigger asChild>
98
+ <button
99
+ type="button"
100
+ disabled={busy}
101
+ aria-label={`Translations, ${current?.label ?? 'locale not set'}`}
102
+ title="Open or create a translation"
103
+ className="text-muted-foreground hover:text-foreground hover:bg-accent inline-flex shrink-0 items-center gap-1 rounded-lg px-2 py-1 text-sm disabled:cursor-not-allowed disabled:opacity-50"
104
+ >
105
+ <Globe className="h-4 w-4" aria-hidden />
106
+ {label}
107
+ <ChevronDown className="h-3.5 w-3.5" aria-hidden />
108
+ </button>
109
+ </DropdownMenu.Trigger>
110
+ <DropdownMenu.Portal container={adminPortalContainer()}>
111
+ <DropdownMenu.Content
112
+ align="start"
113
+ sideOffset={6}
114
+ className="border-border bg-popover z-[120] min-w-40 rounded-lg border p-1 shadow-md"
115
+ >
116
+ {locales.map((locale) => {
117
+ const sibling = siblings.find((row) => row.locale === locale.code)
118
+ const isCurrent = locale.code === currentLocale
119
+ const canChoose = Boolean(sibling) || canCreate
120
+ return (
121
+ <DropdownMenu.Item
122
+ key={locale.code}
123
+ disabled={!canChoose || busy}
124
+ onSelect={() => {
125
+ void choose(locale.code)
126
+ }}
127
+ className="hover:bg-accent focus:bg-accent data-[disabled]:text-muted-foreground flex cursor-pointer items-center justify-between gap-3 rounded-md px-2.5 py-1.5 text-sm outline-none data-[disabled]:cursor-not-allowed"
128
+ >
129
+ <span>{locale.label}</span>
130
+ <span className="text-muted-foreground text-xs">
131
+ {isCurrent ? 'Current' : sibling ? sibling.status : 'New draft'}
132
+ </span>
133
+ </DropdownMenu.Item>
134
+ )
135
+ })}
136
+ </DropdownMenu.Content>
137
+ </DropdownMenu.Portal>
138
+ </DropdownMenu.Root>
139
+ )
140
+ }
@@ -12,6 +12,7 @@ import {
12
12
  } from 'lucide-react'
13
13
  import { useState, useMemo, useCallback, useEffect, useRef } from 'react'
14
14
  import { toast } from 'sonner'
15
+ import { BatchAiActions } from '../components/BatchAiActions.js'
15
16
  import { cmsApi } from '../lib/api.js'
16
17
  import { useApiData } from '../lib/useApiData.js'
17
18
  import { useVirtualWindow, VirtualTablePadding } from '../components/virtual-window.js'
@@ -501,6 +502,11 @@ export function CollectionList({ collectionSlug, config, onNavigate }: Collectio
501
502
  >
502
503
  Unpublish
503
504
  </button>
505
+ <BatchAiActions
506
+ documents={[...selected].map((id) => ({ collection: collectionSlug, id }))}
507
+ onQueued={() => setSelected(new Set())}
508
+ buttonClassName="rounded-lg px-3 py-1.5 text-sm"
509
+ />
504
510
  <button
505
511
  onClick={() => bulkAction('delete')}
506
512
  className="rounded-lg px-3 py-1.5 text-sm text-white"
@@ -19,6 +19,7 @@ import {
19
19
  X,
20
20
  } from 'lucide-react'
21
21
  import { toast } from 'sonner'
22
+ import { BatchAiActions } from '../../components/BatchAiActions.js'
22
23
  import { useVirtualWindow, VirtualTablePadding } from '../../components/virtual-window.js'
23
24
  import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
24
25
  import { adminPortalContainer } from '../../lib/portal-container.js'
@@ -667,6 +668,8 @@ export function PagesListView({ onNavigate }: PagesListViewProps) {
667
668
  onPublish={() => runBulkStatus('PUBLISHED')}
668
669
  onDraft={() => runBulkStatus('DRAFT')}
669
670
  onAddTag={(slug) => runBulkTag(slug, 'add')}
671
+ documents={selectedPages.map((page) => ({ collection: 'pages', id: page.id }))}
672
+ onQueued={() => setSelected(new Set())}
670
673
  onRemoveTag={(slug) => runBulkTag(slug, 'remove')}
671
674
  onDelete={() => setPendingDelete(true)}
672
675
  deletableCount={deletablePages.length}
@@ -1154,6 +1157,8 @@ function BulkActionBar({
1154
1157
  onAddTag,
1155
1158
  onRemoveTag,
1156
1159
  onDelete,
1160
+ documents,
1161
+ onQueued,
1157
1162
  }: {
1158
1163
  count: number
1159
1164
  tags: PageTag[]
@@ -1165,6 +1170,8 @@ function BulkActionBar({
1165
1170
  onAddTag: (slug: string) => void
1166
1171
  onRemoveTag: (slug: string) => void
1167
1172
  onDelete: () => void
1173
+ documents: Array<{ collection: string; id: string }>
1174
+ onQueued: () => void
1168
1175
  }) {
1169
1176
  return (
1170
1177
  <div className="border-primary/30 bg-primary/5 flex flex-wrap items-center gap-2 rounded-lg border px-4 py-2">
@@ -1186,6 +1193,11 @@ function BulkActionBar({
1186
1193
  </button>
1187
1194
  <TagMenu label="Add tag" tags={tags} onPick={onAddTag} />
1188
1195
  <TagMenu label="Remove tag" tags={tags} onPick={onRemoveTag} />
1196
+ <BatchAiActions
1197
+ documents={documents}
1198
+ onQueued={onQueued}
1199
+ buttonClassName="rounded-md px-2 py-1 text-sm text-[var(--txt)] transition-colors hover:bg-[var(--acc-l)]"
1200
+ />
1189
1201
  <button
1190
1202
  type="button"
1191
1203
  onClick={onDelete}
@@ -16,6 +16,7 @@ import {
16
16
  X,
17
17
  } from 'lucide-react'
18
18
  import { toast } from 'sonner'
19
+ import { BatchAiActions } from '../../components/BatchAiActions.js'
19
20
  import { useVirtualWindow, VirtualTablePadding } from '../../components/virtual-window.js'
20
21
  import {
21
22
  bulkDeletePosts,
@@ -345,6 +346,8 @@ export function PostsListView({ onNavigate }: PostsListViewProps) {
345
346
  onPublish={() => runBulkUpdate('PUBLISHED')}
346
347
  onDraft={() => runBulkUpdate('DRAFT')}
347
348
  onDelete={() => setPendingBulk('delete')}
349
+ documents={selectedPosts.map((post) => ({ collection: post.postTypeId, id: post.id }))}
350
+ onQueued={() => setSelected(new Set())}
348
351
  />
349
352
  )}
350
353
 
@@ -746,12 +749,16 @@ function BulkActionBar({
746
749
  onPublish,
747
750
  onDraft,
748
751
  onDelete,
752
+ documents,
753
+ onQueued,
749
754
  }: {
750
755
  count: number
751
756
  onClear: () => void
752
757
  onPublish: () => void
753
758
  onDraft: () => void
754
759
  onDelete: () => void
760
+ documents: Array<{ collection: string; id: string }>
761
+ onQueued: () => void
755
762
  }) {
756
763
  return (
757
764
  <div className="border-primary/30 bg-primary/5 flex items-center gap-2 rounded-lg border px-4 py-2">
@@ -771,6 +778,7 @@ function BulkActionBar({
771
778
  >
772
779
  Move to draft
773
780
  </button>
781
+ <BatchAiActions documents={documents} onQueued={onQueued} />
774
782
  <button
775
783
  type="button"
776
784
  onClick={onDelete}
@@ -106,6 +106,8 @@ interface EditorTopBarProps {
106
106
  onRedo?: () => void
107
107
  canUndo?: boolean
108
108
  canRedo?: boolean
109
+ /** Locale switcher for this document. Hidden by the caller when unused. */
110
+ localeControl?: ReactNode
109
111
  onSubmitForReview: () => void
110
112
  onApprove: () => void
111
113
  onRequestChanges: (note: string) => void
@@ -160,6 +162,7 @@ export function EditorTopBar({
160
162
  onRedo,
161
163
  canUndo = false,
162
164
  canRedo = false,
165
+ localeControl,
163
166
  onSubmitForReview,
164
167
  onApprove,
165
168
  onRequestChanges,
@@ -325,6 +328,7 @@ export function EditorTopBar({
325
328
  </button>
326
329
  </span>
327
330
  )}
331
+ {localeControl ? <span className="ml-1 inline-flex shrink-0">{localeControl}</span> : null}
328
332
  </div>
329
333
 
330
334
  {/* Right controls */}
@@ -9,6 +9,7 @@ import { SchedulePublishDialog } from '../../components/SchedulePublishDialog.js
9
9
  import { SeoEditorPane } from '../../components/seo/SeoEditorPane.js'
10
10
  import { EditorRail, RailHeader } from '../../components/EditorRail.js'
11
11
  import { EditorRailTabs, type RailTab, type RailTabId } from '../../components/EditorRailTabs.js'
12
+ import { DocumentLocaleSwitcher } from '../../components/DocumentLocaleSwitcher.js'
12
13
  import { ErrorBoundary } from '../../components/ErrorBoundary.js'
13
14
  import { VersionHistoryPanel } from './VersionHistoryPanel.js'
14
15
  import { useDebouncedAutosave } from './autosave.js'
@@ -993,6 +994,17 @@ export function PageSectionEditor({
993
994
  onRedo={effectiveCanEdit ? handleRedo : undefined}
994
995
  canUndo={canUndo}
995
996
  canRedo={canRedo}
997
+ localeControl={
998
+ page.id ? (
999
+ <DocumentLocaleSwitcher
1000
+ collection={PAGES_COLLECTION}
1001
+ documentId={page.id}
1002
+ canCreate={effectiveCanEdit}
1003
+ editorPath={(id) => `/pages/${id}/edit`}
1004
+ onNavigate={guardedNavigate}
1005
+ />
1006
+ ) : null
1007
+ }
996
1008
  onSubmitForReview={handleSubmitForReview}
997
1009
  onApprove={handleApprove}
998
1010
  onRequestChanges={handleRequestChanges}
@@ -9,6 +9,7 @@ import { SchedulePublishDialog } from '../../components/SchedulePublishDialog.js
9
9
  import { EditorRail, RailHeader } from '../../components/EditorRail.js'
10
10
  import { EditorRailTabs, type RailTab, type RailTabId } from '../../components/EditorRailTabs.js'
11
11
  import { SeoEditorPane } from '../../components/seo/SeoEditorPane.js'
12
+ import { DocumentLocaleSwitcher } from '../../components/DocumentLocaleSwitcher.js'
12
13
  import { ErrorBoundary } from '../../components/ErrorBoundary.js'
13
14
  import { VersionHistoryPanel } from '../page-editor/VersionHistoryPanel.js'
14
15
  import { useDebouncedAutosave } from '../page-editor/autosave.js'
@@ -864,6 +865,17 @@ export function PostSectionEditor({
864
865
  onRedo={effectiveCanEdit ? handleRedo : undefined}
865
866
  canUndo={canUndo}
866
867
  canRedo={canRedo}
868
+ localeControl={
869
+ post.id ? (
870
+ <DocumentLocaleSwitcher
871
+ collection={postType}
872
+ documentId={post.id}
873
+ canCreate={effectiveCanEdit}
874
+ editorPath={(id) => `/posts/${postType}/${id}/edit`}
875
+ onNavigate={guardedNavigate}
876
+ />
877
+ ) : null
878
+ }
867
879
  onSubmitForReview={handleSubmitForReview}
868
880
  onApprove={handleApprove}
869
881
  onRequestChanges={handleRequestChanges}