@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
|
@@ -0,0 +1,603 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure helpers for Appearance → Header & Footer: form values built from the
|
|
3
|
+
* region schema (never from a fixed field list), save bodies, the page
|
|
4
|
+
* override chips, and the draft chrome the preview renders.
|
|
5
|
+
*/
|
|
6
|
+
import type {
|
|
7
|
+
Announcement,
|
|
8
|
+
AnnouncementTone,
|
|
9
|
+
ChromePageOverrides,
|
|
10
|
+
ChromeRegionName,
|
|
11
|
+
FieldDef,
|
|
12
|
+
RegionSchema,
|
|
13
|
+
RegionSchemaSource,
|
|
14
|
+
RegionValues,
|
|
15
|
+
} from '@prenta/core'
|
|
16
|
+
|
|
17
|
+
import type {
|
|
18
|
+
AnnouncementSaveBody,
|
|
19
|
+
ChromeMenuItem,
|
|
20
|
+
ChromeMenuOption,
|
|
21
|
+
RegionSaveBody,
|
|
22
|
+
} from '../../lib/chrome-service.js'
|
|
23
|
+
|
|
24
|
+
/** The announcement message limit (DATA_CONTRACTS §2). */
|
|
25
|
+
export const ANNOUNCEMENT_MAX = 110
|
|
26
|
+
/** The link text limit `PUT /chrome/announcement` enforces. */
|
|
27
|
+
export const ANNOUNCEMENT_LINK_MAX = 40
|
|
28
|
+
/** How many menus a list field holds when the schema sets no `max` (core's `MAX_MENU_LIST`). */
|
|
29
|
+
const MENU_LIST_MAX = 20
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The menu pickers a list field shows: three to start (the kit's footer
|
|
33
|
+
* columns), one more than the list holds as it fills, up to `max`, and never
|
|
34
|
+
* fewer than are saved, so every saved menu can be cleared.
|
|
35
|
+
*/
|
|
36
|
+
export function menuListSlots(def: { max?: number }, length: number): number {
|
|
37
|
+
const max = def.max ?? MENU_LIST_MAX
|
|
38
|
+
return Math.max(length, Math.min(max, Math.max(3, length + 1)))
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const ANNOUNCEMENT_TONES: ReadonlyArray<{ id: AnnouncementTone; label: string }> = [
|
|
42
|
+
{ id: 'brand', label: 'Brand' },
|
|
43
|
+
{ id: 'dark', label: 'Dark' },
|
|
44
|
+
{ id: 'alert', label: 'Alert' },
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
export const SOCIAL_PLATFORMS = ['LinkedIn', 'Facebook', 'Instagram', 'X', 'YouTube', 'TikTok']
|
|
48
|
+
|
|
49
|
+
export interface RegionForm {
|
|
50
|
+
variant: string
|
|
51
|
+
fields: Record<string, unknown>
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
type Rec = Record<string, unknown>
|
|
55
|
+
|
|
56
|
+
function isRec(value: unknown): value is Rec {
|
|
57
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function str(value: unknown): string {
|
|
61
|
+
return typeof value === 'string' ? value : ''
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** A schema's fields in the order the site wrote them, own properties only. */
|
|
65
|
+
export function fieldEntries(schema: Pick<RegionSchema, 'fields'>): Array<[string, FieldDef]> {
|
|
66
|
+
return Object.keys(schema.fields)
|
|
67
|
+
.filter((key) => Object.hasOwn(schema.fields, key))
|
|
68
|
+
.map((key) => [key, schema.fields[key]!])
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Optional fields store `{ on, ...value }` and show a switch. */
|
|
72
|
+
export function isToggled(def: FieldDef): boolean {
|
|
73
|
+
if (def.type === 'menu') return Boolean(def.optional) && !def.list
|
|
74
|
+
return (def.type === 'link' || def.type === 'text') && Boolean(def.optional)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The field types this admin can edit. A newer schema may send others. */
|
|
78
|
+
const KNOWN_TYPES = new Set([
|
|
79
|
+
'brand',
|
|
80
|
+
'menu',
|
|
81
|
+
'link',
|
|
82
|
+
'text',
|
|
83
|
+
'textarea',
|
|
84
|
+
'select',
|
|
85
|
+
'boolean',
|
|
86
|
+
'social',
|
|
87
|
+
])
|
|
88
|
+
|
|
89
|
+
export function isKnownField(def: FieldDef): boolean {
|
|
90
|
+
return KNOWN_TYPES.has((def as { type: string }).type)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** `topMenu` → "Top menu", for fields without a `label`. */
|
|
94
|
+
export function humanizeKey(key: string): string {
|
|
95
|
+
const words = key
|
|
96
|
+
.replace(/[_-]+/g, ' ')
|
|
97
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
|
98
|
+
.trim()
|
|
99
|
+
.toLowerCase()
|
|
100
|
+
return words ? words[0]!.toUpperCase() + words.slice(1) : key
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function fieldLabel(key: string, def: FieldDef): string {
|
|
104
|
+
return def.label?.trim() || humanizeKey(key)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The help line under a field's label: the schema's, or what the CMS fills in. */
|
|
108
|
+
export function fieldHelp(def: FieldDef): string | null {
|
|
109
|
+
if (def.help?.trim()) return def.help.trim()
|
|
110
|
+
if (def.type === 'text' && def.tokens?.includes('{year}')) return '{year} updates automatically.'
|
|
111
|
+
if (def.type === 'menu' && def.list) {
|
|
112
|
+
return `Up to ${def.max ?? MENU_LIST_MAX} menus. Column headings use the menu name.`
|
|
113
|
+
}
|
|
114
|
+
return null
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** What a field shows when nothing is saved, in the stored shape. */
|
|
118
|
+
function defaultValue(def: FieldDef): unknown {
|
|
119
|
+
switch (def.type) {
|
|
120
|
+
case 'menu':
|
|
121
|
+
if (def.list) return def.default ? [def.default] : []
|
|
122
|
+
return def.optional ? { on: false, value: def.default ?? '' } : (def.default ?? '')
|
|
123
|
+
case 'link':
|
|
124
|
+
return def.optional ? { on: false, label: '', url: '' } : { label: '', url: '' }
|
|
125
|
+
case 'text':
|
|
126
|
+
return def.optional ? { on: false, value: '' } : ''
|
|
127
|
+
case 'textarea':
|
|
128
|
+
return ''
|
|
129
|
+
case 'select':
|
|
130
|
+
return def.default ?? def.options[0] ?? ''
|
|
131
|
+
case 'boolean':
|
|
132
|
+
return def.default ?? false
|
|
133
|
+
case 'social':
|
|
134
|
+
return []
|
|
135
|
+
default:
|
|
136
|
+
return undefined
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** A saved value in the shape the field edits, or its default when it doesn't fit. */
|
|
141
|
+
function formValue(def: FieldDef, saved: unknown): unknown {
|
|
142
|
+
if (saved === undefined) return defaultValue(def)
|
|
143
|
+
switch (def.type) {
|
|
144
|
+
case 'menu':
|
|
145
|
+
if (def.list) {
|
|
146
|
+
return Array.isArray(saved) ? saved.filter((slug) => typeof slug === 'string') : []
|
|
147
|
+
}
|
|
148
|
+
if (def.optional) {
|
|
149
|
+
return isRec(saved) && typeof saved.on === 'boolean'
|
|
150
|
+
? { on: saved.on, value: str(saved.value) }
|
|
151
|
+
: defaultValue(def)
|
|
152
|
+
}
|
|
153
|
+
return typeof saved === 'string' ? saved : defaultValue(def)
|
|
154
|
+
case 'link': {
|
|
155
|
+
const link = isRec(saved) ? { label: str(saved.label), url: str(saved.url) } : null
|
|
156
|
+
if (!link) return defaultValue(def)
|
|
157
|
+
return def.optional ? { on: isRec(saved) && saved.on === true, ...link } : link
|
|
158
|
+
}
|
|
159
|
+
case 'text':
|
|
160
|
+
if (def.optional) {
|
|
161
|
+
return isRec(saved) && typeof saved.on === 'boolean'
|
|
162
|
+
? { on: saved.on, value: str(saved.value) }
|
|
163
|
+
: defaultValue(def)
|
|
164
|
+
}
|
|
165
|
+
return typeof saved === 'string' ? saved : ''
|
|
166
|
+
case 'textarea':
|
|
167
|
+
return typeof saved === 'string' ? saved : ''
|
|
168
|
+
case 'select':
|
|
169
|
+
return typeof saved === 'string' && def.options.includes(saved) ? saved : defaultValue(def)
|
|
170
|
+
case 'boolean':
|
|
171
|
+
return typeof saved === 'boolean' ? saved : defaultValue(def)
|
|
172
|
+
case 'social':
|
|
173
|
+
return Array.isArray(saved)
|
|
174
|
+
? saved.filter(isRec).map((item) => ({ platform: str(item.platform), url: str(item.url) }))
|
|
175
|
+
: []
|
|
176
|
+
default:
|
|
177
|
+
// A type this admin doesn't know: keep what's saved and send it back.
|
|
178
|
+
return saved
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** The form for a region: the saved variant and every field, defaults filled in. */
|
|
183
|
+
export function regionForm(schema: RegionSchema, values: RegionValues | null): RegionForm {
|
|
184
|
+
const variants = Object.keys(schema.variants)
|
|
185
|
+
const variant =
|
|
186
|
+
values && Object.hasOwn(schema.variants, values.variant) ? values.variant : (variants[0] ?? '')
|
|
187
|
+
const fields: Rec = {}
|
|
188
|
+
for (const [key, def] of fieldEntries(schema)) {
|
|
189
|
+
if (def.type === 'brand') continue
|
|
190
|
+
const saved = values && Object.hasOwn(values.fields, key) ? values.fields[key] : undefined
|
|
191
|
+
const value = formValue(def, saved)
|
|
192
|
+
if (value !== undefined) fields[key] = value
|
|
193
|
+
}
|
|
194
|
+
return { variant, fields }
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* The value `PUT /chrome/:region` stores for one field. A required menu or
|
|
199
|
+
* link left empty is left out (the site then gets its default or null), and
|
|
200
|
+
* empty rows are dropped from lists.
|
|
201
|
+
*/
|
|
202
|
+
function saveValue(def: FieldDef, value: unknown): unknown {
|
|
203
|
+
switch (def.type) {
|
|
204
|
+
case 'menu':
|
|
205
|
+
if (def.list) {
|
|
206
|
+
return Array.isArray(value)
|
|
207
|
+
? [...new Set(value.filter((slug): slug is string => typeof slug === 'string' && !!slug))]
|
|
208
|
+
: []
|
|
209
|
+
}
|
|
210
|
+
if (def.optional) return value
|
|
211
|
+
return typeof value === 'string' && value ? value : null
|
|
212
|
+
case 'link':
|
|
213
|
+
if (def.optional) return value
|
|
214
|
+
return isRec(value) && (str(value.url).trim() || str(value.label).trim()) ? value : null
|
|
215
|
+
case 'social':
|
|
216
|
+
return Array.isArray(value)
|
|
217
|
+
? value.filter((row) => isRec(row) && (str(row.url).trim() || str(row.platform).trim()))
|
|
218
|
+
: []
|
|
219
|
+
default:
|
|
220
|
+
return value
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* The `PUT /chrome/:region` body. A field with nothing saved whose value is
|
|
226
|
+
* still its default is left out, so the site keeps following the default in
|
|
227
|
+
* its code if the developer changes it later.
|
|
228
|
+
*/
|
|
229
|
+
export function regionSaveBody(
|
|
230
|
+
schema: RegionSchema,
|
|
231
|
+
form: RegionForm,
|
|
232
|
+
saved: Record<string, unknown> = {},
|
|
233
|
+
): RegionSaveBody {
|
|
234
|
+
const fields: Rec = {}
|
|
235
|
+
for (const [key, def] of fieldEntries(schema)) {
|
|
236
|
+
if (def.type === 'brand' || !Object.hasOwn(form.fields, key)) continue
|
|
237
|
+
if (!Object.hasOwn(saved, key) && sameValue(form.fields[key], defaultValue(def))) continue
|
|
238
|
+
const value = saveValue(def, form.fields[key])
|
|
239
|
+
if (value !== null && value !== undefined) fields[key] = value
|
|
240
|
+
}
|
|
241
|
+
return { variant: form.variant, fields }
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
|
245
|
+
const PHONE = /^[+()0-9.\-\s]{3,40}$/
|
|
246
|
+
const LINK_PROBLEM = 'Links can be a site path or an http(s), mailto or tel link.'
|
|
247
|
+
|
|
248
|
+
function linkProblem(value: unknown, required: boolean): string | null {
|
|
249
|
+
if (!isRec(value)) return null
|
|
250
|
+
const url = str(value.url).trim()
|
|
251
|
+
if (str(value.label).length > 80) return 'Keep the button text to 80 characters.'
|
|
252
|
+
if (!url) return required ? 'Add the link URL.' : null
|
|
253
|
+
return isSafeLinkUrl(url) ? null : LINK_PROBLEM
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Why a field's value can't be saved, or null. Mirrors the checks
|
|
258
|
+
* `PUT /chrome/:region` makes, so the form says what's wrong before a 400.
|
|
259
|
+
*/
|
|
260
|
+
export function fieldProblem(def: FieldDef, value: unknown): string | null {
|
|
261
|
+
switch (def.type) {
|
|
262
|
+
case 'menu':
|
|
263
|
+
if (def.list) {
|
|
264
|
+
const max = def.max ?? MENU_LIST_MAX
|
|
265
|
+
return Array.isArray(value) && value.filter(Boolean).length > max
|
|
266
|
+
? `Pick at most ${max} menus.`
|
|
267
|
+
: null
|
|
268
|
+
}
|
|
269
|
+
if (def.optional && isRec(value) && value.on && !str(value.value)) {
|
|
270
|
+
return 'Pick a menu, or turn this off.'
|
|
271
|
+
}
|
|
272
|
+
return null
|
|
273
|
+
case 'link':
|
|
274
|
+
if (def.optional) return isRec(value) ? linkProblem(value, value.on === true) : null
|
|
275
|
+
return isRec(value) && (str(value.label).trim() || str(value.url).trim())
|
|
276
|
+
? linkProblem(value, true)
|
|
277
|
+
: null
|
|
278
|
+
case 'text': {
|
|
279
|
+
const text = def.optional ? (isRec(value) ? str(value.value) : '') : str(value)
|
|
280
|
+
const max = def.max ?? 500
|
|
281
|
+
if (text.length > max) return `Keep it to ${max} characters.`
|
|
282
|
+
const trimmed = text.trim()
|
|
283
|
+
if (trimmed && def.format === 'email' && !EMAIL.test(trimmed))
|
|
284
|
+
return 'Enter an email address.'
|
|
285
|
+
if (trimmed && def.format === 'tel' && !PHONE.test(trimmed)) return 'Enter a phone number.'
|
|
286
|
+
return null
|
|
287
|
+
}
|
|
288
|
+
case 'textarea': {
|
|
289
|
+
const max = def.max ?? 2000
|
|
290
|
+
return str(value).length > max ? `Keep it to ${max} characters.` : null
|
|
291
|
+
}
|
|
292
|
+
case 'social':
|
|
293
|
+
if (!Array.isArray(value)) return null
|
|
294
|
+
for (const row of value) {
|
|
295
|
+
if (!isRec(row) || !(str(row.url).trim() || str(row.platform).trim())) continue
|
|
296
|
+
if (!str(row.platform).trim()) return 'Pick a platform for each link.'
|
|
297
|
+
const problem = linkProblem({ label: '', url: row.url }, true)
|
|
298
|
+
if (problem) return problem
|
|
299
|
+
}
|
|
300
|
+
return null
|
|
301
|
+
default:
|
|
302
|
+
return null
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** Every field that can't be saved yet, by key. */
|
|
307
|
+
export function regionProblems(schema: RegionSchema, form: RegionForm): Record<string, string> {
|
|
308
|
+
const problems: Record<string, string> = {}
|
|
309
|
+
for (const [key, def] of fieldEntries(schema)) {
|
|
310
|
+
const problem = fieldProblem(def, form.fields[key])
|
|
311
|
+
if (problem) problems[key] = problem
|
|
312
|
+
}
|
|
313
|
+
return problems
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** Forms are built in schema order, so JSON compares them faithfully. */
|
|
317
|
+
export function sameValue(a: unknown, b: unknown): boolean {
|
|
318
|
+
return JSON.stringify(a) === JSON.stringify(b)
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// ─── Announcement ───────────────────────────────────────────────────────
|
|
322
|
+
|
|
323
|
+
export interface AnnouncementForm {
|
|
324
|
+
on: boolean
|
|
325
|
+
text: string
|
|
326
|
+
linkLabel: string
|
|
327
|
+
linkUrl: string
|
|
328
|
+
tone: AnnouncementTone
|
|
329
|
+
dismissible: boolean
|
|
330
|
+
scheduled: boolean
|
|
331
|
+
/** `datetime-local` values, in the editor's time zone. */
|
|
332
|
+
startsAt: string
|
|
333
|
+
endsAt: string
|
|
334
|
+
/**
|
|
335
|
+
* The saved times. A `datetime-local` value can't tell the two 1:30s of a
|
|
336
|
+
* clock-change night apart, so an untouched field sends these back as they were.
|
|
337
|
+
*/
|
|
338
|
+
savedStartsAt: string | null
|
|
339
|
+
savedEndsAt: string | null
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function pad(value: number): string {
|
|
343
|
+
return String(value).padStart(2, '0')
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** An ISO time as a `datetime-local` value in the browser's zone. */
|
|
347
|
+
export function toLocalInput(iso: string | null): string {
|
|
348
|
+
if (!iso) return ''
|
|
349
|
+
const at = new Date(iso)
|
|
350
|
+
if (Number.isNaN(at.getTime())) return ''
|
|
351
|
+
return `${at.getFullYear()}-${pad(at.getMonth() + 1)}-${pad(at.getDate())}T${pad(at.getHours())}:${pad(at.getMinutes())}`
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* A `datetime-local` value (local time, no zone) as the ISO time with a zone
|
|
356
|
+
* the API needs, or null when it's empty or not a time.
|
|
357
|
+
*/
|
|
358
|
+
export function fromLocalInput(value: string, saved: string | null = null): string | null {
|
|
359
|
+
if (!value) return null
|
|
360
|
+
if (saved && toLocalInput(saved) === value) return saved
|
|
361
|
+
const at = new Date(value)
|
|
362
|
+
return Number.isNaN(at.getTime()) ? null : at.toISOString()
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
export function announcementForm(announcement: Announcement): AnnouncementForm {
|
|
366
|
+
return {
|
|
367
|
+
on: announcement.on,
|
|
368
|
+
text: announcement.text,
|
|
369
|
+
linkLabel: announcement.link?.label ?? '',
|
|
370
|
+
linkUrl: announcement.link?.url ?? '',
|
|
371
|
+
tone: announcement.tone,
|
|
372
|
+
dismissible: announcement.dismissible,
|
|
373
|
+
scheduled: announcement.schedule !== null,
|
|
374
|
+
startsAt: toLocalInput(announcement.schedule?.startsAt ?? null),
|
|
375
|
+
endsAt: toLocalInput(announcement.schedule?.endsAt ?? null),
|
|
376
|
+
savedStartsAt: announcement.schedule?.startsAt ?? null,
|
|
377
|
+
savedEndsAt: announcement.schedule?.endsAt ?? null,
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
export function announcementSaveBody(form: AnnouncementForm): AnnouncementSaveBody {
|
|
382
|
+
const url = form.linkUrl.trim()
|
|
383
|
+
const startsAt = form.scheduled ? fromLocalInput(form.startsAt, form.savedStartsAt) : null
|
|
384
|
+
const endsAt = form.scheduled ? fromLocalInput(form.endsAt, form.savedEndsAt) : null
|
|
385
|
+
return {
|
|
386
|
+
on: form.on,
|
|
387
|
+
text: form.text,
|
|
388
|
+
link: url || form.linkLabel.trim() ? { label: form.linkLabel, url } : null,
|
|
389
|
+
tone: form.tone,
|
|
390
|
+
dismissible: form.dismissible,
|
|
391
|
+
schedule: startsAt || endsAt ? { startsAt, endsAt } : null,
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/** Why the announcement can't be saved yet, or null. Mirrors the API's checks. */
|
|
396
|
+
export function announcementProblem(form: AnnouncementForm): string | null {
|
|
397
|
+
if (form.text.length > ANNOUNCEMENT_MAX) {
|
|
398
|
+
return `Keep the message to ${ANNOUNCEMENT_MAX} characters.`
|
|
399
|
+
}
|
|
400
|
+
if (form.on && !form.text.trim()) return 'Add a message before turning the bar on.'
|
|
401
|
+
if (form.linkLabel.length > ANNOUNCEMENT_LINK_MAX) {
|
|
402
|
+
return `Keep the link text to ${ANNOUNCEMENT_LINK_MAX} characters.`
|
|
403
|
+
}
|
|
404
|
+
if (form.linkLabel.trim() && !form.linkUrl.trim())
|
|
405
|
+
return 'Add the link URL, or clear the link text.'
|
|
406
|
+
if (form.linkUrl.trim() && !isSafeLinkUrl(form.linkUrl.trim())) {
|
|
407
|
+
return 'Links can be a site path or an http(s), mailto or tel link.'
|
|
408
|
+
}
|
|
409
|
+
if (form.scheduled) {
|
|
410
|
+
const starts = fromLocalInput(form.startsAt, form.savedStartsAt)
|
|
411
|
+
const ends = fromLocalInput(form.endsAt, form.savedEndsAt)
|
|
412
|
+
if (!starts && !ends) return 'Pick when the bar starts or ends, or turn the schedule off.'
|
|
413
|
+
if (starts && ends && Date.parse(ends) <= Date.parse(starts)) {
|
|
414
|
+
return 'The end has to be after the start.'
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
return null
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** Links an editor may set (the API's `isSafeLinkUrl`). */
|
|
421
|
+
function isSafeLinkUrl(url: string): boolean {
|
|
422
|
+
if (url.length > 2048) return false
|
|
423
|
+
if (url.startsWith('/') || url.startsWith('#') || url.startsWith('?')) return true
|
|
424
|
+
return /^(https?:\/\/|mailto:|tel:)/i.test(url)
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// ─── Page overrides ─────────────────────────────────────────────────────
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* The mono chips for one page's override of one region, as the kit writes
|
|
431
|
+
* them: `header:minimal`, `header.cta: off`, `footer: none`.
|
|
432
|
+
*/
|
|
433
|
+
export function overrideChips(page: ChromePageOverrides, region: ChromeRegionName): string[] {
|
|
434
|
+
const override = page[region]
|
|
435
|
+
if (override === undefined) return []
|
|
436
|
+
if (override === null) return [`${region}: none`]
|
|
437
|
+
const chips: string[] = []
|
|
438
|
+
if (override.variant) chips.push(`${region}:${override.variant}`)
|
|
439
|
+
for (const [key, value] of Object.entries(override.fields ?? {})) {
|
|
440
|
+
chips.push(isRec(value) && value.on === false ? `${region}.${key}: off` : `${region}.${key}`)
|
|
441
|
+
}
|
|
442
|
+
return chips.length > 0 ? chips : [`${region}: inherits`]
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// ─── Source card ────────────────────────────────────────────────────────
|
|
446
|
+
|
|
447
|
+
/** `./src/components/site/Header.astro` → `src/components/site/Header.astro`. */
|
|
448
|
+
export function componentPath(component: string): string {
|
|
449
|
+
return component.replace(/^\.\//, '')
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
export function shortCommit(commit: string): string {
|
|
453
|
+
return /^[0-9a-f]{8,}$/i.test(commit) ? commit.slice(0, 7) : commit
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* "View in repo ↗". `prenta push chrome` stores the repo as `owner/name`
|
|
458
|
+
* without its host, so this assumes GitHub; a full http(s) URL passed with
|
|
459
|
+
* `--repo` is used as it is.
|
|
460
|
+
*/
|
|
461
|
+
export function repoFileUrl(source: RegionSchemaSource, component: string): string | null {
|
|
462
|
+
const repo = source.repo.trim()
|
|
463
|
+
const path = componentPath(component).split('/').filter(Boolean).map(encodeURIComponent).join('/')
|
|
464
|
+
const ref = encodeURIComponent(source.commit || source.branch || 'HEAD')
|
|
465
|
+
if (/^[\w.-]+\/[\w.-]+$/.test(repo)) {
|
|
466
|
+
return `https://github.com/${repo}/blob/${ref}/${path}`
|
|
467
|
+
}
|
|
468
|
+
if (/^https?:\/\//i.test(repo)) {
|
|
469
|
+
try {
|
|
470
|
+
const url = new URL(repo.replace(/\.git$/, ''))
|
|
471
|
+
url.pathname = `${url.pathname.replace(/\/+$/, '')}/blob/${ref}/${path}`
|
|
472
|
+
return url.toString()
|
|
473
|
+
} catch {
|
|
474
|
+
return null
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
return null
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/** "just now", "5 minutes ago", "2 hours ago", "3 days ago", then the date. */
|
|
481
|
+
export function timeAgo(iso: string | null | undefined, now: number): string {
|
|
482
|
+
if (!iso) return 'never'
|
|
483
|
+
const at = Date.parse(iso)
|
|
484
|
+
if (!Number.isFinite(at)) return 'never'
|
|
485
|
+
const seconds = Math.max(0, Math.round((now - at) / 1000))
|
|
486
|
+
const plural = (n: number, unit: string) => `${n} ${unit}${n === 1 ? '' : 's'} ago`
|
|
487
|
+
if (seconds < 60) return 'just now'
|
|
488
|
+
if (seconds < 3600) return plural(Math.floor(seconds / 60), 'minute')
|
|
489
|
+
if (seconds < 86_400) return plural(Math.floor(seconds / 3600), 'hour')
|
|
490
|
+
if (seconds < 30 * 86_400) return plural(Math.floor(seconds / 86_400), 'day')
|
|
491
|
+
return new Date(at).toLocaleDateString(undefined, {
|
|
492
|
+
day: 'numeric',
|
|
493
|
+
month: 'short',
|
|
494
|
+
year: 'numeric',
|
|
495
|
+
})
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// ─── Draft chrome for the preview ───────────────────────────────────────
|
|
499
|
+
|
|
500
|
+
/** A menu as the site's components receive it (`ChromeMenu`). */
|
|
501
|
+
interface PreviewMenu {
|
|
502
|
+
id: string
|
|
503
|
+
name: string
|
|
504
|
+
items: Array<{ label: string; url: string; target?: '_blank' | '_self'; children?: unknown[] }>
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function publicItems(items: ChromeMenuItem[]): PreviewMenu['items'] {
|
|
508
|
+
return items.map((item) => ({
|
|
509
|
+
label: item.label,
|
|
510
|
+
url: item.url,
|
|
511
|
+
...(item.target ? { target: item.target } : {}),
|
|
512
|
+
...(item.children.length > 0 ? { children: publicItems(item.children) } : {}),
|
|
513
|
+
}))
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
function publicMenu(menus: ChromeMenuOption[], slug: unknown): PreviewMenu | null {
|
|
517
|
+
const menu = typeof slug === 'string' && slug ? menus.find((m) => m.slug === slug) : undefined
|
|
518
|
+
return menu ? { id: menu.slug, name: menu.name, items: publicItems(menu.items) } : null
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
export interface PreviewContext {
|
|
522
|
+
menus: ChromeMenuOption[]
|
|
523
|
+
siteTitle: string
|
|
524
|
+
now: Date
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* One field as `/public/chrome` would send it, so a Preview URL page can
|
|
529
|
+
* render the site's real components with the draft. Brand logos aren't
|
|
530
|
+
* resolved here (null); the site name is.
|
|
531
|
+
*/
|
|
532
|
+
function publicField(def: FieldDef, value: unknown, ctx: PreviewContext): unknown {
|
|
533
|
+
switch (def.type) {
|
|
534
|
+
case 'brand':
|
|
535
|
+
return def.key === 'logo' ? null : ctx.siteTitle
|
|
536
|
+
case 'menu':
|
|
537
|
+
if (def.list) {
|
|
538
|
+
return (Array.isArray(value) ? value : [])
|
|
539
|
+
.map((slug) => publicMenu(ctx.menus, slug))
|
|
540
|
+
.filter(Boolean)
|
|
541
|
+
}
|
|
542
|
+
if (def.optional) return isRec(value) && value.on ? publicMenu(ctx.menus, value.value) : null
|
|
543
|
+
return publicMenu(ctx.menus, value || def.default)
|
|
544
|
+
case 'link': {
|
|
545
|
+
if (def.optional && !(isRec(value) && value.on)) return null
|
|
546
|
+
return isRec(value) && str(value.url)
|
|
547
|
+
? { label: str(value.label), url: str(value.url) }
|
|
548
|
+
: null
|
|
549
|
+
}
|
|
550
|
+
case 'text': {
|
|
551
|
+
const year = String(ctx.now.getFullYear())
|
|
552
|
+
const fill = (text: string) =>
|
|
553
|
+
def.tokens?.includes('{year}') ? text.split('{year}').join(year) : text
|
|
554
|
+
if (def.optional) return isRec(value) && value.on ? fill(str(value.value)) : null
|
|
555
|
+
return fill(str(value))
|
|
556
|
+
}
|
|
557
|
+
default:
|
|
558
|
+
return value
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
export interface PreviewChrome {
|
|
563
|
+
header?: { variant: string; fields: Rec } | null
|
|
564
|
+
footer?: { variant: string; fields: Rec } | null
|
|
565
|
+
announcement: {
|
|
566
|
+
id: string
|
|
567
|
+
text: string
|
|
568
|
+
link: { label: string; url: string } | null
|
|
569
|
+
tone: AnnouncementTone
|
|
570
|
+
dismissible: boolean
|
|
571
|
+
endsAt: string | null
|
|
572
|
+
} | null
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/** The draft as the `/public/chrome` shape, for `postMessage` to a Preview URL page. */
|
|
576
|
+
export function previewChrome(
|
|
577
|
+
regions: Partial<Record<ChromeRegionName, { schema: RegionSchema; form: RegionForm }>>,
|
|
578
|
+
announcement: { id: string; form: AnnouncementForm } | null,
|
|
579
|
+
ctx: PreviewContext,
|
|
580
|
+
): PreviewChrome {
|
|
581
|
+
const out: PreviewChrome = { announcement: null }
|
|
582
|
+
for (const [region, entry] of Object.entries(regions) as Array<
|
|
583
|
+
[ChromeRegionName, { schema: RegionSchema; form: RegionForm }]
|
|
584
|
+
>) {
|
|
585
|
+
const fields: Rec = {}
|
|
586
|
+
for (const [key, def] of fieldEntries(entry.schema)) {
|
|
587
|
+
fields[key] = publicField(def, entry.form.fields[key] ?? defaultValue(def), ctx)
|
|
588
|
+
}
|
|
589
|
+
out[region] = { variant: entry.form.variant, fields }
|
|
590
|
+
}
|
|
591
|
+
if (announcement && announcement.form.on && announcement.form.text.trim()) {
|
|
592
|
+
const body = announcementSaveBody(announcement.form)
|
|
593
|
+
out.announcement = {
|
|
594
|
+
id: announcement.id,
|
|
595
|
+
text: body.text,
|
|
596
|
+
link: body.link?.url ? body.link : null,
|
|
597
|
+
tone: body.tone,
|
|
598
|
+
dismissible: body.dismissible,
|
|
599
|
+
endsAt: body.schedule?.endsAt ?? null,
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
return out
|
|
603
|
+
}
|