@prenta/admin 2.7.0 → 2.8.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 (163) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/design-system/tokens.css +17 -0
  3. package/design-system/tokens.json +35 -1
  4. package/design-system/tokens.scss +15 -0
  5. package/design-system/tokens.tailwind.cjs +14 -0
  6. package/dist/AdminRoot.d.ts.map +1 -1
  7. package/dist/AdminRoot.js +2 -2
  8. package/dist/AdminRoot.js.map +1 -1
  9. package/dist/__tests__/lib/batch-wizard.test.d.ts +2 -0
  10. package/dist/__tests__/lib/batch-wizard.test.d.ts.map +1 -0
  11. package/dist/__tests__/lib/batch-wizard.test.js +144 -0
  12. package/dist/__tests__/lib/batch-wizard.test.js.map +1 -0
  13. package/dist/__tests__/lib/job-drawer.test.js +2 -0
  14. package/dist/__tests__/lib/job-drawer.test.js.map +1 -1
  15. package/dist/__tests__/lib/job-view.test.js +100 -3
  16. package/dist/__tests__/lib/job-view.test.js.map +1 -1
  17. package/dist/__tests__/lib/jobs-service.test.js +56 -2
  18. package/dist/__tests__/lib/jobs-service.test.js.map +1 -1
  19. package/dist/__tests__/views/appearance-chrome.render.test.d.ts +2 -0
  20. package/dist/__tests__/views/appearance-chrome.render.test.d.ts.map +1 -0
  21. package/dist/__tests__/views/appearance-chrome.render.test.js +688 -0
  22. package/dist/__tests__/views/appearance-chrome.render.test.js.map +1 -0
  23. package/dist/__tests__/views/header-footer-form.test.d.ts +2 -0
  24. package/dist/__tests__/views/header-footer-form.test.d.ts.map +1 -0
  25. package/dist/__tests__/views/header-footer-form.test.js +266 -0
  26. package/dist/__tests__/views/header-footer-form.test.js.map +1 -0
  27. package/dist/__tests__/views/jobs-drawer.render.test.js +442 -2
  28. package/dist/__tests__/views/jobs-drawer.render.test.js.map +1 -1
  29. package/dist/__tests__/views/jobs-list.render.test.js +7 -5
  30. package/dist/__tests__/views/jobs-list.render.test.js.map +1 -1
  31. package/dist/__tests__/views/jobs-wizard.render.test.d.ts +2 -0
  32. package/dist/__tests__/views/jobs-wizard.render.test.d.ts.map +1 -0
  33. package/dist/__tests__/views/jobs-wizard.render.test.js +588 -0
  34. package/dist/__tests__/views/jobs-wizard.render.test.js.map +1 -0
  35. package/dist/lib/api.d.ts +2 -0
  36. package/dist/lib/api.d.ts.map +1 -1
  37. package/dist/lib/api.js +8 -1
  38. package/dist/lib/api.js.map +1 -1
  39. package/dist/lib/batch-wizard.d.ts +84 -0
  40. package/dist/lib/batch-wizard.d.ts.map +1 -0
  41. package/dist/lib/batch-wizard.js +122 -0
  42. package/dist/lib/batch-wizard.js.map +1 -0
  43. package/dist/lib/chrome-service.d.ts +69 -0
  44. package/dist/lib/chrome-service.d.ts.map +1 -0
  45. package/dist/lib/chrome-service.js +108 -0
  46. package/dist/lib/chrome-service.js.map +1 -0
  47. package/dist/lib/job-view.d.ts +43 -0
  48. package/dist/lib/job-view.d.ts.map +1 -1
  49. package/dist/lib/job-view.js +57 -1
  50. package/dist/lib/job-view.js.map +1 -1
  51. package/dist/lib/jobs-service.d.ts +19 -1
  52. package/dist/lib/jobs-service.d.ts.map +1 -1
  53. package/dist/lib/jobs-service.js +41 -0
  54. package/dist/lib/jobs-service.js.map +1 -1
  55. package/dist/prenta-admin.css +1 -1
  56. package/dist/views/AppearanceChrome.d.ts +22 -4
  57. package/dist/views/AppearanceChrome.d.ts.map +1 -1
  58. package/dist/views/AppearanceChrome.js +451 -15
  59. package/dist/views/AppearanceChrome.js.map +1 -1
  60. package/dist/views/Jobs/CancelJobDialog.d.ts +7 -1
  61. package/dist/views/Jobs/CancelJobDialog.d.ts.map +1 -1
  62. package/dist/views/Jobs/CancelJobDialog.js +13 -3
  63. package/dist/views/Jobs/CancelJobDialog.js.map +1 -1
  64. package/dist/views/Jobs/DrawerReview.d.ts +7 -0
  65. package/dist/views/Jobs/DrawerReview.d.ts.map +1 -1
  66. package/dist/views/Jobs/DrawerReview.js +1 -1
  67. package/dist/views/Jobs/DrawerReview.js.map +1 -1
  68. package/dist/views/Jobs/JobDrawer.d.ts +23 -3
  69. package/dist/views/Jobs/JobDrawer.d.ts.map +1 -1
  70. package/dist/views/Jobs/JobDrawer.js +30 -5
  71. package/dist/views/Jobs/JobDrawer.js.map +1 -1
  72. package/dist/views/Jobs/JobsTable.d.ts +4 -2
  73. package/dist/views/Jobs/JobsTable.d.ts.map +1 -1
  74. package/dist/views/Jobs/JobsTable.js +6 -5
  75. package/dist/views/Jobs/JobsTable.js.map +1 -1
  76. package/dist/views/Jobs/NewBatchJob.d.ts +34 -0
  77. package/dist/views/Jobs/NewBatchJob.d.ts.map +1 -0
  78. package/dist/views/Jobs/NewBatchJob.js +282 -0
  79. package/dist/views/Jobs/NewBatchJob.js.map +1 -0
  80. package/dist/views/Jobs/WorkerStrip.d.ts +3 -1
  81. package/dist/views/Jobs/WorkerStrip.d.ts.map +1 -1
  82. package/dist/views/Jobs/WorkerStrip.js +2 -2
  83. package/dist/views/Jobs/WorkerStrip.js.map +1 -1
  84. package/dist/views/Jobs/focus.d.ts +6 -0
  85. package/dist/views/Jobs/focus.d.ts.map +1 -1
  86. package/dist/views/Jobs/focus.js +10 -0
  87. package/dist/views/Jobs/focus.js.map +1 -1
  88. package/dist/views/Jobs.d.ts +12 -3
  89. package/dist/views/Jobs.d.ts.map +1 -1
  90. package/dist/views/Jobs.js +109 -31
  91. package/dist/views/Jobs.js.map +1 -1
  92. package/dist/views/header-footer/AnnouncementTab.d.ts +15 -0
  93. package/dist/views/header-footer/AnnouncementTab.d.ts.map +1 -0
  94. package/dist/views/header-footer/AnnouncementTab.js +43 -0
  95. package/dist/views/header-footer/AnnouncementTab.js.map +1 -0
  96. package/dist/views/header-footer/ChromeField.d.ts +25 -0
  97. package/dist/views/header-footer/ChromeField.d.ts.map +1 -0
  98. package/dist/views/header-footer/ChromeField.js +99 -0
  99. package/dist/views/header-footer/ChromeField.js.map +1 -0
  100. package/dist/views/header-footer/ChromePreview.d.ts +31 -0
  101. package/dist/views/header-footer/ChromePreview.d.ts.map +1 -0
  102. package/dist/views/header-footer/ChromePreview.js +195 -0
  103. package/dist/views/header-footer/ChromePreview.js.map +1 -0
  104. package/dist/views/header-footer/DeveloperTab.d.ts +17 -0
  105. package/dist/views/header-footer/DeveloperTab.d.ts.map +1 -0
  106. package/dist/views/header-footer/DeveloperTab.js +98 -0
  107. package/dist/views/header-footer/DeveloperTab.js.map +1 -0
  108. package/dist/views/header-footer/RegionTab.d.ts +27 -0
  109. package/dist/views/header-footer/RegionTab.d.ts.map +1 -0
  110. package/dist/views/header-footer/RegionTab.js +80 -0
  111. package/dist/views/header-footer/RegionTab.js.map +1 -0
  112. package/dist/views/header-footer/chrome-form.d.ts +137 -0
  113. package/dist/views/header-footer/chrome-form.d.ts.map +1 -0
  114. package/dist/views/header-footer/chrome-form.js +518 -0
  115. package/dist/views/header-footer/chrome-form.js.map +1 -0
  116. package/dist/views/header-footer/code-samples.d.ts +30 -0
  117. package/dist/views/header-footer/code-samples.d.ts.map +1 -0
  118. package/dist/views/header-footer/code-samples.js +127 -0
  119. package/dist/views/header-footer/code-samples.js.map +1 -0
  120. package/dist/views/header-footer/controls.d.ts +57 -0
  121. package/dist/views/header-footer/controls.d.ts.map +1 -0
  122. package/dist/views/header-footer/controls.js +43 -0
  123. package/dist/views/header-footer/controls.js.map +1 -0
  124. package/package.json +3 -3
  125. package/src/AdminRoot.tsx +16 -2
  126. package/src/__tests__/lib/batch-wizard.test.ts +199 -0
  127. package/src/__tests__/lib/job-drawer.test.ts +2 -0
  128. package/src/__tests__/lib/job-view.test.ts +123 -2
  129. package/src/__tests__/lib/jobs-service.test.ts +72 -3
  130. package/src/__tests__/views/appearance-chrome.render.test.tsx +818 -0
  131. package/src/__tests__/views/header-footer-form.test.ts +333 -0
  132. package/src/__tests__/views/jobs-drawer.render.test.tsx +508 -2
  133. package/src/__tests__/views/jobs-list.render.test.tsx +8 -5
  134. package/src/__tests__/views/jobs-wizard.render.test.tsx +707 -0
  135. package/src/lib/api.ts +12 -1
  136. package/src/lib/batch-wizard.ts +188 -0
  137. package/src/lib/chrome-service.ts +200 -0
  138. package/src/lib/job-view.ts +95 -1
  139. package/src/lib/jobs-service.ts +56 -1
  140. package/src/styles/prenta-tokens.css +17 -0
  141. package/src/styles/theme.css +15 -0
  142. package/src/views/AppearanceChrome.tsx +704 -46
  143. package/src/views/Jobs/CancelJobDialog.tsx +20 -2
  144. package/src/views/Jobs/DrawerReview.tsx +1 -1
  145. package/src/views/Jobs/JobDrawer.tsx +83 -3
  146. package/src/views/Jobs/JobsTable.tsx +11 -4
  147. package/src/views/Jobs/NewBatchJob.tsx +857 -0
  148. package/src/views/Jobs/WorkerStrip.tsx +16 -11
  149. package/src/views/Jobs/focus.ts +11 -0
  150. package/src/views/Jobs.tsx +163 -49
  151. package/src/views/header-footer/AnnouncementTab.tsx +231 -0
  152. package/src/views/header-footer/ChromeField.tsx +358 -0
  153. package/src/views/header-footer/ChromePreview.tsx +497 -0
  154. package/src/views/header-footer/DeveloperTab.tsx +331 -0
  155. package/src/views/header-footer/RegionTab.tsx +398 -0
  156. package/src/views/header-footer/chrome-form.ts +603 -0
  157. package/src/views/header-footer/code-samples.ts +146 -0
  158. package/src/views/header-footer/controls.tsx +166 -0
  159. package/dist/views/Jobs/placeholders.d.ts +0 -6
  160. package/dist/views/Jobs/placeholders.d.ts.map +0 -1
  161. package/dist/views/Jobs/placeholders.js +0 -20
  162. package/dist/views/Jobs/placeholders.js.map +0 -1
  163. package/src/views/Jobs/placeholders.tsx +0 -66
@@ -1,38 +1,691 @@
1
1
  'use client'
2
2
 
3
- import { useMemo, useState } from 'react'
4
- import type { RegionSchema } from '@prenta/core'
3
+ import { Code } from 'lucide-react'
4
+ import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
5
+ import { toast } from 'sonner'
6
+ import type {
7
+ Announcement,
8
+ ChromeOverridesList,
9
+ ChromeRegionName,
10
+ ChromeSettings,
11
+ ChromeSyncStatus,
12
+ RegionSchema,
13
+ RegionValues,
14
+ } from '@prenta/core'
15
+
5
16
  import { Breadcrumb } from '../components/ui/Breadcrumb.js'
17
+ import { Button } from '../components/ui/Button.js'
6
18
  import { PageHeader } from '../components/ui/PageHeader.js'
7
- import { UnderlineTabs } from '../components/ui/UnderlineTabs.js'
19
+ import { UnderlineTabPanel, UnderlineTabs } from '../components/ui/UnderlineTabs.js'
20
+ import { UnsavedBar } from '../components/ui/UnsavedBar.js'
21
+ import { useAdminLinks } from '../components/ui/admin-links.js'
22
+ import {
23
+ fetchAnnouncement,
24
+ fetchChromeMenus,
25
+ fetchChromeOverrides,
26
+ fetchChromeSchema,
27
+ fetchChromeSettings,
28
+ fetchRegion,
29
+ fetchSiteTitle,
30
+ removeOrphan,
31
+ saveAnnouncement,
32
+ saveChromeSettings,
33
+ saveRegion,
34
+ type ChromeMenuOption,
35
+ } from '../lib/chrome-service.js'
36
+ import { editPathForDoc } from '../lib/collection-routes.js'
37
+ import { parseRouteQuery } from '../router/index.js'
38
+ import { AnnouncementTab } from './header-footer/AnnouncementTab.js'
39
+ import { ChromePreview } from './header-footer/ChromePreview.js'
40
+ import { DeveloperTab, type ChromeSettingsForm } from './header-footer/DeveloperTab.js'
41
+ import { RegionTab } from './header-footer/RegionTab.js'
42
+ import {
43
+ announcementForm,
44
+ announcementProblem,
45
+ announcementSaveBody,
46
+ previewChrome,
47
+ regionForm,
48
+ regionProblems,
49
+ regionSaveBody,
50
+ sameValue,
51
+ type AnnouncementForm,
52
+ type RegionForm,
53
+ } from './header-footer/chrome-form.js'
54
+ import { CARD } from './header-footer/controls.js'
8
55
 
9
- // Placeholder until P-11 builds the schema-driven screen on `/chrome/*`.
10
- const NO_REGIONS: RegionSchema[] = []
56
+ type TabId = 'announcement' | 'header' | 'footer' | 'developer'
57
+ const TAB_IDS: readonly TabId[] = ['announcement', 'header', 'footer', 'developer']
58
+ const REGIONS: readonly ChromeRegionName[] = ['header', 'footer']
59
+ const REGION_LABEL: Record<ChromeRegionName, string> = { header: 'Header', footer: 'Footer' }
60
+ const TAB_PATH = '/appearance/header'
11
61
 
12
- export function AppearanceChrome({
13
- regions = NO_REGIONS,
14
- }: {
15
- regions?: RegionSchema[]
62
+ /** Saving values, the announcement or "Remove now" needs a role that can publish. */
63
+ const EDIT_ROLES = new Set(['OWNER', 'ADMIN', 'EDITOR'])
64
+ /** The Developer tab's settings are admin-only. */
65
+ const ADMIN_ROLES = new Set(['OWNER', 'ADMIN'])
66
+
67
+ interface RegionState {
68
+ schema: RegionSchema
69
+ values: RegionValues
70
+ etag: string | null
71
+ saved: RegionForm
72
+ draft: RegionForm
73
+ }
74
+
75
+ interface AnnouncementState {
76
+ value: Announcement
77
+ etag: string | null
78
+ saved: AnnouncementForm
79
+ draft: AnnouncementForm
80
+ }
81
+
82
+ interface SettingsState {
83
+ saved: ChromeSettingsForm
84
+ draft: ChromeSettingsForm
85
+ previewUrl: string | null
86
+ }
87
+
88
+ interface Loaded {
89
+ sync: ChromeSyncStatus | null
90
+ regions: Partial<Record<ChromeRegionName, RegionState>>
91
+ /** A region the schema has whose values didn't load. */
92
+ regionErrors: Partial<Record<ChromeRegionName, string>>
93
+ announcement: AnnouncementState | null
94
+ settings: SettingsState | null
95
+ /** `unavailable` on a core without the endpoint; `error` when it failed. */
96
+ overrides: ChromeOverridesList | 'unavailable' | 'error'
97
+ menus: ChromeMenuOption[]
98
+ siteTitle: string
99
+ }
100
+
101
+ function settingsForm(settings: ChromeSettings): ChromeSettingsForm {
102
+ return { revalidateUrl: settings.revalidateUrl ?? '', previewUrl: settings.previewUrl ?? '' }
103
+ }
104
+
105
+ function settingsProblem(form: ChromeSettingsForm): string | null {
106
+ for (const [label, value] of [
107
+ ['Revalidate URL', form.revalidateUrl],
108
+ ['Preview URL', form.previewUrl],
109
+ ] as const) {
110
+ const url = value.trim()
111
+ if (url && !/^https?:\/\/\S+$/i.test(url)) return `${label} must be an http(s) URL.`
112
+ }
113
+ return null
114
+ }
115
+
116
+ /** Whether the site shows the saved announcement now: on, and inside its schedule. */
117
+ function isLive(announcement: Announcement, now: number): boolean {
118
+ if (!announcement.on || !announcement.text.trim()) return false
119
+ const startsAt = announcement.schedule?.startsAt
120
+ const endsAt = announcement.schedule?.endsAt
121
+ if (startsAt && Date.parse(startsAt) > now) return false
122
+ if (endsAt && Date.parse(endsAt) <= now) return false
123
+ return true
124
+ }
125
+
126
+ function readTab(): TabId | null {
127
+ if (typeof window === 'undefined') return null
128
+ const tab = parseRouteQuery(window.location.search).get('tab')
129
+ return TAB_IDS.includes(tab as TabId) ? (tab as TabId) : null
130
+ }
131
+
132
+ export interface AppearanceChromeProps {
16
133
  onNavigate?: (path: string) => void
17
- }) {
18
- const tabs = useMemo(
19
- () => [
20
- { id: 'announcement', label: 'Announcement' },
21
- ...regions.map((region) => ({ id: region.region, label: region.region })),
22
- { id: 'developer', label: 'Developer' },
23
- ],
24
- [regions],
134
+ /** Current admin session, to make the screen read-only for roles that can't save. */
135
+ session?: { user?: { role?: string } } | null
136
+ /** The admin's collections by slug, for "Edit menus" and the page override links. */
137
+ collections?: ReadonlyMap<string, { slug: string; type?: string }>
138
+ /** Router path including the query string, so `?tab=` deep links follow navigation. */
139
+ currentPath?: string
140
+ }
141
+
142
+ /**
143
+ * Appearance → Header & Footer (APPEARANCE.md, `appearance-hf.jsx`). The
144
+ * header and footer are built in the site's code; this screen fills in the
145
+ * values its `defineRegion` schema exposes, and runs the CMS-owned
146
+ * announcement bar. Every form is generated from the schema.
147
+ */
148
+ export function AppearanceChrome({
149
+ onNavigate,
150
+ session,
151
+ collections,
152
+ currentPath,
153
+ }: AppearanceChromeProps) {
154
+ const links = useAdminLinks()
155
+ const navigate = useCallback(
156
+ (path: string) => (onNavigate ?? links.navigate)?.(path),
157
+ [onNavigate, links],
158
+ )
159
+ const role = session?.user?.role
160
+ // With no session (embedded or tests) the server still enforces access.
161
+ const readOnly = role ? !EDIT_ROLES.has(role) : false
162
+ const canEditSettings = role ? ADMIN_ROLES.has(role) : false
163
+
164
+ const [tab, setTab] = useState<TabId>(() => readTab() ?? 'announcement')
165
+ const [loaded, setLoaded] = useState<Loaded | null>(null)
166
+ const [loadError, setLoadError] = useState<string | null>(null)
167
+ const [saving, setSaving] = useState(false)
168
+ const [removing, setRemoving] = useState<ChromeRegionName | null>(null)
169
+ const [secret, setSecret] = useState<string | null>(null)
170
+ const [now, setNow] = useState(() => Date.now())
171
+
172
+ useEffect(() => {
173
+ function sync() {
174
+ const fromUrl = readTab()
175
+ if (fromUrl) setTab(fromUrl)
176
+ }
177
+ sync()
178
+ window.addEventListener('popstate', sync)
179
+ return () => window.removeEventListener('popstate', sync)
180
+ }, [currentPath])
181
+
182
+ // "2 hours ago" stays current while the screen is open.
183
+ useEffect(() => {
184
+ const timer = setInterval(() => setNow(Date.now()), 60_000)
185
+ return () => clearInterval(timer)
186
+ }, [])
187
+
188
+ const load = useCallback(async () => {
189
+ setLoadError(null)
190
+ const [schema, announcement, settings, overrides, menus, siteTitle] = await Promise.all([
191
+ fetchChromeSchema(),
192
+ fetchAnnouncement(),
193
+ fetchChromeSettings(),
194
+ fetchChromeOverrides(),
195
+ fetchChromeMenus(),
196
+ fetchSiteTitle(),
197
+ ])
198
+ if (!schema.ok) {
199
+ setLoadError(schema.error)
200
+ return
201
+ }
202
+ const regions: Loaded['regions'] = {}
203
+ const regionErrors: Loaded['regionErrors'] = {}
204
+ await Promise.all(
205
+ schema.value.regions
206
+ .filter((region) => REGIONS.includes(region.region))
207
+ .map(async (region) => {
208
+ const values = await fetchRegion(region.region)
209
+ if (!values.ok) {
210
+ regionErrors[region.region] = values.error
211
+ return
212
+ }
213
+ const form = regionForm(region, values.value)
214
+ regions[region.region] = {
215
+ schema: region,
216
+ values: values.value,
217
+ etag: values.etag,
218
+ saved: form,
219
+ draft: form,
220
+ }
221
+ }),
222
+ )
223
+ setLoaded({
224
+ sync: schema.value.sync,
225
+ regions,
226
+ regionErrors,
227
+ announcement: announcement.ok
228
+ ? {
229
+ value: announcement.value,
230
+ etag: announcement.etag,
231
+ saved: announcementForm(announcement.value),
232
+ draft: announcementForm(announcement.value),
233
+ }
234
+ : null,
235
+ settings: settings.ok
236
+ ? {
237
+ saved: settingsForm(settings.value),
238
+ draft: settingsForm(settings.value),
239
+ previewUrl: settings.value.previewUrl,
240
+ }
241
+ : null,
242
+ overrides: overrides.ok
243
+ ? overrides.value
244
+ : overrides.status === 404
245
+ ? 'unavailable'
246
+ : 'error',
247
+ menus,
248
+ siteTitle: siteTitle ?? 'Your site',
249
+ })
250
+ }, [])
251
+
252
+ useEffect(() => {
253
+ void load()
254
+ }, [load])
255
+
256
+ const changeTab = useCallback(
257
+ (id: string) => {
258
+ const next = TAB_IDS.includes(id as TabId) ? (id as TabId) : 'announcement'
259
+ setTab(next)
260
+ navigate(`${TAB_PATH}?tab=${next}`)
261
+ },
262
+ [navigate],
25
263
  )
26
- const [tab, setTab] = useState('announcement')
27
- const [text, setText] = useState('')
28
- const region = regions.find((item) => item.region === tab)
264
+
265
+ const editPath = useCallback(
266
+ (collection: string, id: string) =>
267
+ editPathForDoc(collections?.get(collection), collection, id),
268
+ [collections],
269
+ )
270
+
271
+ // ─── Drafts ──────────────────────────────────────────────────────────
272
+
273
+ const setRegionDraft = useCallback((region: ChromeRegionName, draft: RegionForm) => {
274
+ setLoaded((current) => {
275
+ const state = current?.regions[region]
276
+ if (!current || !state) return current
277
+ return { ...current, regions: { ...current.regions, [region]: { ...state, draft } } }
278
+ })
279
+ }, [])
280
+
281
+ const setAnnouncementDraft = useCallback((draft: AnnouncementForm) => {
282
+ setLoaded((current) =>
283
+ current?.announcement
284
+ ? { ...current, announcement: { ...current.announcement, draft } }
285
+ : current,
286
+ )
287
+ }, [])
288
+
289
+ const setSettingsDraft = useCallback((draft: ChromeSettingsForm) => {
290
+ setLoaded((current) =>
291
+ current?.settings ? { ...current, settings: { ...current.settings, draft } } : current,
292
+ )
293
+ }, [])
294
+
295
+ const dirtyRegions = REGIONS.filter((region) => {
296
+ const state = loaded?.regions[region]
297
+ return state ? !sameValue(state.draft, state.saved) : false
298
+ })
299
+ const announcementDirty = loaded?.announcement
300
+ ? !sameValue(loaded.announcement.draft, loaded.announcement.saved)
301
+ : false
302
+ const settingsDirty = loaded?.settings
303
+ ? !sameValue(loaded.settings.draft, loaded.settings.saved)
304
+ : false
305
+ const dirty = dirtyRegions.length > 0 || announcementDirty || settingsDirty
306
+
307
+ const problems = useMemo(() => {
308
+ const out: Partial<Record<ChromeRegionName, Record<string, string>>> = {}
309
+ for (const region of REGIONS) {
310
+ const state = loaded?.regions[region]
311
+ if (state) out[region] = regionProblems(state.schema, state.draft)
312
+ }
313
+ return out
314
+ }, [loaded])
315
+ const annProblem = loaded?.announcement ? announcementProblem(loaded.announcement.draft) : null
316
+ const setProblem = loaded?.settings ? settingsProblem(loaded.settings.draft) : null
317
+ const invalid =
318
+ dirtyRegions.some((region) => Object.keys(problems[region] ?? {}).length > 0) ||
319
+ (announcementDirty && annProblem !== null) ||
320
+ (settingsDirty && setProblem !== null)
321
+
322
+ const discard = useCallback(() => {
323
+ setLoaded((current) => {
324
+ if (!current) return current
325
+ const regions: Loaded['regions'] = {}
326
+ for (const region of REGIONS) {
327
+ const state = current.regions[region]
328
+ if (state) regions[region] = { ...state, draft: state.saved }
329
+ }
330
+ // Drafts only; the loaded values and ETags stay.
331
+ return {
332
+ ...current,
333
+ regions,
334
+ announcement: current.announcement
335
+ ? { ...current.announcement, draft: current.announcement.saved }
336
+ : null,
337
+ settings: current.settings ? { ...current.settings, draft: current.settings.saved } : null,
338
+ }
339
+ })
340
+ }, [])
341
+
342
+ // ─── Saving ──────────────────────────────────────────────────────────
343
+
344
+ const loadedRef = useRef(loaded)
345
+ loadedRef.current = loaded
346
+
347
+ const save = useCallback(async () => {
348
+ const snapshot = loadedRef.current
349
+ if (!snapshot || saving) return
350
+ setSaving(true)
351
+ const failures: string[] = []
352
+ let revalidated = false
353
+ let savedSettings = false
354
+ try {
355
+ for (const region of REGIONS) {
356
+ const state = snapshot.regions[region]
357
+ if (!state || sameValue(state.draft, state.saved)) continue
358
+ const sent = state.draft
359
+ const res = await saveRegion(
360
+ region,
361
+ regionSaveBody(state.schema, sent, state.values.fields),
362
+ state.etag,
363
+ )
364
+ if (!res.ok) {
365
+ failures.push(`${REGION_LABEL[region]}: ${res.error}`)
366
+ continue
367
+ }
368
+ revalidated = true
369
+ const form = regionForm(state.schema, res.value)
370
+ setLoaded((current) => {
371
+ const latest = current?.regions[region]
372
+ if (!current || !latest) return current
373
+ // Keep edits made while the request was out.
374
+ const draft = sameValue(latest.draft, sent) ? form : latest.draft
375
+ return {
376
+ ...current,
377
+ regions: {
378
+ ...current.regions,
379
+ [region]: { ...latest, values: res.value, etag: res.etag, saved: form, draft },
380
+ },
381
+ }
382
+ })
383
+ }
384
+
385
+ const ann = snapshot.announcement
386
+ if (ann && !sameValue(ann.draft, ann.saved)) {
387
+ const sent = ann.draft
388
+ const res = await saveAnnouncement(announcementSaveBody(sent), ann.etag)
389
+ if (!res.ok) {
390
+ failures.push(`Announcement bar: ${res.error}`)
391
+ } else {
392
+ revalidated = true
393
+ const form = announcementForm(res.value)
394
+ setLoaded((current) =>
395
+ current?.announcement
396
+ ? {
397
+ ...current,
398
+ announcement: {
399
+ value: res.value,
400
+ etag: res.etag,
401
+ saved: form,
402
+ draft: sameValue(current.announcement.draft, sent)
403
+ ? form
404
+ : current.announcement.draft,
405
+ },
406
+ }
407
+ : current,
408
+ )
409
+ }
410
+ }
411
+
412
+ const settings = snapshot.settings
413
+ if (settings && !sameValue(settings.draft, settings.saved)) {
414
+ const sent = settings.draft
415
+ // Only what changed: the API leaves a missing key alone, and a sent
416
+ // revalidate URL re-checks it and re-activates its webhook endpoint.
417
+ const body: Partial<ChromeSettings> = {}
418
+ if (sent.revalidateUrl !== settings.saved.revalidateUrl) {
419
+ body.revalidateUrl = sent.revalidateUrl.trim() || null
420
+ }
421
+ if (sent.previewUrl !== settings.saved.previewUrl) {
422
+ body.previewUrl = sent.previewUrl.trim() || null
423
+ }
424
+ const res = await saveChromeSettings(body)
425
+ if (!res.ok) {
426
+ failures.push(`Developer settings: ${res.error}`)
427
+ } else {
428
+ savedSettings = true
429
+ if (res.value.secret) setSecret(res.value.secret)
430
+ const form = settingsForm(res.value)
431
+ setLoaded((current) =>
432
+ current?.settings
433
+ ? {
434
+ ...current,
435
+ settings: {
436
+ saved: form,
437
+ draft: sameValue(current.settings.draft, sent) ? form : current.settings.draft,
438
+ previewUrl: res.value.previewUrl,
439
+ },
440
+ }
441
+ : current,
442
+ )
443
+ }
444
+ }
445
+ } finally {
446
+ setSaving(false)
447
+ }
448
+
449
+ if (failures.length > 0) {
450
+ toast.error(failures.join(' '))
451
+ } else if (revalidated) {
452
+ toast('Saved · site revalidated')
453
+ } else if (savedSettings) {
454
+ toast('Saved')
455
+ }
456
+ }, [saving])
457
+
458
+ const removeOrphans = useCallback(
459
+ async (region: ChromeRegionName) => {
460
+ const state = loadedRef.current?.regions[region]
461
+ if (!state || removing) return
462
+ setRemoving(region)
463
+ const keys = state.values.orphans.map((orphan) => orphan.key)
464
+ let last: { values: RegionValues; etag: string | null } | null = null
465
+ let error: string | null = null
466
+ for (const key of keys) {
467
+ const res = await removeOrphan(region, key)
468
+ if (!res.ok) {
469
+ error = res.error
470
+ break
471
+ }
472
+ last = { values: res.value, etag: res.etag }
473
+ }
474
+ setRemoving(null)
475
+ let overtaken = false
476
+ if (last) {
477
+ const next = last
478
+ const latest = loadedRef.current?.regions[region]
479
+ // The DELETE answers with the row as it is now. If someone else saved
480
+ // the region since this form loaded, its ETag covers their values, so
481
+ // keep the old one: the next save then gets 412 instead of quietly
482
+ // overwriting their change with this form.
483
+ overtaken =
484
+ !!latest &&
485
+ !sameValue(
486
+ { variant: next.values.variant, fields: next.values.fields },
487
+ { variant: latest.values.variant, fields: latest.values.fields },
488
+ )
489
+ setLoaded((current) => {
490
+ const now = current?.regions[region]
491
+ if (!current || !now) return current
492
+ return {
493
+ ...current,
494
+ regions: {
495
+ ...current.regions,
496
+ [region]: overtaken
497
+ ? { ...now, values: { ...now.values, orphans: next.values.orphans } }
498
+ : { ...now, values: next.values, etag: next.etag ?? now.etag },
499
+ },
500
+ }
501
+ })
502
+ }
503
+ if (error) toast.error(error)
504
+ else if (overtaken) {
505
+ toast.error(
506
+ `Removed. Someone else changed the ${region} since you opened it: reload to see their changes before you save.`,
507
+ )
508
+ } else if (keys.length === 1) toast(`Removed ${region}.${keys[0]}`)
509
+ else toast(`Removed ${keys.length} values`)
510
+ },
511
+ [removing],
512
+ )
513
+
514
+ // ─── Render ──────────────────────────────────────────────────────────
515
+
516
+ const ann = loaded?.announcement ?? null
517
+ const live = ann ? isLive(ann.value, now) : false
518
+ const tabs = [
519
+ {
520
+ id: 'announcement',
521
+ label: 'Announcement bar',
522
+ badge: ann ? (
523
+ <b
524
+ className={`rounded-pill text-count px-1.5 leading-[normal] font-bold ${
525
+ live ? 'bg-suc-l text-suc' : 'bg-bg text-faint'
526
+ }`}
527
+ >
528
+ {live ? 'Live' : 'Off'}
529
+ </b>
530
+ ) : undefined,
531
+ },
532
+ { id: 'header', label: 'Header' },
533
+ { id: 'footer', label: 'Footer' },
534
+ { id: 'developer', label: 'Developer', icon: <Code /> },
535
+ ]
536
+
537
+ const hasMenus = collections?.has('navigations') ?? false
538
+ const draft = useMemo(() => {
539
+ if (!loaded) return null
540
+ const regions: Parameters<typeof previewChrome>[0] = {}
541
+ for (const region of REGIONS) {
542
+ const state = loaded.regions[region]
543
+ if (state) regions[region] = { schema: state.schema, form: state.draft }
544
+ }
545
+ return previewChrome(
546
+ regions,
547
+ loaded.announcement
548
+ ? { id: loaded.announcement.value.id, form: loaded.announcement.draft }
549
+ : null,
550
+ { menus: loaded.menus, siteTitle: loaded.siteTitle, now: new Date(now) },
551
+ )
552
+ }, [loaded, now])
553
+
554
+ let body: ReactNode
555
+ if (loadError) {
556
+ body = (
557
+ <div className={`${CARD} flex flex-wrap items-center gap-3 px-4 py-3.5`} role="alert">
558
+ <span className="text-body text-txt min-w-0 flex-1">
559
+ Couldn’t load the header and footer. {loadError}
560
+ </span>
561
+ <Button variant="secondary" size="sm" onClick={() => void load()}>
562
+ Try again
563
+ </Button>
564
+ </div>
565
+ )
566
+ } else if (!loaded) {
567
+ body = (
568
+ <div className={`${CARD} text-body text-sub px-4 py-3.5`} role="status">
569
+ Loading the header and footer…
570
+ </div>
571
+ )
572
+ } else if (tab === 'developer') {
573
+ body = (
574
+ <DeveloperTab
575
+ sync={loaded.sync}
576
+ settings={loaded.settings?.draft ?? { revalidateUrl: '', previewUrl: '' }}
577
+ onSettingsChange={setSettingsDraft}
578
+ canEditSettings={canEditSettings && loaded.settings !== null}
579
+ secret={secret}
580
+ settingsProblem={settingsDirty ? setProblem : null}
581
+ now={now}
582
+ />
583
+ )
584
+ } else {
585
+ const region = tab === 'header' || tab === 'footer' ? tab : null
586
+ const state = region ? loaded.regions[region] : undefined
587
+ let column: ReactNode
588
+ if (tab === 'announcement') {
589
+ column = ann ? (
590
+ <AnnouncementTab
591
+ form={ann.draft}
592
+ onChange={setAnnouncementDraft}
593
+ problem={annProblem}
594
+ readOnly={readOnly}
595
+ />
596
+ ) : (
597
+ <div className={`${CARD} text-body text-sub px-4 py-3.5`} role="alert">
598
+ Couldn’t load the announcement bar.
599
+ </div>
600
+ )
601
+ } else if (region && state) {
602
+ column = (
603
+ <RegionTab
604
+ region={region}
605
+ schema={state.schema}
606
+ form={state.draft}
607
+ onChange={(form) => setRegionDraft(region, form)}
608
+ problems={problems[region] ?? {}}
609
+ orphans={state.values.orphans}
610
+ removingOrphans={removing === region}
611
+ onRemoveOrphans={() => void removeOrphans(region)}
612
+ overrides={loaded.overrides}
613
+ menus={loaded.menus}
614
+ siteTitle={loaded.siteTitle}
615
+ readOnly={readOnly}
616
+ saving={saving}
617
+ now={now}
618
+ href={links.href}
619
+ onNavigate={navigate}
620
+ editPath={editPath}
621
+ />
622
+ )
623
+ } else if (region && loaded.regionErrors[region]) {
624
+ column = (
625
+ <div className={`${CARD} flex flex-wrap items-center gap-3 px-4 py-3.5`} role="alert">
626
+ <span className="text-body text-txt min-w-0 flex-1">
627
+ {`Couldn’t load the ${region}. ${loaded.regionErrors[region]}`}
628
+ </span>
629
+ <Button variant="secondary" size="sm" onClick={() => void load()}>
630
+ Try again
631
+ </Button>
632
+ </div>
633
+ )
634
+ } else if (region) {
635
+ column = (
636
+ <section className={CARD} aria-label={`${REGION_LABEL[region]} schema`}>
637
+ <div className="flex flex-col items-start gap-2 px-4 py-3.5">
638
+ <h2 className="text-card-title text-txt m-0 font-[650]">
639
+ {`The site hasn’t sent a ${region} schema yet`}
640
+ </h2>
641
+ <p className="text-meta text-sub m-0 leading-[1.45]">
642
+ {`Your developer declares the ${region} with defineRegion in prenta.config.ts and runs prenta push chrome on every deploy. Until then the site keeps its ${region} layout variants.`}
643
+ </p>
644
+ <Button variant="secondary" size="sm" onClick={() => changeTab('developer')}>
645
+ How it’s wired
646
+ </Button>
647
+ </div>
648
+ </section>
649
+ )
650
+ }
651
+ body = (
652
+ <div className="grid grid-cols-[minmax(0,440px)_minmax(0,1fr)] items-start gap-[18px] [@media(width<=1100px)]:grid-cols-[minmax(0,1fr)]">
653
+ <div className="flex min-w-0 flex-col gap-3.5">{column}</div>
654
+ {draft ? (
655
+ <ChromePreview
656
+ header={
657
+ loaded.regions.header
658
+ ? { schema: loaded.regions.header.schema, form: loaded.regions.header.draft }
659
+ : undefined
660
+ }
661
+ footer={
662
+ loaded.regions.footer
663
+ ? { schema: loaded.regions.footer.schema, form: loaded.regions.footer.draft }
664
+ : undefined
665
+ }
666
+ announcement={ann?.draft ?? null}
667
+ menus={loaded.menus}
668
+ siteTitle={loaded.siteTitle}
669
+ previewUrl={loaded.settings?.previewUrl ?? null}
670
+ draft={draft}
671
+ />
672
+ ) : null}
673
+ </div>
674
+ )
675
+ }
29
676
 
30
677
  return (
31
- <div className="flex flex-col gap-4 p-6">
678
+ <div className="mobile:px-3.5 mobile:pt-4 mobile:pb-[100px] flex min-w-0 flex-col gap-[18px] px-7 pt-[22px] pb-[120px] leading-[normal]">
32
679
  <PageHeader
33
680
  flush
34
681
  title="Header & Footer"
35
- subtitle="Fields come from the site chrome schema."
682
+ subtitle={
683
+ // The Appearance screens' `.ap-sub` is 620px wide, not PageHeader's 640.
684
+ <span className="max-w-[620px] text-pretty">
685
+ Built by your developer in code. You fill in the content they’ve made editable, and run
686
+ the announcement bar whenever you need it.
687
+ </span>
688
+ }
36
689
  breadcrumb={
37
690
  <Breadcrumb
38
691
  items={[
@@ -43,32 +696,37 @@ export function AppearanceChrome({
43
696
  ]}
44
697
  />
45
698
  }
699
+ actions={
700
+ hasMenus ? (
701
+ <Button variant="secondary" onClick={() => navigate('/appearance/menus')}>
702
+ Edit menus
703
+ </Button>
704
+ ) : undefined
705
+ }
46
706
  />
47
- <UnderlineTabs tabs={tabs} value={tab} onChange={setTab} label="Header and footer" />
48
- {tab === 'announcement' ? (
49
- <label className="flex flex-col gap-2 text-base">
50
- Announcement
51
- <input
52
- value={text}
53
- maxLength={110}
54
- onChange={(event) => setText(event.target.value)}
55
- className="border-border bg-card rounded-md border px-3 py-2"
56
- />
57
- <span className="text-muted-foreground text-sm">{text.length}/110</span>
58
- </label>
59
- ) : null}
60
- {region ? (
61
- <ul className="flex flex-col gap-2">
62
- {Object.entries(region.fields).map(([key, item]) => (
63
- <li key={key} className="text-foreground text-base">
64
- {item.label ?? key}
65
- </li>
66
- ))}
67
- </ul>
68
- ) : null}
69
- {tab === 'developer' ? (
70
- <pre className="bg-muted overflow-x-auto rounded-md p-4 text-sm">{`defineRegion('header', { component, variants, fields: { logo: field.brand('logo') } })`}</pre>
707
+ {readOnly ? (
708
+ <p className="text-meta text-sub m-0" role="note">
709
+ You can look, but only editors and admins can change the header, footer and announcement
710
+ bar.
711
+ </p>
71
712
  ) : null}
713
+ <UnderlineTabs
714
+ tabs={tabs}
715
+ value={tab}
716
+ onChange={changeTab}
717
+ label="Header and footer"
718
+ idBase="hf"
719
+ />
720
+ <UnderlineTabPanel idBase="hf" tabId={tab} className="min-w-0 outline-none">
721
+ {body}
722
+ </UnderlineTabPanel>
723
+ <UnsavedBar
724
+ dirty={dirty && !readOnly}
725
+ onDiscard={discard}
726
+ onSave={() => void save()}
727
+ saving={saving}
728
+ saveDisabled={invalid || removing !== null}
729
+ />
72
730
  </div>
73
731
  )
74
732
  }