@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.
- package/CHANGELOG.md +34 -0
- package/design-system/tokens.css +17 -0
- package/design-system/tokens.json +35 -1
- package/design-system/tokens.scss +15 -0
- package/design-system/tokens.tailwind.cjs +14 -0
- package/dist/AdminRoot.d.ts.map +1 -1
- package/dist/AdminRoot.js +2 -2
- package/dist/AdminRoot.js.map +1 -1
- package/dist/__tests__/lib/batch-wizard.test.d.ts +2 -0
- package/dist/__tests__/lib/batch-wizard.test.d.ts.map +1 -0
- package/dist/__tests__/lib/batch-wizard.test.js +144 -0
- package/dist/__tests__/lib/batch-wizard.test.js.map +1 -0
- package/dist/__tests__/lib/job-drawer.test.js +2 -0
- package/dist/__tests__/lib/job-drawer.test.js.map +1 -1
- package/dist/__tests__/lib/job-view.test.js +100 -3
- package/dist/__tests__/lib/job-view.test.js.map +1 -1
- package/dist/__tests__/lib/jobs-service.test.js +56 -2
- package/dist/__tests__/lib/jobs-service.test.js.map +1 -1
- package/dist/__tests__/views/appearance-chrome.render.test.d.ts +2 -0
- package/dist/__tests__/views/appearance-chrome.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/appearance-chrome.render.test.js +688 -0
- package/dist/__tests__/views/appearance-chrome.render.test.js.map +1 -0
- package/dist/__tests__/views/header-footer-form.test.d.ts +2 -0
- package/dist/__tests__/views/header-footer-form.test.d.ts.map +1 -0
- package/dist/__tests__/views/header-footer-form.test.js +266 -0
- package/dist/__tests__/views/header-footer-form.test.js.map +1 -0
- package/dist/__tests__/views/jobs-drawer.render.test.js +442 -2
- package/dist/__tests__/views/jobs-drawer.render.test.js.map +1 -1
- package/dist/__tests__/views/jobs-list.render.test.js +7 -5
- package/dist/__tests__/views/jobs-list.render.test.js.map +1 -1
- package/dist/__tests__/views/jobs-wizard.render.test.d.ts +2 -0
- package/dist/__tests__/views/jobs-wizard.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/jobs-wizard.render.test.js +588 -0
- package/dist/__tests__/views/jobs-wizard.render.test.js.map +1 -0
- package/dist/lib/api.d.ts +2 -0
- package/dist/lib/api.d.ts.map +1 -1
- package/dist/lib/api.js +8 -1
- package/dist/lib/api.js.map +1 -1
- package/dist/lib/batch-wizard.d.ts +84 -0
- package/dist/lib/batch-wizard.d.ts.map +1 -0
- package/dist/lib/batch-wizard.js +122 -0
- package/dist/lib/batch-wizard.js.map +1 -0
- package/dist/lib/chrome-service.d.ts +69 -0
- package/dist/lib/chrome-service.d.ts.map +1 -0
- package/dist/lib/chrome-service.js +108 -0
- package/dist/lib/chrome-service.js.map +1 -0
- package/dist/lib/job-view.d.ts +43 -0
- package/dist/lib/job-view.d.ts.map +1 -1
- package/dist/lib/job-view.js +57 -1
- package/dist/lib/job-view.js.map +1 -1
- package/dist/lib/jobs-service.d.ts +19 -1
- package/dist/lib/jobs-service.d.ts.map +1 -1
- package/dist/lib/jobs-service.js +41 -0
- package/dist/lib/jobs-service.js.map +1 -1
- package/dist/prenta-admin.css +1 -1
- package/dist/views/AppearanceChrome.d.ts +22 -4
- package/dist/views/AppearanceChrome.d.ts.map +1 -1
- package/dist/views/AppearanceChrome.js +451 -15
- package/dist/views/AppearanceChrome.js.map +1 -1
- package/dist/views/Jobs/CancelJobDialog.d.ts +7 -1
- package/dist/views/Jobs/CancelJobDialog.d.ts.map +1 -1
- package/dist/views/Jobs/CancelJobDialog.js +13 -3
- package/dist/views/Jobs/CancelJobDialog.js.map +1 -1
- package/dist/views/Jobs/DrawerReview.d.ts +7 -0
- package/dist/views/Jobs/DrawerReview.d.ts.map +1 -1
- package/dist/views/Jobs/DrawerReview.js +1 -1
- package/dist/views/Jobs/DrawerReview.js.map +1 -1
- package/dist/views/Jobs/JobDrawer.d.ts +23 -3
- package/dist/views/Jobs/JobDrawer.d.ts.map +1 -1
- package/dist/views/Jobs/JobDrawer.js +30 -5
- package/dist/views/Jobs/JobDrawer.js.map +1 -1
- package/dist/views/Jobs/JobsTable.d.ts +4 -2
- package/dist/views/Jobs/JobsTable.d.ts.map +1 -1
- package/dist/views/Jobs/JobsTable.js +6 -5
- package/dist/views/Jobs/JobsTable.js.map +1 -1
- package/dist/views/Jobs/NewBatchJob.d.ts +34 -0
- package/dist/views/Jobs/NewBatchJob.d.ts.map +1 -0
- package/dist/views/Jobs/NewBatchJob.js +282 -0
- package/dist/views/Jobs/NewBatchJob.js.map +1 -0
- package/dist/views/Jobs/WorkerStrip.d.ts +3 -1
- package/dist/views/Jobs/WorkerStrip.d.ts.map +1 -1
- package/dist/views/Jobs/WorkerStrip.js +2 -2
- package/dist/views/Jobs/WorkerStrip.js.map +1 -1
- package/dist/views/Jobs/focus.d.ts +6 -0
- package/dist/views/Jobs/focus.d.ts.map +1 -1
- package/dist/views/Jobs/focus.js +10 -0
- package/dist/views/Jobs/focus.js.map +1 -1
- package/dist/views/Jobs.d.ts +12 -3
- package/dist/views/Jobs.d.ts.map +1 -1
- package/dist/views/Jobs.js +109 -31
- package/dist/views/Jobs.js.map +1 -1
- package/dist/views/header-footer/AnnouncementTab.d.ts +15 -0
- package/dist/views/header-footer/AnnouncementTab.d.ts.map +1 -0
- package/dist/views/header-footer/AnnouncementTab.js +43 -0
- package/dist/views/header-footer/AnnouncementTab.js.map +1 -0
- package/dist/views/header-footer/ChromeField.d.ts +25 -0
- package/dist/views/header-footer/ChromeField.d.ts.map +1 -0
- package/dist/views/header-footer/ChromeField.js +99 -0
- package/dist/views/header-footer/ChromeField.js.map +1 -0
- package/dist/views/header-footer/ChromePreview.d.ts +31 -0
- package/dist/views/header-footer/ChromePreview.d.ts.map +1 -0
- package/dist/views/header-footer/ChromePreview.js +195 -0
- package/dist/views/header-footer/ChromePreview.js.map +1 -0
- package/dist/views/header-footer/DeveloperTab.d.ts +17 -0
- package/dist/views/header-footer/DeveloperTab.d.ts.map +1 -0
- package/dist/views/header-footer/DeveloperTab.js +98 -0
- package/dist/views/header-footer/DeveloperTab.js.map +1 -0
- package/dist/views/header-footer/RegionTab.d.ts +27 -0
- package/dist/views/header-footer/RegionTab.d.ts.map +1 -0
- package/dist/views/header-footer/RegionTab.js +80 -0
- package/dist/views/header-footer/RegionTab.js.map +1 -0
- package/dist/views/header-footer/chrome-form.d.ts +137 -0
- package/dist/views/header-footer/chrome-form.d.ts.map +1 -0
- package/dist/views/header-footer/chrome-form.js +518 -0
- package/dist/views/header-footer/chrome-form.js.map +1 -0
- package/dist/views/header-footer/code-samples.d.ts +30 -0
- package/dist/views/header-footer/code-samples.d.ts.map +1 -0
- package/dist/views/header-footer/code-samples.js +127 -0
- package/dist/views/header-footer/code-samples.js.map +1 -0
- package/dist/views/header-footer/controls.d.ts +57 -0
- package/dist/views/header-footer/controls.d.ts.map +1 -0
- package/dist/views/header-footer/controls.js +43 -0
- package/dist/views/header-footer/controls.js.map +1 -0
- package/package.json +3 -3
- package/src/AdminRoot.tsx +16 -2
- package/src/__tests__/lib/batch-wizard.test.ts +199 -0
- package/src/__tests__/lib/job-drawer.test.ts +2 -0
- package/src/__tests__/lib/job-view.test.ts +123 -2
- package/src/__tests__/lib/jobs-service.test.ts +72 -3
- package/src/__tests__/views/appearance-chrome.render.test.tsx +818 -0
- package/src/__tests__/views/header-footer-form.test.ts +333 -0
- package/src/__tests__/views/jobs-drawer.render.test.tsx +508 -2
- package/src/__tests__/views/jobs-list.render.test.tsx +8 -5
- package/src/__tests__/views/jobs-wizard.render.test.tsx +707 -0
- package/src/lib/api.ts +12 -1
- package/src/lib/batch-wizard.ts +188 -0
- package/src/lib/chrome-service.ts +200 -0
- package/src/lib/job-view.ts +95 -1
- package/src/lib/jobs-service.ts +56 -1
- package/src/styles/prenta-tokens.css +17 -0
- package/src/styles/theme.css +15 -0
- package/src/views/AppearanceChrome.tsx +704 -46
- package/src/views/Jobs/CancelJobDialog.tsx +20 -2
- package/src/views/Jobs/DrawerReview.tsx +1 -1
- package/src/views/Jobs/JobDrawer.tsx +83 -3
- package/src/views/Jobs/JobsTable.tsx +11 -4
- package/src/views/Jobs/NewBatchJob.tsx +857 -0
- package/src/views/Jobs/WorkerStrip.tsx +16 -11
- package/src/views/Jobs/focus.ts +11 -0
- package/src/views/Jobs.tsx +163 -49
- package/src/views/header-footer/AnnouncementTab.tsx +231 -0
- package/src/views/header-footer/ChromeField.tsx +358 -0
- package/src/views/header-footer/ChromePreview.tsx +497 -0
- package/src/views/header-footer/DeveloperTab.tsx +331 -0
- package/src/views/header-footer/RegionTab.tsx +398 -0
- package/src/views/header-footer/chrome-form.ts +603 -0
- package/src/views/header-footer/code-samples.ts +146 -0
- package/src/views/header-footer/controls.tsx +166 -0
- package/dist/views/Jobs/placeholders.d.ts +0 -6
- package/dist/views/Jobs/placeholders.d.ts.map +0 -1
- package/dist/views/Jobs/placeholders.js +0 -20
- package/dist/views/Jobs/placeholders.js.map +0 -1
- package/src/views/Jobs/placeholders.tsx +0 -66
|
@@ -1,38 +1,691 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
-
import {
|
|
4
|
-
import type
|
|
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
|
-
|
|
10
|
-
const
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
27
|
-
const
|
|
28
|
-
|
|
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-
|
|
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=
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
}
|