@growth-labs/cms 0.5.26 → 0.5.29
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/README.md +20 -0
- package/dist/engine/slug.d.ts.map +1 -1
- package/dist/engine/slug.js +11 -1
- package/dist/engine/slug.js.map +1 -1
- package/dist/integration/options.d.ts +196 -0
- package/dist/integration/options.d.ts.map +1 -1
- package/dist/integration/options.js +62 -0
- package/dist/integration/options.js.map +1 -1
- package/dist/routes/content.d.ts.map +1 -1
- package/dist/routes/content.js +30 -0
- package/dist/routes/content.js.map +1 -1
- package/dist/ui/client/mount.d.ts +1 -0
- package/dist/ui/client/mount.d.ts.map +1 -1
- package/dist/ui/client/mount.js +2 -0
- package/dist/ui/client/mount.js.map +1 -1
- package/dist/ui/components/CmsApp.js +2 -11
- package/dist/ui/components/CmsApp.js.map +1 -1
- package/dist/ui/components/Sidebar.d.ts.map +1 -1
- package/dist/ui/components/Sidebar.js +2 -2
- package/dist/ui/components/Sidebar.js.map +1 -1
- package/dist/ui/components/Topbar.d.ts.map +1 -1
- package/dist/ui/components/Topbar.js +1 -10
- package/dist/ui/components/Topbar.js.map +1 -1
- package/dist/ui/editor/ContentForm.d.ts +3 -1
- package/dist/ui/editor/ContentForm.d.ts.map +1 -1
- package/dist/ui/editor/ContentForm.js +42 -16
- package/dist/ui/editor/ContentForm.js.map +1 -1
- package/dist/ui/editor/Rte.js +3 -3
- package/dist/ui/editor/Rte.js.map +1 -1
- package/dist/ui/editor/content-payload.d.ts +12 -0
- package/dist/ui/editor/content-payload.d.ts.map +1 -1
- package/dist/ui/editor/content-payload.js +24 -0
- package/dist/ui/editor/content-payload.js.map +1 -1
- package/dist/ui/inspector/FoundryTab.js +1 -1
- package/dist/ui/inspector/FoundryTab.js.map +1 -1
- package/dist/ui/pages/admin.astro +1 -1
- package/dist/ui/screens/AuthorsScreen.js +1 -1
- package/dist/ui/screens/ContentInsightsScreen.js +1 -1
- package/dist/ui/screens/ContentInsightsScreen.js.map +1 -1
- package/dist/ui/screens/ContentRoute.d.ts +3 -1
- package/dist/ui/screens/ContentRoute.d.ts.map +1 -1
- package/dist/ui/screens/ContentRoute.js +2 -2
- package/dist/ui/screens/ContentRoute.js.map +1 -1
- package/dist/ui/screens/EditorScreen.d.ts +6 -1
- package/dist/ui/screens/EditorScreen.d.ts.map +1 -1
- package/dist/ui/screens/EditorScreen.js +17 -11
- package/dist/ui/screens/EditorScreen.js.map +1 -1
- package/dist/ui/screens/LibraryScreen.js +2 -2
- package/dist/ui/screens/LibraryScreen.js.map +1 -1
- package/dist/ui/screens/SettingsScreen.js +1 -1
- package/dist/ui/screens/SubscriptionsScreen.js +2 -2
- package/dist/ui/screens/registry.d.ts +3 -1
- package/dist/ui/screens/registry.d.ts.map +1 -1
- package/dist/ui/screens/registry.js +2 -2
- package/dist/ui/screens/registry.js.map +1 -1
- package/dist/ui/styles/broadsheet.css +131 -156
- package/dist/ui/styles/growth-labs-tokens.css +149 -0
- package/dist/ui/theme.d.ts +7 -4
- package/dist/ui/theme.d.ts.map +1 -1
- package/dist/ui/theme.js +24 -65
- package/dist/ui/theme.js.map +1 -1
- package/dist/ui/use-tweaks.d.ts.map +1 -1
- package/dist/ui/use-tweaks.js +2 -1
- package/dist/ui/use-tweaks.js.map +1 -1
- package/package.json +1 -1
- package/src/engine/slug.ts +12 -1
- package/src/integration/options.ts +69 -0
- package/src/routes/content.ts +37 -0
- package/src/ui/client/mount.tsx +2 -0
- package/src/ui/components/CmsApp.tsx +3 -20
- package/src/ui/components/Sidebar.tsx +5 -11
- package/src/ui/components/Topbar.tsx +2 -13
- package/src/ui/editor/ContentForm.tsx +81 -13
- package/src/ui/editor/Rte.tsx +3 -3
- package/src/ui/editor/content-payload.ts +39 -0
- package/src/ui/inspector/FoundryTab.tsx +1 -1
- package/src/ui/pages/admin.astro +1 -1
- package/src/ui/screens/AuthorsScreen.tsx +1 -1
- package/src/ui/screens/ContentInsightsScreen.tsx +1 -1
- package/src/ui/screens/ContentRoute.tsx +4 -0
- package/src/ui/screens/EditorScreen.tsx +21 -9
- package/src/ui/screens/LibraryScreen.tsx +5 -2
- package/src/ui/screens/SettingsScreen.tsx +1 -1
- package/src/ui/screens/SubscriptionsScreen.tsx +2 -2
- package/src/ui/screens/registry.tsx +4 -0
- package/src/ui/styles/broadsheet.css +131 -156
- package/src/ui/styles/growth-labs-tokens.css +149 -0
- package/src/ui/theme.ts +29 -68
- package/src/ui/use-tweaks.ts +2 -1
|
@@ -18,8 +18,10 @@
|
|
|
18
18
|
//
|
|
19
19
|
// Compile-gated: all Tiptap/React usage verified by `pnpm run build` (tsc).
|
|
20
20
|
|
|
21
|
-
import { useCallback, useEffect, useId, useRef, useState } from 'react'
|
|
21
|
+
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'
|
|
22
|
+
import { type ContentMetadata, parseContentMetadata } from '../../engine/content-metadata.js'
|
|
22
23
|
import { countWords, estimateReadTime } from '../../engine/publisher.js'
|
|
24
|
+
import type { ContentMetadataSelectField } from '../../integration/options.js'
|
|
23
25
|
import type { ContentStatus, ContentType } from '../../schema/types.js'
|
|
24
26
|
import { Icon } from '../icons.js'
|
|
25
27
|
import { uploadMediaAsset } from '../screens/media-upload.js'
|
|
@@ -31,8 +33,10 @@ import {
|
|
|
31
33
|
type ContentDraftFields,
|
|
32
34
|
canCreateContentDraft,
|
|
33
35
|
foundryDispatchSourceForDraft,
|
|
36
|
+
initialContentMetadata,
|
|
34
37
|
normalizeMediaDurationSeconds,
|
|
35
38
|
slugifyTitle,
|
|
39
|
+
updateContentMetadataSelection,
|
|
36
40
|
} from './content-payload.js'
|
|
37
41
|
import { measureEditorMediaDurationSeconds } from './editor-media-duration.js'
|
|
38
42
|
import { uploadEditorImage } from './editor-media-upload.js'
|
|
@@ -63,6 +67,7 @@ export interface ContentFormProps {
|
|
|
63
67
|
/** Called when save state changes so editor chrome can show a real save action. */
|
|
64
68
|
onSaveStateChange?: (state: ContentSaveState) => void
|
|
65
69
|
primaryCategories?: Array<{ slug: string; label: string }>
|
|
70
|
+
contentMetadataSelects?: ContentMetadataSelectField[]
|
|
66
71
|
}
|
|
67
72
|
|
|
68
73
|
export interface ContentSaveState {
|
|
@@ -106,6 +111,7 @@ interface ExistingContentItem {
|
|
|
106
111
|
published_at?: number | null
|
|
107
112
|
hero_image_id?: string | null
|
|
108
113
|
hero_image_url?: string | null
|
|
114
|
+
metadata_json?: string
|
|
109
115
|
}
|
|
110
116
|
|
|
111
117
|
interface ExistingContentPayload {
|
|
@@ -149,6 +155,7 @@ interface FormState {
|
|
|
149
155
|
publishedAt: string | null
|
|
150
156
|
premium: boolean
|
|
151
157
|
channel: string | null
|
|
158
|
+
metadata: ContentMetadata
|
|
152
159
|
heroImageId: string
|
|
153
160
|
heroImageUrl: string | null
|
|
154
161
|
heroUploadStatus: 'idle' | 'uploading' | 'staged'
|
|
@@ -180,7 +187,9 @@ interface FormState {
|
|
|
180
187
|
aiOpen: boolean
|
|
181
188
|
}
|
|
182
189
|
|
|
183
|
-
|
|
190
|
+
const EMPTY_CONTENT_METADATA_SELECTS: ContentMetadataSelectField[] = []
|
|
191
|
+
|
|
192
|
+
function createEmptyFormState(docId: string | null, metadata: ContentMetadata = {}): FormState {
|
|
184
193
|
return {
|
|
185
194
|
status: 'draft',
|
|
186
195
|
title: '',
|
|
@@ -197,6 +206,7 @@ function createEmptyFormState(docId: string | null): FormState {
|
|
|
197
206
|
publishedAt: null,
|
|
198
207
|
premium: false,
|
|
199
208
|
channel: null,
|
|
209
|
+
metadata,
|
|
200
210
|
heroImageId: '',
|
|
201
211
|
heroImageUrl: null,
|
|
202
212
|
heroUploadStatus: 'idle',
|
|
@@ -238,8 +248,15 @@ export function ContentForm({
|
|
|
238
248
|
onStatusChange,
|
|
239
249
|
onSaveStateChange,
|
|
240
250
|
primaryCategories = [],
|
|
251
|
+
contentMetadataSelects = EMPTY_CONTENT_METADATA_SELECTS,
|
|
241
252
|
}: ContentFormProps) {
|
|
242
|
-
const
|
|
253
|
+
const metadataFields = useMemo(
|
|
254
|
+
() => contentMetadataSelects.filter((field) => field.contentTypes.includes(contentType)),
|
|
255
|
+
[contentMetadataSelects, contentType],
|
|
256
|
+
)
|
|
257
|
+
const [form, setForm] = useState<FormState>(() =>
|
|
258
|
+
createEmptyFormState(docId, initialContentMetadata(metadataFields, contentType)),
|
|
259
|
+
)
|
|
243
260
|
|
|
244
261
|
// Autosave state (mutable refs, not React state — no re-render needed)
|
|
245
262
|
const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
@@ -260,7 +277,7 @@ export function ContentForm({
|
|
|
260
277
|
|
|
261
278
|
const loadExistingContent = useCallback(async () => {
|
|
262
279
|
if (!docId) {
|
|
263
|
-
const next = createEmptyFormState(null)
|
|
280
|
+
const next = createEmptyFormState(null, initialContentMetadata(metadataFields, contentType))
|
|
264
281
|
setForm(next)
|
|
265
282
|
publishDraft(next)
|
|
266
283
|
return
|
|
@@ -288,7 +305,7 @@ export function ContentForm({
|
|
|
288
305
|
saveError: err instanceof Error ? err.message : 'Could not load content',
|
|
289
306
|
}))
|
|
290
307
|
}
|
|
291
|
-
}, [contentType, docId, onStatusChange, publishDraft])
|
|
308
|
+
}, [contentType, docId, metadataFields, onStatusChange, publishDraft])
|
|
292
309
|
|
|
293
310
|
// -- Word-count (derived from the body markdown) -------------------------
|
|
294
311
|
const wordCount = countWords(form.body)
|
|
@@ -521,6 +538,16 @@ export function ContentForm({
|
|
|
521
538
|
scheduleAutosave(next)
|
|
522
539
|
}
|
|
523
540
|
|
|
541
|
+
function handleMetadataSelectChange(field: ContentMetadataSelectField, value: string) {
|
|
542
|
+
const next = {
|
|
543
|
+
...latestForm.current,
|
|
544
|
+
metadata: updateContentMetadataSelection(latestForm.current.metadata, field, value),
|
|
545
|
+
}
|
|
546
|
+
setForm(next)
|
|
547
|
+
publishDraft(next)
|
|
548
|
+
scheduleAutosave(next)
|
|
549
|
+
}
|
|
550
|
+
|
|
524
551
|
function handleBodyChange(md: string, portableText: string) {
|
|
525
552
|
const next = { ...latestForm.current, body: md, bodyPortableText: portableText }
|
|
526
553
|
setForm(next)
|
|
@@ -807,7 +834,7 @@ export function ContentForm({
|
|
|
807
834
|
}
|
|
808
835
|
|
|
809
836
|
return (
|
|
810
|
-
<div style={formWrap}>
|
|
837
|
+
<div className="editor-document" style={formWrap}>
|
|
811
838
|
{/* Autosave indicator + AI assist toggle */}
|
|
812
839
|
<div style={saveIndicator}>
|
|
813
840
|
<span
|
|
@@ -895,6 +922,39 @@ export function ContentForm({
|
|
|
895
922
|
</div>
|
|
896
923
|
)}
|
|
897
924
|
|
|
925
|
+
{metadataFields.map((field) => {
|
|
926
|
+
const storedValue = form.metadata[field.key]
|
|
927
|
+
const value = typeof storedValue === 'string' ? storedValue : ''
|
|
928
|
+
const inferred =
|
|
929
|
+
field.inferenceMarkerKey !== undefined && form.metadata[field.inferenceMarkerKey] === true
|
|
930
|
+
return (
|
|
931
|
+
<div key={field.key} style={fieldGroup}>
|
|
932
|
+
<label style={{ display: 'grid', gap: 6, fontSize: 12, color: 'var(--ink-muted)' }}>
|
|
933
|
+
{field.label}
|
|
934
|
+
<select
|
|
935
|
+
value={value}
|
|
936
|
+
onChange={(event) => handleMetadataSelectChange(field, event.currentTarget.value)}
|
|
937
|
+
aria-label={field.label}
|
|
938
|
+
style={urlInput}
|
|
939
|
+
>
|
|
940
|
+
<option value="">Select…</option>
|
|
941
|
+
{field.options.map((option) => (
|
|
942
|
+
<option key={option.value} value={option.value}>
|
|
943
|
+
{option.label}
|
|
944
|
+
</option>
|
|
945
|
+
))}
|
|
946
|
+
</select>
|
|
947
|
+
</label>
|
|
948
|
+
{(field.helpText || inferred) && (
|
|
949
|
+
<div style={assetHelp}>
|
|
950
|
+
{inferred ? 'Inferred value — review and choose the best fit. ' : ''}
|
|
951
|
+
{field.helpText}
|
|
952
|
+
</div>
|
|
953
|
+
)}
|
|
954
|
+
</div>
|
|
955
|
+
)
|
|
956
|
+
})}
|
|
957
|
+
|
|
898
958
|
{/* Signature masthead hairline between the standfirst and the body */}
|
|
899
959
|
<hr className="masthead-rule" style={{ margin: '6px 0 20px' }} />
|
|
900
960
|
|
|
@@ -1457,6 +1517,7 @@ function contentResponseToFormState(
|
|
|
1457
1517
|
next.publishedAt = unixSecondsToIso(item.published_at)
|
|
1458
1518
|
next.premium = item.visibility === 'premium'
|
|
1459
1519
|
next.channel = toNullableString(item.channel)
|
|
1520
|
+
next.metadata = storedContentMetadata(item.metadata_json)
|
|
1460
1521
|
next.heroImageId = heroImageId
|
|
1461
1522
|
next.heroImageUrl = heroImageUrl || (heroImageId ? mediaUrlFromId(heroImageId) : null)
|
|
1462
1523
|
|
|
@@ -1491,6 +1552,14 @@ function contentResponseToFormState(
|
|
|
1491
1552
|
return next
|
|
1492
1553
|
}
|
|
1493
1554
|
|
|
1555
|
+
function storedContentMetadata(raw: string | null | undefined): ContentMetadata {
|
|
1556
|
+
try {
|
|
1557
|
+
return parseContentMetadata(raw ?? '{}')
|
|
1558
|
+
} catch {
|
|
1559
|
+
return {}
|
|
1560
|
+
}
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1494
1563
|
function formToPreviewDraft(form: FormState, contentType: ContentType): ContentPreviewDraft {
|
|
1495
1564
|
return {
|
|
1496
1565
|
id: form.savedId ?? 'draft',
|
|
@@ -1567,7 +1636,6 @@ const formWrap: React.CSSProperties = {
|
|
|
1567
1636
|
display: 'flex',
|
|
1568
1637
|
flexDirection: 'column',
|
|
1569
1638
|
gap: 0,
|
|
1570
|
-
padding: '16px 24px',
|
|
1571
1639
|
flex: 1,
|
|
1572
1640
|
overflowY: 'auto',
|
|
1573
1641
|
}
|
|
@@ -1589,11 +1657,11 @@ const titleInput: React.CSSProperties = {
|
|
|
1589
1657
|
width: '100%',
|
|
1590
1658
|
border: 0,
|
|
1591
1659
|
background: 'transparent',
|
|
1592
|
-
fontFamily: 'var(--
|
|
1660
|
+
fontFamily: 'var(--font-display)',
|
|
1593
1661
|
fontWeight: 600,
|
|
1594
|
-
fontSize:
|
|
1595
|
-
letterSpacing: '-0.
|
|
1596
|
-
lineHeight: 1.
|
|
1662
|
+
fontSize: 34,
|
|
1663
|
+
letterSpacing: '-0.02em',
|
|
1664
|
+
lineHeight: 1.15,
|
|
1597
1665
|
color: 'var(--ink)',
|
|
1598
1666
|
padding: 0,
|
|
1599
1667
|
outline: 'none',
|
|
@@ -1603,8 +1671,8 @@ const dekInput: React.CSSProperties = {
|
|
|
1603
1671
|
width: '100%',
|
|
1604
1672
|
border: 0,
|
|
1605
1673
|
background: 'transparent',
|
|
1606
|
-
fontFamily: 'var(--
|
|
1607
|
-
fontSize:
|
|
1674
|
+
fontFamily: 'var(--font-body)',
|
|
1675
|
+
fontSize: 17,
|
|
1608
1676
|
lineHeight: 1.5,
|
|
1609
1677
|
color: 'var(--ink-soft)',
|
|
1610
1678
|
padding: 0,
|
package/src/ui/editor/Rte.tsx
CHANGED
|
@@ -493,8 +493,8 @@ const contentWrap: React.CSSProperties = {
|
|
|
493
493
|
flex: 1,
|
|
494
494
|
padding: '20px 22px',
|
|
495
495
|
overflowY: 'auto',
|
|
496
|
-
fontFamily: 'var(--
|
|
497
|
-
fontSize:
|
|
498
|
-
lineHeight: 1.
|
|
496
|
+
fontFamily: 'var(--font-body)',
|
|
497
|
+
fontSize: 15.5,
|
|
498
|
+
lineHeight: 1.65,
|
|
499
499
|
color: 'var(--ink)',
|
|
500
500
|
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import type { ContentMetadata } from '../../engine/content-metadata.js'
|
|
1
2
|
import { countWords, estimateReadTime } from '../../engine/publisher.js'
|
|
3
|
+
import type { ContentMetadataSelectField } from '../../integration/options.js'
|
|
2
4
|
import type { ContentType } from '../../schema/types.js'
|
|
3
5
|
|
|
4
6
|
export interface ContentDraftFields {
|
|
@@ -17,6 +19,42 @@ export interface ContentDraftFields {
|
|
|
17
19
|
audioUrl: string
|
|
18
20
|
durationSeconds: number | null
|
|
19
21
|
channel?: string | null
|
|
22
|
+
metadata?: ContentMetadata
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
type MetadataSelectFieldContract = Pick<
|
|
26
|
+
ContentMetadataSelectField,
|
|
27
|
+
'key' | 'defaultValue' | 'inferenceMarkerKey'
|
|
28
|
+
> & {
|
|
29
|
+
contentTypes: readonly ContentType[]
|
|
30
|
+
options: ReadonlyArray<{ value: string }>
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function initialContentMetadata(
|
|
34
|
+
fields: readonly MetadataSelectFieldContract[],
|
|
35
|
+
contentType: ContentType,
|
|
36
|
+
): ContentMetadata {
|
|
37
|
+
const metadata: ContentMetadata = {}
|
|
38
|
+
for (const field of fields) {
|
|
39
|
+
if (!field.contentTypes.includes(contentType) || !field.defaultValue) continue
|
|
40
|
+
metadata[field.key] = field.defaultValue
|
|
41
|
+
}
|
|
42
|
+
return metadata
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function updateContentMetadataSelection(
|
|
46
|
+
metadata: Readonly<ContentMetadata>,
|
|
47
|
+
field: MetadataSelectFieldContract,
|
|
48
|
+
value: string,
|
|
49
|
+
): ContentMetadata {
|
|
50
|
+
if (value && !field.options.some((option) => option.value === value)) {
|
|
51
|
+
throw new RangeError(`Unknown option for metadata field ${field.key}`)
|
|
52
|
+
}
|
|
53
|
+
const next: ContentMetadata = { ...metadata }
|
|
54
|
+
if (value) next[field.key] = value
|
|
55
|
+
else delete next[field.key]
|
|
56
|
+
if (field.inferenceMarkerKey) delete next[field.inferenceMarkerKey]
|
|
57
|
+
return next
|
|
20
58
|
}
|
|
21
59
|
|
|
22
60
|
export function normalizeMediaDurationSeconds(value: unknown): number | null {
|
|
@@ -97,6 +135,7 @@ export function buildContentUpdatePayload(
|
|
|
97
135
|
title: draft.title,
|
|
98
136
|
excerpt: draft.dek || null,
|
|
99
137
|
}
|
|
138
|
+
if (draft.metadata !== undefined) payload.metadata = draft.metadata
|
|
100
139
|
const channel = draft.channel?.trim()
|
|
101
140
|
payload.primaryCategory = channel || null
|
|
102
141
|
const heroImageId = draft.heroImageId?.trim()
|
|
@@ -499,7 +499,7 @@ const progressTrack: React.CSSProperties = {
|
|
|
499
499
|
const progressFill: React.CSSProperties = {
|
|
500
500
|
width: '100%',
|
|
501
501
|
height: '100%',
|
|
502
|
-
background: 'var(--
|
|
502
|
+
background: 'var(--accent-primary)',
|
|
503
503
|
}
|
|
504
504
|
|
|
505
505
|
const errorText: React.CSSProperties = {
|
package/src/ui/pages/admin.astro
CHANGED
|
@@ -22,7 +22,7 @@ const initial = { workspaces: config.workspaces, activeWorkspaceId: config.activ
|
|
|
22
22
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
23
23
|
<link
|
|
24
24
|
rel="stylesheet"
|
|
25
|
-
href="https://fonts.googleapis.com/css2?family=
|
|
25
|
+
href="https://fonts.googleapis.com/css2?family=Lato:wght@300;400;700&family=Space+Grotesk:wght@400;500;600;700&display=swap"
|
|
26
26
|
/>
|
|
27
27
|
</head>
|
|
28
28
|
<body>
|
|
@@ -520,7 +520,7 @@ function initialsFor(name: string): string {
|
|
|
520
520
|
return (name.trim().slice(0, 2) || '—').toUpperCase()
|
|
521
521
|
}
|
|
522
522
|
|
|
523
|
-
const AVATAR_COLORS = ['#
|
|
523
|
+
const AVATAR_COLORS = ['#2F6FBD', '#1FA3A8', '#8075ED', '#F7A34B', '#2CA8E0', '#4D4D4D']
|
|
524
524
|
|
|
525
525
|
function avatarColor(seed: string): string {
|
|
526
526
|
let h = 0
|
|
@@ -171,7 +171,7 @@ export function ContentInsightsScreen() {
|
|
|
171
171
|
style={{
|
|
172
172
|
overflow: 'hidden',
|
|
173
173
|
marginBottom: 'var(--gap)',
|
|
174
|
-
borderLeft: `3px solid ${group.lane == null ? 'var(--hairline)' : 'var(--
|
|
174
|
+
borderLeft: `3px solid ${group.lane == null ? 'var(--hairline)' : 'var(--accent-primary)'}`,
|
|
175
175
|
}}
|
|
176
176
|
>
|
|
177
177
|
<header
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
// This file creates thin stubs so the registry can compile now (Task 10).
|
|
9
9
|
|
|
10
10
|
import { useReducer } from 'react'
|
|
11
|
+
import type { ContentMetadataSelectField } from '../../integration/options.js'
|
|
11
12
|
import { type ContentType, contentViewReducer, initialContentView } from './content-view.js'
|
|
12
13
|
import { EditorScreen } from './EditorScreen.js'
|
|
13
14
|
import { LibraryScreen } from './LibraryScreen.js'
|
|
@@ -15,9 +16,11 @@ import { LibraryScreen } from './LibraryScreen.js'
|
|
|
15
16
|
export function ContentRoute({
|
|
16
17
|
allowInlineAuthorCreation = true,
|
|
17
18
|
primaryCategories = [],
|
|
19
|
+
contentMetadataSelects = [],
|
|
18
20
|
}: {
|
|
19
21
|
allowInlineAuthorCreation?: boolean
|
|
20
22
|
primaryCategories?: Array<{ slug: string; label: string }>
|
|
23
|
+
contentMetadataSelects?: ContentMetadataSelectField[]
|
|
21
24
|
}) {
|
|
22
25
|
const [view, dispatch] = useReducer(contentViewReducer, initialContentView)
|
|
23
26
|
|
|
@@ -49,6 +52,7 @@ export function ContentRoute({
|
|
|
49
52
|
onClose={handleClose}
|
|
50
53
|
allowInlineAuthorCreation={allowInlineAuthorCreation}
|
|
51
54
|
primaryCategories={primaryCategories}
|
|
55
|
+
contentMetadataSelects={contentMetadataSelects}
|
|
52
56
|
/>
|
|
53
57
|
)
|
|
54
58
|
}
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
// is Playwright-smoked only.
|
|
18
18
|
|
|
19
19
|
import { useCallback, useState } from 'react'
|
|
20
|
+
import type { ContentMetadataSelectField } from '../../integration/options.js'
|
|
20
21
|
import type { ContentStatus, ContentType } from '../../schema/types.js'
|
|
21
22
|
import { buildSharePrefill, ShareModal } from '../components/ShareModal.js'
|
|
22
23
|
import {
|
|
@@ -40,6 +41,7 @@ export interface EditorScreenProps {
|
|
|
40
41
|
onClose: () => void
|
|
41
42
|
allowInlineAuthorCreation?: boolean
|
|
42
43
|
primaryCategories?: Array<{ slug: string; label: string }>
|
|
44
|
+
contentMetadataSelects?: ContentMetadataSelectField[]
|
|
43
45
|
}
|
|
44
46
|
|
|
45
47
|
// ---------------------------------------------------------------------------
|
|
@@ -58,12 +60,20 @@ const CONTENT_TYPE_LABELS: Record<ContentType, string> = {
|
|
|
58
60
|
|
|
59
61
|
const STATUS_PILL_COLORS: Record<ContentStatus, string> = {
|
|
60
62
|
draft: 'var(--ink-faint)',
|
|
61
|
-
review: '
|
|
62
|
-
scheduled: '
|
|
63
|
-
published: '
|
|
63
|
+
review: 'var(--amber)',
|
|
64
|
+
scheduled: 'var(--accent)',
|
|
65
|
+
published: 'var(--green)',
|
|
64
66
|
archived: 'var(--ink-faint)',
|
|
65
67
|
}
|
|
66
68
|
|
|
69
|
+
export function editorStatusColor(status: ContentStatus): string {
|
|
70
|
+
return STATUS_PILL_COLORS[status]
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function editorStatusBackground(status: ContentStatus): string {
|
|
74
|
+
return `color-mix(in srgb, ${editorStatusColor(status)} 14%, transparent)`
|
|
75
|
+
}
|
|
76
|
+
|
|
67
77
|
// ---------------------------------------------------------------------------
|
|
68
78
|
// EditorScreen
|
|
69
79
|
// ---------------------------------------------------------------------------
|
|
@@ -73,6 +83,7 @@ export function EditorScreen({
|
|
|
73
83
|
onClose,
|
|
74
84
|
allowInlineAuthorCreation = true,
|
|
75
85
|
primaryCategories = [],
|
|
86
|
+
contentMetadataSelects = [],
|
|
76
87
|
}: EditorScreenProps) {
|
|
77
88
|
const contentType: ContentType = (docRef.contentType as ContentType | undefined) ?? 'article'
|
|
78
89
|
const siteBase = typeof window !== 'undefined' ? window.location.origin : undefined
|
|
@@ -369,6 +380,7 @@ export function EditorScreen({
|
|
|
369
380
|
onStatusChange={setStatus}
|
|
370
381
|
onSaveStateChange={handleSaveStateChange}
|
|
371
382
|
primaryCategories={primaryCategories}
|
|
383
|
+
contentMetadataSelects={contentMetadataSelects}
|
|
372
384
|
/>
|
|
373
385
|
</div>
|
|
374
386
|
|
|
@@ -503,8 +515,8 @@ function statusPill(status: ContentStatus): React.CSSProperties {
|
|
|
503
515
|
fontWeight: 600,
|
|
504
516
|
letterSpacing: '0.06em',
|
|
505
517
|
textTransform: 'uppercase',
|
|
506
|
-
background:
|
|
507
|
-
color:
|
|
518
|
+
background: editorStatusBackground(status),
|
|
519
|
+
color: editorStatusColor(status),
|
|
508
520
|
flexShrink: 0,
|
|
509
521
|
}
|
|
510
522
|
}
|
|
@@ -526,10 +538,10 @@ const headerBar: React.CSSProperties = {
|
|
|
526
538
|
display: 'flex',
|
|
527
539
|
alignItems: 'center',
|
|
528
540
|
justifyContent: 'space-between',
|
|
529
|
-
height:
|
|
530
|
-
padding: '0
|
|
541
|
+
height: 58,
|
|
542
|
+
padding: '0 20px',
|
|
531
543
|
borderBottom: '1px solid var(--border)',
|
|
532
|
-
background: 'var(--
|
|
544
|
+
background: 'var(--surface-raised-nav)',
|
|
533
545
|
flexShrink: 0,
|
|
534
546
|
gap: 12,
|
|
535
547
|
}
|
|
@@ -661,7 +673,7 @@ const formArea: React.CSSProperties = {
|
|
|
661
673
|
}
|
|
662
674
|
|
|
663
675
|
const inspectorSlot: React.CSSProperties = {
|
|
664
|
-
flex: '0 0
|
|
676
|
+
flex: '0 0 296px',
|
|
665
677
|
borderLeft: '1px solid var(--border)',
|
|
666
678
|
display: 'flex',
|
|
667
679
|
flexDirection: 'column',
|
|
@@ -753,7 +753,10 @@ function ListRow({
|
|
|
753
753
|
>
|
|
754
754
|
{row.title || '(Untitled)'}
|
|
755
755
|
{row.featured && (
|
|
756
|
-
<span
|
|
756
|
+
<span
|
|
757
|
+
title="Premium"
|
|
758
|
+
style={{ color: 'var(--accent-primary-strong)', flex: 'none' }}
|
|
759
|
+
>
|
|
757
760
|
<Icon name="star" size={13} />
|
|
758
761
|
</span>
|
|
759
762
|
)}
|
|
@@ -1064,7 +1067,7 @@ function BoardCard({
|
|
|
1064
1067
|
{row.updatedAt ? formatDate(row.updatedAt) : (row.authorName ?? '—')}
|
|
1065
1068
|
</span>
|
|
1066
1069
|
{row.featured && (
|
|
1067
|
-
<span style={{ marginLeft: 'auto', color: 'var(--
|
|
1070
|
+
<span style={{ marginLeft: 'auto', color: 'var(--accent-primary-strong)' }}>
|
|
1068
1071
|
<Icon name="star" size={12} />
|
|
1069
1072
|
</span>
|
|
1070
1073
|
)}
|
|
@@ -375,7 +375,7 @@ function GeneralSettings({
|
|
|
375
375
|
// Branding settings
|
|
376
376
|
// ---------------------------------------------------------------------------
|
|
377
377
|
|
|
378
|
-
const ACCENT_PRESETS = ['#
|
|
378
|
+
const ACCENT_PRESETS = ['#2F6FBD', '#1FA3A8', '#2CA8E0', '#8075ED', '#F56A61']
|
|
379
379
|
const TYPEFACE_OPTIONS = ['serif', 'Source Serif 4', 'Spectral', 'Newsreader', 'Inter']
|
|
380
380
|
|
|
381
381
|
function BrandingSettings({
|
|
@@ -1310,7 +1310,7 @@ function KpiCard({
|
|
|
1310
1310
|
// PlanMixChart — stacked bar + legend rows (aside panel)
|
|
1311
1311
|
// ---------------------------------------------------------------------------
|
|
1312
1312
|
|
|
1313
|
-
const PLAN_DOT_COLORS = ['#
|
|
1313
|
+
const PLAN_DOT_COLORS = ['#1FA3A8', '#2F6FBD', '#8075ED', '#F7A34B', '#4D4D4D']
|
|
1314
1314
|
|
|
1315
1315
|
function PlanMixChart({
|
|
1316
1316
|
planMix,
|
|
@@ -1746,7 +1746,7 @@ function initialsFor(email: string): string {
|
|
|
1746
1746
|
return local.slice(0, 2).toUpperCase()
|
|
1747
1747
|
}
|
|
1748
1748
|
|
|
1749
|
-
const AVATAR_COLORS = ['#
|
|
1749
|
+
const AVATAR_COLORS = ['#2F6FBD', '#1FA3A8', '#8075ED', '#F7A34B', '#2CA8E0', '#4D4D4D']
|
|
1750
1750
|
|
|
1751
1751
|
function avatarColor(seed: string): string {
|
|
1752
1752
|
let h = 0
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
// internal router state (which is not exported). The ContentRoute itself owns
|
|
12
12
|
// the full open-doc reducer (which doc is open, new vs existing).
|
|
13
13
|
|
|
14
|
+
import type { ContentMetadataSelectField } from '../../integration/options.js'
|
|
14
15
|
import type { ScreenRegistry } from '../components/CmsApp.js'
|
|
15
16
|
import { useHashRouter } from '../use-hash-router.js'
|
|
16
17
|
import { AnalyticsScreen } from './AnalyticsScreen.js'
|
|
@@ -42,10 +43,12 @@ function CalendarRoute() {
|
|
|
42
43
|
export function createCmsScreens({
|
|
43
44
|
allowInlineAuthorCreation = true,
|
|
44
45
|
primaryCategories = [],
|
|
46
|
+
contentMetadataSelects = [],
|
|
45
47
|
surveyId,
|
|
46
48
|
}: {
|
|
47
49
|
allowInlineAuthorCreation?: boolean
|
|
48
50
|
primaryCategories?: Array<{ slug: string; label: string }>
|
|
51
|
+
contentMetadataSelects?: ContentMetadataSelectField[]
|
|
49
52
|
surveyId?: string
|
|
50
53
|
} = {}): ScreenRegistry {
|
|
51
54
|
return {
|
|
@@ -53,6 +56,7 @@ export function createCmsScreens({
|
|
|
53
56
|
<ContentRoute
|
|
54
57
|
allowInlineAuthorCreation={allowInlineAuthorCreation}
|
|
55
58
|
primaryCategories={primaryCategories}
|
|
59
|
+
contentMetadataSelects={contentMetadataSelects}
|
|
56
60
|
/>
|
|
57
61
|
),
|
|
58
62
|
'content-insights': () => <ContentInsightsScreen />,
|