@growth-labs/cms 0.1.1 → 0.1.2
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 +6 -2
- package/dist/engine/publisher.d.ts +14 -3
- package/dist/engine/publisher.d.ts.map +1 -1
- package/dist/engine/publisher.js +11 -5
- package/dist/engine/publisher.js.map +1 -1
- package/dist/routes/content.d.ts +1 -1
- package/dist/routes/content.d.ts.map +1 -1
- package/dist/routes/content.js +30 -23
- package/dist/routes/content.js.map +1 -1
- package/dist/schema/insights-ingest.d.ts +16 -16
- package/dist/schema/migrations.d.ts +5 -3
- package/dist/schema/migrations.d.ts.map +1 -1
- package/dist/schema/migrations.js +148 -4
- package/dist/schema/migrations.js.map +1 -1
- package/dist/schema/types.d.ts +2 -2
- package/dist/schema/types.d.ts.map +1 -1
- package/dist/ui/commands.d.ts +2 -1
- package/dist/ui/commands.d.ts.map +1 -1
- package/dist/ui/commands.js +1 -2
- package/dist/ui/commands.js.map +1 -1
- package/dist/ui/components/SharePickers.d.ts.map +1 -1
- package/dist/ui/components/SharePickers.js +11 -7
- package/dist/ui/components/SharePickers.js.map +1 -1
- package/dist/ui/editor/ContentForm.d.ts +1 -1
- package/dist/ui/editor/ContentForm.d.ts.map +1 -1
- package/dist/ui/editor/ContentForm.js +5 -5
- package/dist/ui/editor/ContentForm.js.map +1 -1
- package/dist/ui/editor/content-payload.js +2 -2
- package/dist/ui/editor/content-payload.js.map +1 -1
- package/dist/ui/preview/draft-page.js +5 -4
- package/dist/ui/preview/draft-page.js.map +1 -1
- package/dist/ui/screens/EditorScreen.d.ts.map +1 -1
- package/dist/ui/screens/EditorScreen.js +2 -0
- package/dist/ui/screens/EditorScreen.js.map +1 -1
- package/dist/ui/screens/LibraryScreen.d.ts.map +1 -1
- package/dist/ui/screens/LibraryScreen.js +4 -2
- package/dist/ui/screens/LibraryScreen.js.map +1 -1
- package/dist/ui/screens/SocialShareScreen.js +1 -1
- package/dist/ui/screens/SocialShareScreen.js.map +1 -1
- package/dist/ui/screens/content-view.d.ts +2 -1
- package/dist/ui/screens/content-view.d.ts.map +1 -1
- package/dist/ui/screens/content-view.js.map +1 -1
- package/dist/ui/screens/public-url.d.ts.map +1 -1
- package/dist/ui/screens/public-url.js +2 -0
- package/dist/ui/screens/public-url.js.map +1 -1
- package/migrations/0001_create_cms_tables.sql +4 -4
- package/migrations/0017_content_pages.sql +138 -0
- package/package.json +1 -1
- package/src/engine/publisher.ts +45 -9
- package/src/routes/content.ts +40 -26
- package/src/schema/migrations.ts +148 -4
- package/src/schema/types.ts +2 -2
- package/src/ui/commands.ts +3 -1
- package/src/ui/components/SharePickers.tsx +13 -10
- package/src/ui/editor/ContentForm.tsx +7 -7
- package/src/ui/editor/content-payload.ts +2 -2
- package/src/ui/preview/draft-page.tsx +7 -4
- package/src/ui/screens/EditorScreen.tsx +2 -0
- package/src/ui/screens/LibraryScreen.tsx +4 -2
- package/src/ui/screens/SocialShareScreen.tsx +1 -0
- package/src/ui/screens/content-view.ts +3 -1
- package/src/ui/screens/public-url.ts +2 -0
package/src/engine/publisher.ts
CHANGED
|
@@ -19,12 +19,17 @@
|
|
|
19
19
|
// timestamps; the video copy hard-codes `processing_state = 'ready'` so a
|
|
20
20
|
// duplicate is NOT re-queued to Foundry. Any future `content_items` column
|
|
21
21
|
// addition requires a matching edit here or the duplicate silently drops it.
|
|
22
|
+
import type { ContentType } from '../schema/types.js'
|
|
22
23
|
import type { D1Database } from './d1.js'
|
|
23
24
|
import { evaluateArticleBody, type PublishGuardOutcome } from './publish-guard.js'
|
|
24
25
|
import { slugify } from './slug.js'
|
|
25
26
|
|
|
26
|
-
type ContentType = 'article' | 'video' | 'podcast' | 'newsletter'
|
|
27
27
|
type ContentVisibility = 'free' | 'premium'
|
|
28
|
+
type BodyBackedContentType = 'article' | 'newsletter' | 'page'
|
|
29
|
+
|
|
30
|
+
function isBodyBackedContentType(type: ContentType): type is BodyBackedContentType {
|
|
31
|
+
return type === 'article' || type === 'newsletter' || type === 'page'
|
|
32
|
+
}
|
|
28
33
|
|
|
29
34
|
/**
|
|
30
35
|
* The processing_state a freshly created video starts in. Mirrors
|
|
@@ -137,10 +142,39 @@ export interface NewsletterInput extends BaseContentInput {
|
|
|
137
142
|
}
|
|
138
143
|
}
|
|
139
144
|
|
|
140
|
-
export
|
|
145
|
+
export interface PageInput extends BaseContentInput {
|
|
146
|
+
type: 'page'
|
|
147
|
+
content: {
|
|
148
|
+
bodyMarkdown: string
|
|
149
|
+
bodyHtml?: string | null
|
|
150
|
+
subtitle?: string | null
|
|
151
|
+
wordCount?: number | null
|
|
152
|
+
readTimeMinutes?: number | null
|
|
153
|
+
editorTakeaways?: string[] | null
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export type CreateContentInput =
|
|
158
|
+
| ArticleInput
|
|
159
|
+
| VideoInput
|
|
160
|
+
| PodcastInput
|
|
161
|
+
| NewsletterInput
|
|
162
|
+
| PageInput
|
|
163
|
+
|
|
164
|
+
type BodyBackedContentInput = ArticleInput | NewsletterInput | PageInput
|
|
165
|
+
|
|
166
|
+
function isBodyBackedContentInput(input: CreateContentInput): input is BodyBackedContentInput {
|
|
167
|
+
return isBodyBackedContentType(input.type)
|
|
168
|
+
}
|
|
141
169
|
|
|
142
170
|
export interface UpdateContentInput extends Partial<BaseContentInput> {
|
|
143
|
-
content?: Partial<
|
|
171
|
+
content?: Partial<
|
|
172
|
+
| ArticleInput['content']
|
|
173
|
+
| VideoInput['content']
|
|
174
|
+
| PodcastInput['content']
|
|
175
|
+
| NewsletterInput['content']
|
|
176
|
+
| PageInput['content']
|
|
177
|
+
>
|
|
144
178
|
relations?: Array<{
|
|
145
179
|
relatedId: string
|
|
146
180
|
rank: number
|
|
@@ -342,7 +376,7 @@ export async function createContent(
|
|
|
342
376
|
)
|
|
343
377
|
.run()
|
|
344
378
|
|
|
345
|
-
if (input
|
|
379
|
+
if (isBodyBackedContentInput(input)) {
|
|
346
380
|
const wc = input.content.wordCount ?? countWords(input.content.bodyMarkdown)
|
|
347
381
|
const rt = input.content.readTimeMinutes ?? estimateReadTime(wc)
|
|
348
382
|
await db
|
|
@@ -530,7 +564,7 @@ export async function updateContentItem(
|
|
|
530
564
|
}
|
|
531
565
|
|
|
532
566
|
if (input.content) {
|
|
533
|
-
if (existing.type
|
|
567
|
+
if (isBodyBackedContentType(existing.type)) {
|
|
534
568
|
const current = await db
|
|
535
569
|
.prepare(
|
|
536
570
|
`
|
|
@@ -551,7 +585,9 @@ export async function updateContentItem(
|
|
|
551
585
|
}>()
|
|
552
586
|
if (!current) return { slug }
|
|
553
587
|
|
|
554
|
-
const articleContent = input.content as Partial<
|
|
588
|
+
const articleContent = input.content as Partial<
|
|
589
|
+
ArticleInput['content'] | NewsletterInput['content'] | PageInput['content']
|
|
590
|
+
>
|
|
555
591
|
const bodyMarkdownProvided = hasOwn(articleContent, 'bodyMarkdown')
|
|
556
592
|
const wordCountProvided = hasOwn(articleContent, 'wordCount')
|
|
557
593
|
const readTimeProvided = hasOwn(articleContent, 'readTimeMinutes')
|
|
@@ -779,7 +815,7 @@ export async function evaluateContentBodyForPublish(
|
|
|
779
815
|
body_html: string | null
|
|
780
816
|
}>()
|
|
781
817
|
|
|
782
|
-
if (!row || (row.type
|
|
818
|
+
if (!row || !isBodyBackedContentType(row.type)) {
|
|
783
819
|
return null
|
|
784
820
|
}
|
|
785
821
|
|
|
@@ -906,7 +942,7 @@ export async function duplicateContentItem(
|
|
|
906
942
|
.bind(newId, newSlug, sourceId)
|
|
907
943
|
.run()
|
|
908
944
|
|
|
909
|
-
if (source.type
|
|
945
|
+
if (isBodyBackedContentType(source.type)) {
|
|
910
946
|
// INSERT…SELECT copy #2 — article_content. Body + editor takeaways + the
|
|
911
947
|
// generated faq_json carry across so the duplicate keeps its SEO/FAQ work.
|
|
912
948
|
await db
|
|
@@ -988,7 +1024,7 @@ export async function getContentSnapshot(
|
|
|
988
1024
|
const type = base.type as ContentType
|
|
989
1025
|
let content: Record<string, unknown> | null = null
|
|
990
1026
|
|
|
991
|
-
if (type
|
|
1027
|
+
if (isBodyBackedContentType(type)) {
|
|
992
1028
|
content = await db
|
|
993
1029
|
.prepare('SELECT * FROM article_content WHERE content_id = ? LIMIT 1')
|
|
994
1030
|
.bind(contentId)
|
package/src/routes/content.ts
CHANGED
|
@@ -126,7 +126,7 @@ export interface ContentRouteConfig extends CmsRouteConfig {
|
|
|
126
126
|
const PUBLISH_TIMEZONE_FALLBACK = 'Europe/Paris'
|
|
127
127
|
const LOCAL_DATE_TIME_RE = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})$/
|
|
128
128
|
|
|
129
|
-
type ContentType = 'article' | 'video' | 'podcast' | 'newsletter'
|
|
129
|
+
type ContentType = 'article' | 'video' | 'podcast' | 'newsletter' | 'page'
|
|
130
130
|
|
|
131
131
|
/**
|
|
132
132
|
* Map a content `action` verb (the ?action= query value) to the capability the
|
|
@@ -157,15 +157,18 @@ function mapActionToCapability(action: string | null, type: ContentType): Conten
|
|
|
157
157
|
}
|
|
158
158
|
}
|
|
159
159
|
|
|
160
|
-
const parseTypes = (
|
|
161
|
-
|
|
162
|
-
|
|
160
|
+
const parseTypes = (
|
|
161
|
+
value: string | null,
|
|
162
|
+
): { ok: true; types: ContentType[] | undefined } | { ok: false; invalidTypes: string[] } => {
|
|
163
|
+
if (!value) return { ok: true, types: undefined }
|
|
164
|
+
const allowed: ContentType[] = ['article', 'video', 'podcast', 'newsletter', 'page']
|
|
163
165
|
const parts = value
|
|
164
166
|
.split(',')
|
|
165
167
|
.map((item) => item.trim())
|
|
166
|
-
.filter(Boolean)
|
|
167
|
-
const
|
|
168
|
-
|
|
168
|
+
.filter(Boolean)
|
|
169
|
+
const invalidTypes = parts.filter((part) => !allowed.includes(part as ContentType))
|
|
170
|
+
if (invalidTypes.length > 0) return { ok: false, invalidTypes }
|
|
171
|
+
return { ok: true, types: parts.length > 0 ? (parts as ContentType[]) : undefined }
|
|
169
172
|
}
|
|
170
173
|
|
|
171
174
|
const parseStatus = (value: string | null): ContentStatus | undefined => {
|
|
@@ -214,6 +217,15 @@ function slugEnum(values: readonly string[] | null) {
|
|
|
214
217
|
: z.string().min(1)
|
|
215
218
|
}
|
|
216
219
|
|
|
220
|
+
const BodyContentCreateSchema = z.object({
|
|
221
|
+
bodyMarkdown: z.string().min(1),
|
|
222
|
+
bodyHtml: z.string().optional().nullable(),
|
|
223
|
+
subtitle: z.string().optional().nullable(),
|
|
224
|
+
wordCount: z.number().int().optional().nullable(),
|
|
225
|
+
readTimeMinutes: z.number().int().optional().nullable(),
|
|
226
|
+
editorTakeaways: z.array(z.string().min(1)).max(4).optional().nullable(),
|
|
227
|
+
})
|
|
228
|
+
|
|
217
229
|
function buildSchemas(resolved: ReturnType<typeof resolveConfig>) {
|
|
218
230
|
const category = slugEnum(resolved.primaryCategorySlugs)
|
|
219
231
|
const topic = slugEnum(resolved.primaryTopicSlugs)
|
|
@@ -241,14 +253,7 @@ function buildSchemas(resolved: ReturnType<typeof resolveConfig>) {
|
|
|
241
253
|
const CreateSchema = z.discriminatedUnion('type', [
|
|
242
254
|
BaseSchema.extend({
|
|
243
255
|
type: z.literal('article'),
|
|
244
|
-
content:
|
|
245
|
-
bodyMarkdown: z.string().min(1),
|
|
246
|
-
bodyHtml: z.string().optional().nullable(),
|
|
247
|
-
subtitle: z.string().optional().nullable(),
|
|
248
|
-
wordCount: z.number().int().optional().nullable(),
|
|
249
|
-
readTimeMinutes: z.number().int().optional().nullable(),
|
|
250
|
-
editorTakeaways: z.array(z.string().min(1)).max(4).optional().nullable(),
|
|
251
|
-
}),
|
|
256
|
+
content: BodyContentCreateSchema,
|
|
252
257
|
}),
|
|
253
258
|
BaseSchema.extend({
|
|
254
259
|
type: z.literal('video'),
|
|
@@ -275,14 +280,11 @@ function buildSchemas(resolved: ReturnType<typeof resolveConfig>) {
|
|
|
275
280
|
}),
|
|
276
281
|
BaseSchema.extend({
|
|
277
282
|
type: z.literal('newsletter'),
|
|
278
|
-
content:
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
readTimeMinutes: z.number().int().optional().nullable(),
|
|
284
|
-
editorTakeaways: z.array(z.string().min(1)).max(4).optional().nullable(),
|
|
285
|
-
}),
|
|
283
|
+
content: BodyContentCreateSchema,
|
|
284
|
+
}),
|
|
285
|
+
BaseSchema.extend({
|
|
286
|
+
type: z.literal('page'),
|
|
287
|
+
content: BodyContentCreateSchema,
|
|
286
288
|
}),
|
|
287
289
|
])
|
|
288
290
|
|
|
@@ -348,6 +350,7 @@ function getContentUpdateSchema(type: string) {
|
|
|
348
350
|
switch (type) {
|
|
349
351
|
case 'article':
|
|
350
352
|
case 'newsletter':
|
|
353
|
+
case 'page':
|
|
351
354
|
return ArticleContentUpdateSchema
|
|
352
355
|
case 'video':
|
|
353
356
|
return VideoContentUpdateSchema
|
|
@@ -521,7 +524,7 @@ function parseLocalDateTimeInZoneToUnixSeconds(value: string, timeZone: string):
|
|
|
521
524
|
// --- read-side payload helpers (ported verbatim) --------------------------
|
|
522
525
|
|
|
523
526
|
async function getContentPayload(ctx: RouteContext, type: string, id: string) {
|
|
524
|
-
if (type === 'article' || type === 'newsletter') {
|
|
527
|
+
if (type === 'article' || type === 'newsletter' || type === 'page') {
|
|
525
528
|
return ctx.db
|
|
526
529
|
.prepare(
|
|
527
530
|
`SELECT body_markdown, body_html, word_count, read_time_minutes, subtitle,
|
|
@@ -760,7 +763,14 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
|
|
|
760
763
|
const url = new URL(ctx.request.url)
|
|
761
764
|
const limit = Math.min(Number(url.searchParams.get('limit') || '20'), 50)
|
|
762
765
|
const status = parseStatus(url.searchParams.get('status'))
|
|
763
|
-
const
|
|
766
|
+
const parsedTypes = parseTypes(url.searchParams.get('types'))
|
|
767
|
+
if (!parsedTypes.ok) {
|
|
768
|
+
return json(
|
|
769
|
+
{ error: 'Invalid content type filter', invalidTypes: parsedTypes.invalidTypes },
|
|
770
|
+
400,
|
|
771
|
+
)
|
|
772
|
+
}
|
|
773
|
+
const types = parsedTypes.types
|
|
764
774
|
const cursor = parseCursor(url.searchParams.get('cursor'))
|
|
765
775
|
const q = url.searchParams.get('q') || null
|
|
766
776
|
const author = url.searchParams.get('author') || null
|
|
@@ -1080,7 +1090,11 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
|
|
|
1080
1090
|
// the same check but allow empty bodies (the auto-save path needs to
|
|
1081
1091
|
// persist work in progress); publish-time validation rejects empty
|
|
1082
1092
|
// bodies separately.
|
|
1083
|
-
if (
|
|
1093
|
+
if (
|
|
1094
|
+
existing.type === 'article' ||
|
|
1095
|
+
existing.type === 'newsletter' ||
|
|
1096
|
+
existing.type === 'page'
|
|
1097
|
+
) {
|
|
1084
1098
|
const articleContent = contentParsed.data as {
|
|
1085
1099
|
bodyMarkdown?: string
|
|
1086
1100
|
bodyHtml?: string | null
|
package/src/schema/migrations.ts
CHANGED
|
@@ -22,8 +22,8 @@
|
|
|
22
22
|
export const CMS_MIGRATION_SQL = `
|
|
23
23
|
CREATE TABLE content_items (
|
|
24
24
|
id TEXT PRIMARY KEY,
|
|
25
|
-
type TEXT NOT NULL CHECK (type IN ('article','video','podcast','newsletter')),
|
|
26
|
-
status TEXT NOT NULL CHECK (status IN ('draft','scheduled','published','archived')),
|
|
25
|
+
type TEXT NOT NULL CHECK (type IN ('article','video','podcast','newsletter','page')),
|
|
26
|
+
status TEXT NOT NULL CHECK (status IN ('draft','scheduled','review','published','archived')),
|
|
27
27
|
visibility TEXT NOT NULL CHECK (visibility IN ('free','premium')),
|
|
28
28
|
slug TEXT NOT NULL UNIQUE,
|
|
29
29
|
title TEXT NOT NULL,
|
|
@@ -216,8 +216,10 @@ interface D1Like {
|
|
|
216
216
|
* the parent rebuild, then restores the children from the stash. The explicit
|
|
217
217
|
* DELETE/restore is the load-bearing, FK-action-independent fix; the prepended
|
|
218
218
|
* `PRAGMA defer_foreign_keys = ON` is defense-in-depth only (it does not reliably
|
|
219
|
-
* span the per-statement exec runner).
|
|
220
|
-
*
|
|
219
|
+
* span the per-statement exec runner). Do not add SQL `BEGIN`/`COMMIT` here:
|
|
220
|
+
* D1's JavaScript API rejects explicit transaction statements and requires its
|
|
221
|
+
* storage transaction API instead. On an empty DB the stash/delete/restore is a
|
|
222
|
+
* no-op and the temp tables are dropped, so the net table set is unchanged.
|
|
221
223
|
*/
|
|
222
224
|
export const CMS_MIGRATIONS_0002_PLUS: { id: string; sql: string }[] = [
|
|
223
225
|
{
|
|
@@ -569,6 +571,148 @@ CREATE TABLE content_insight_dismissals (
|
|
|
569
571
|
dismissed_by TEXT,
|
|
570
572
|
PRIMARY KEY (site_id, intent_key)
|
|
571
573
|
);
|
|
574
|
+
`,
|
|
575
|
+
},
|
|
576
|
+
{
|
|
577
|
+
id: '0017_content_pages',
|
|
578
|
+
sql: `
|
|
579
|
+
PRAGMA defer_foreign_keys = ON;
|
|
580
|
+
CREATE TABLE IF NOT EXISTS _cms0017_bk_content_items AS SELECT
|
|
581
|
+
id, type, status, visibility, slug, title, seo_title, description, excerpt,
|
|
582
|
+
byline, channel, primary_topic, featured, author_id, hero_image_id,
|
|
583
|
+
hero_image_alt, hero_image_caption, social_image_id, canonical_url,
|
|
584
|
+
publish_at, published_at, created_at, updated_at, publish_tz,
|
|
585
|
+
ai_locked_fields, published_revision_id, deleted_at, board_position,
|
|
586
|
+
seo_score, seo_focus_keyword, ai_og_image_id
|
|
587
|
+
FROM content_items;
|
|
588
|
+
DROP TABLE IF EXISTS content_items_new;
|
|
589
|
+
CREATE TABLE content_items_new (
|
|
590
|
+
id TEXT PRIMARY KEY,
|
|
591
|
+
type TEXT NOT NULL CHECK (type IN ('article','video','podcast','newsletter','page')),
|
|
592
|
+
status TEXT NOT NULL CHECK (status IN ('draft','scheduled','review','published','archived')),
|
|
593
|
+
visibility TEXT NOT NULL CHECK (visibility IN ('free','premium')),
|
|
594
|
+
slug TEXT NOT NULL UNIQUE,
|
|
595
|
+
title TEXT NOT NULL,
|
|
596
|
+
seo_title TEXT,
|
|
597
|
+
description TEXT,
|
|
598
|
+
excerpt TEXT,
|
|
599
|
+
byline TEXT,
|
|
600
|
+
channel TEXT,
|
|
601
|
+
primary_topic TEXT,
|
|
602
|
+
featured INTEGER NOT NULL DEFAULT 0,
|
|
603
|
+
author_id TEXT,
|
|
604
|
+
hero_image_id TEXT,
|
|
605
|
+
hero_image_alt TEXT,
|
|
606
|
+
hero_image_caption TEXT,
|
|
607
|
+
social_image_id TEXT,
|
|
608
|
+
canonical_url TEXT,
|
|
609
|
+
publish_at INTEGER,
|
|
610
|
+
published_at INTEGER,
|
|
611
|
+
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
|
612
|
+
updated_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
|
613
|
+
publish_tz TEXT NOT NULL DEFAULT 'Europe/Paris',
|
|
614
|
+
ai_locked_fields TEXT NOT NULL DEFAULT '[]',
|
|
615
|
+
published_revision_id TEXT,
|
|
616
|
+
deleted_at INTEGER,
|
|
617
|
+
board_position INTEGER,
|
|
618
|
+
seo_score INTEGER,
|
|
619
|
+
seo_focus_keyword TEXT,
|
|
620
|
+
ai_og_image_id TEXT,
|
|
621
|
+
FOREIGN KEY (author_id) REFERENCES authors(id)
|
|
622
|
+
);
|
|
623
|
+
INSERT INTO content_items_new (
|
|
624
|
+
id, type, status, visibility, slug, title, seo_title, description, excerpt,
|
|
625
|
+
byline, channel, primary_topic, featured, author_id, hero_image_id,
|
|
626
|
+
hero_image_alt, hero_image_caption, social_image_id, canonical_url,
|
|
627
|
+
publish_at, published_at, created_at, updated_at, publish_tz,
|
|
628
|
+
ai_locked_fields, published_revision_id, deleted_at, board_position,
|
|
629
|
+
seo_score, seo_focus_keyword, ai_og_image_id
|
|
630
|
+
) SELECT
|
|
631
|
+
id, type, status, visibility, slug, title, seo_title, description, excerpt,
|
|
632
|
+
byline, channel, primary_topic, featured, author_id, hero_image_id,
|
|
633
|
+
hero_image_alt, hero_image_caption, social_image_id, canonical_url,
|
|
634
|
+
publish_at, published_at, created_at, updated_at, publish_tz,
|
|
635
|
+
ai_locked_fields, published_revision_id, deleted_at, board_position,
|
|
636
|
+
seo_score, seo_focus_keyword, ai_og_image_id
|
|
637
|
+
FROM _cms0017_bk_content_items;
|
|
638
|
+
CREATE TABLE IF NOT EXISTS _cms0017_bk_article_content AS SELECT * FROM article_content;
|
|
639
|
+
CREATE TABLE IF NOT EXISTS _cms0017_bk_video_content AS SELECT * FROM video_content;
|
|
640
|
+
CREATE TABLE IF NOT EXISTS _cms0017_bk_podcast_content AS SELECT * FROM podcast_content;
|
|
641
|
+
CREATE TABLE IF NOT EXISTS _cms0017_bk_content_revisions AS SELECT * FROM content_revisions;
|
|
642
|
+
CREATE TABLE IF NOT EXISTS _cms0017_bk_content_tag_links AS SELECT * FROM content_tag_links;
|
|
643
|
+
CREATE TABLE IF NOT EXISTS _cms0017_bk_content_relations AS SELECT * FROM content_relations;
|
|
644
|
+
CREATE TABLE IF NOT EXISTS _cms0017_bk_content_contributors AS SELECT * FROM content_contributors;
|
|
645
|
+
CREATE TABLE IF NOT EXISTS _cms0017_bk_content_slug_redirects AS SELECT * FROM content_slug_redirects;
|
|
646
|
+
CREATE TABLE IF NOT EXISTS content_items (
|
|
647
|
+
id TEXT PRIMARY KEY,
|
|
648
|
+
type TEXT NOT NULL CHECK (type IN ('article','video','podcast','newsletter','page')),
|
|
649
|
+
status TEXT NOT NULL CHECK (status IN ('draft','scheduled','review','published','archived')),
|
|
650
|
+
visibility TEXT NOT NULL CHECK (visibility IN ('free','premium')),
|
|
651
|
+
slug TEXT NOT NULL UNIQUE,
|
|
652
|
+
title TEXT NOT NULL,
|
|
653
|
+
seo_title TEXT,
|
|
654
|
+
description TEXT,
|
|
655
|
+
excerpt TEXT,
|
|
656
|
+
byline TEXT,
|
|
657
|
+
channel TEXT,
|
|
658
|
+
primary_topic TEXT,
|
|
659
|
+
featured INTEGER NOT NULL DEFAULT 0,
|
|
660
|
+
author_id TEXT,
|
|
661
|
+
hero_image_id TEXT,
|
|
662
|
+
hero_image_alt TEXT,
|
|
663
|
+
hero_image_caption TEXT,
|
|
664
|
+
social_image_id TEXT,
|
|
665
|
+
canonical_url TEXT,
|
|
666
|
+
publish_at INTEGER,
|
|
667
|
+
published_at INTEGER,
|
|
668
|
+
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
|
669
|
+
updated_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
|
670
|
+
publish_tz TEXT NOT NULL DEFAULT 'Europe/Paris',
|
|
671
|
+
ai_locked_fields TEXT NOT NULL DEFAULT '[]',
|
|
672
|
+
published_revision_id TEXT,
|
|
673
|
+
deleted_at INTEGER,
|
|
674
|
+
board_position INTEGER,
|
|
675
|
+
seo_score INTEGER,
|
|
676
|
+
seo_focus_keyword TEXT,
|
|
677
|
+
ai_og_image_id TEXT,
|
|
678
|
+
FOREIGN KEY (author_id) REFERENCES authors(id)
|
|
679
|
+
);
|
|
680
|
+
DELETE FROM article_content;
|
|
681
|
+
DELETE FROM video_content;
|
|
682
|
+
DELETE FROM podcast_content;
|
|
683
|
+
DELETE FROM content_revisions;
|
|
684
|
+
DELETE FROM content_tag_links;
|
|
685
|
+
DELETE FROM content_relations;
|
|
686
|
+
DELETE FROM content_contributors;
|
|
687
|
+
DELETE FROM content_slug_redirects;
|
|
688
|
+
DROP TABLE IF EXISTS content_items;
|
|
689
|
+
ALTER TABLE content_items_new RENAME TO content_items;
|
|
690
|
+
INSERT INTO article_content SELECT * FROM _cms0017_bk_article_content;
|
|
691
|
+
INSERT INTO video_content SELECT * FROM _cms0017_bk_video_content;
|
|
692
|
+
INSERT INTO podcast_content SELECT * FROM _cms0017_bk_podcast_content;
|
|
693
|
+
INSERT INTO content_revisions SELECT * FROM _cms0017_bk_content_revisions;
|
|
694
|
+
INSERT INTO content_tag_links SELECT * FROM _cms0017_bk_content_tag_links;
|
|
695
|
+
INSERT INTO content_relations SELECT * FROM _cms0017_bk_content_relations;
|
|
696
|
+
INSERT INTO content_contributors SELECT * FROM _cms0017_bk_content_contributors;
|
|
697
|
+
INSERT INTO content_slug_redirects SELECT * FROM _cms0017_bk_content_slug_redirects;
|
|
698
|
+
DROP TABLE _cms0017_bk_article_content;
|
|
699
|
+
DROP TABLE _cms0017_bk_video_content;
|
|
700
|
+
DROP TABLE _cms0017_bk_podcast_content;
|
|
701
|
+
DROP TABLE _cms0017_bk_content_revisions;
|
|
702
|
+
DROP TABLE _cms0017_bk_content_tag_links;
|
|
703
|
+
DROP TABLE _cms0017_bk_content_relations;
|
|
704
|
+
DROP TABLE _cms0017_bk_content_contributors;
|
|
705
|
+
DROP TABLE _cms0017_bk_content_slug_redirects;
|
|
706
|
+
DROP TABLE _cms0017_bk_content_items;
|
|
707
|
+
CREATE INDEX IF NOT EXISTS idx_content_items_status ON content_items(status);
|
|
708
|
+
CREATE INDEX IF NOT EXISTS idx_content_items_type ON content_items(type);
|
|
709
|
+
CREATE INDEX IF NOT EXISTS idx_content_items_publish_at ON content_items(publish_at DESC);
|
|
710
|
+
CREATE INDEX IF NOT EXISTS idx_content_items_channel ON content_items(channel);
|
|
711
|
+
CREATE INDEX IF NOT EXISTS idx_content_items_author_id ON content_items(author_id);
|
|
712
|
+
CREATE INDEX IF NOT EXISTS idx_content_items_topic_status_type_published
|
|
713
|
+
ON content_items(primary_topic, status, type, published_at DESC);
|
|
714
|
+
CREATE INDEX IF NOT EXISTS idx_content_items_published_at ON content_items(published_at DESC);
|
|
715
|
+
CREATE INDEX IF NOT EXISTS idx_content_items_deleted_at ON content_items(deleted_at);
|
|
572
716
|
`,
|
|
573
717
|
},
|
|
574
718
|
]
|
package/src/schema/types.ts
CHANGED
|
@@ -9,8 +9,8 @@
|
|
|
9
9
|
// Nullability: a column declared NOT NULL maps to a required, non-nullable
|
|
10
10
|
// field; a nullable column maps to `T | null` (D1 returns SQL NULL as JS null).
|
|
11
11
|
|
|
12
|
-
/** content_items.type CHECK domain
|
|
13
|
-
export type ContentType = 'article' | 'video' | 'podcast' | 'newsletter'
|
|
12
|
+
/** content_items.type CHECK domain. */
|
|
13
|
+
export type ContentType = 'article' | 'video' | 'podcast' | 'newsletter' | 'page'
|
|
14
14
|
|
|
15
15
|
/** content_items.status CHECK domain. */
|
|
16
16
|
export type ContentStatus = 'draft' | 'scheduled' | 'review' | 'published' | 'archived'
|
package/src/ui/commands.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// src/ui/commands.ts — the ⌘K command set + the pure filter. "create"/"jump"
|
|
2
2
|
// are static; "doc" entries are fed in from the /admin/api/search results.
|
|
3
|
+
import type { ContentType } from '../schema/types.js'
|
|
3
4
|
import { ROUTES, type Route } from './hash-router.js'
|
|
4
5
|
|
|
5
6
|
export type Command =
|
|
@@ -8,7 +9,7 @@ export type Command =
|
|
|
8
9
|
label: string
|
|
9
10
|
icon: string
|
|
10
11
|
hint: string
|
|
11
|
-
contentType:
|
|
12
|
+
contentType: ContentType
|
|
12
13
|
}
|
|
13
14
|
| { kind: 'jump'; label: string; icon: string; hint: string; route: Route }
|
|
14
15
|
| { kind: 'doc'; label: string; icon: string; hint: string; id: string }
|
|
@@ -44,6 +45,7 @@ export const baseCommands: Command[] = [
|
|
|
44
45
|
{ kind: 'create', label: 'New article', icon: 'doc', hint: 'Create', contentType: 'article' },
|
|
45
46
|
{ kind: 'create', label: 'New video', icon: 'video', hint: 'Create', contentType: 'video' },
|
|
46
47
|
{ kind: 'create', label: 'New podcast', icon: 'mic', hint: 'Create', contentType: 'podcast' },
|
|
48
|
+
{ kind: 'create', label: 'New page', icon: 'doc', hint: 'Create', contentType: 'page' },
|
|
47
49
|
...ROUTES.map(
|
|
48
50
|
(r): Command => ({
|
|
49
51
|
kind: 'jump',
|
|
@@ -64,7 +64,7 @@ interface ShareLinksListResponse {
|
|
|
64
64
|
// SOURCE OF TRUTH: packages/cms/src/ui/screens/library-data.ts LibraryRow
|
|
65
65
|
interface ContentRow {
|
|
66
66
|
id: string
|
|
67
|
-
type: 'article' | 'video' | 'podcast' | 'newsletter'
|
|
67
|
+
type: 'article' | 'video' | 'podcast' | 'newsletter' | 'page'
|
|
68
68
|
slug: string
|
|
69
69
|
title: string
|
|
70
70
|
authorId: string | null
|
|
@@ -185,6 +185,17 @@ export function ContentPicker({ siteBase, dispatch }: ContentPickerProps) {
|
|
|
185
185
|
setResults([])
|
|
186
186
|
}
|
|
187
187
|
|
|
188
|
+
const iconForContentType = (type: ContentRow['type']): Parameters<typeof Icon>[0]['name'] => {
|
|
189
|
+
const icons: Record<ContentRow['type'], Parameters<typeof Icon>[0]['name']> = {
|
|
190
|
+
article: 'doc',
|
|
191
|
+
video: 'video',
|
|
192
|
+
podcast: 'mic',
|
|
193
|
+
newsletter: 'mail',
|
|
194
|
+
page: 'doc',
|
|
195
|
+
}
|
|
196
|
+
return icons[type]
|
|
197
|
+
}
|
|
198
|
+
|
|
188
199
|
return (
|
|
189
200
|
<div>
|
|
190
201
|
<FieldLabel>Content (pre-fill)</FieldLabel>
|
|
@@ -265,15 +276,7 @@ export function ContentPicker({ siteBase, dispatch }: ContentPickerProps) {
|
|
|
265
276
|
style={dropdownItemStyle}
|
|
266
277
|
>
|
|
267
278
|
<Icon
|
|
268
|
-
name={
|
|
269
|
-
(row.type === 'article'
|
|
270
|
-
? 'doc'
|
|
271
|
-
: row.type === 'video'
|
|
272
|
-
? 'video'
|
|
273
|
-
: row.type === 'podcast'
|
|
274
|
-
? 'mic'
|
|
275
|
-
: 'mail') as Parameters<typeof Icon>[0]['name']
|
|
276
|
-
}
|
|
279
|
+
name={iconForContentType(row.type)}
|
|
277
280
|
size={13}
|
|
278
281
|
style={{ color: 'var(--ink-muted)', flexShrink: 0 }}
|
|
279
282
|
/>
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
// src/ui/editor/ContentForm.tsx —
|
|
1
|
+
// src/ui/editor/ContentForm.tsx — content form with Tiptap RTE.
|
|
2
2
|
//
|
|
3
3
|
// Handles: title, dek (excerpt), type-specific body block:
|
|
4
|
-
// - article / newsletter → <Rte/> (Tiptap body)
|
|
4
|
+
// - article / newsletter / page → <Rte/> (Tiptap body)
|
|
5
5
|
// - video → media/video block (URL input; the actual R2 multipart upload
|
|
6
6
|
// is wired through the existing /media/multipart routes; here
|
|
7
7
|
// we store a source URL or R2 key)
|
|
@@ -44,7 +44,7 @@ import { docToMarkdown } from './serialize.js'
|
|
|
44
44
|
export interface ContentFormProps {
|
|
45
45
|
/** Existing content id (null = new unsaved doc). */
|
|
46
46
|
docId: string | null
|
|
47
|
-
/** Content type (article | video | podcast | newsletter). */
|
|
47
|
+
/** Content type (article | video | podcast | newsletter | page). */
|
|
48
48
|
contentType: ContentType
|
|
49
49
|
/** Called with the live resolved id once the doc is saved for the first time. */
|
|
50
50
|
onDocCreated?: (id: string) => void
|
|
@@ -825,7 +825,7 @@ export function ContentForm({
|
|
|
825
825
|
/>
|
|
826
826
|
</div>
|
|
827
827
|
) : (
|
|
828
|
-
/* article | newsletter */
|
|
828
|
+
/* article | newsletter | page */
|
|
829
829
|
<>
|
|
830
830
|
{contentType === 'article' && (
|
|
831
831
|
<div style={{ marginBottom: 14 }}>
|
|
@@ -855,8 +855,8 @@ export function ContentForm({
|
|
|
855
855
|
)}
|
|
856
856
|
</div>
|
|
857
857
|
|
|
858
|
-
{/* Footer: word count + read time (only for
|
|
859
|
-
{(contentType === 'article' || contentType === 'newsletter') && (
|
|
858
|
+
{/* Footer: word count + read time (only for prose content) */}
|
|
859
|
+
{(contentType === 'article' || contentType === 'newsletter' || contentType === 'page') && (
|
|
860
860
|
<div style={footer}>
|
|
861
861
|
<Icon name="edit" size={12} style={{ color: 'var(--ink-faint)' }} />
|
|
862
862
|
<span>
|
|
@@ -1216,7 +1216,7 @@ function contentResponseToFormState(
|
|
|
1216
1216
|
next.heroImageId = heroImageId
|
|
1217
1217
|
next.heroImageUrl = heroImageUrl || (heroImageId ? mediaUrlFromId(heroImageId) : null)
|
|
1218
1218
|
|
|
1219
|
-
if (contentType === 'article' || contentType === 'newsletter') {
|
|
1219
|
+
if (contentType === 'article' || contentType === 'newsletter' || contentType === 'page') {
|
|
1220
1220
|
next.body = toStringValue(content.body_markdown) || toStringValue(content.body_html)
|
|
1221
1221
|
}
|
|
1222
1222
|
|
|
@@ -28,7 +28,7 @@ export function canCreateContentDraft(
|
|
|
28
28
|
draft: ContentDraftFields,
|
|
29
29
|
): boolean {
|
|
30
30
|
if (draft.title.trim().length < 3) return false
|
|
31
|
-
if (contentType === 'article' || contentType === 'newsletter') {
|
|
31
|
+
if (contentType === 'article' || contentType === 'newsletter' || contentType === 'page') {
|
|
32
32
|
return draft.body.trim().length > 0
|
|
33
33
|
}
|
|
34
34
|
if (contentType === 'video') return draft.videoUrl.trim().length > 0
|
|
@@ -81,7 +81,7 @@ export function buildContentUpdatePayload(
|
|
|
81
81
|
payload.heroImageId = heroImageId
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
-
if (contentType === 'article' || contentType === 'newsletter') {
|
|
84
|
+
if (contentType === 'article' || contentType === 'newsletter' || contentType === 'page') {
|
|
85
85
|
const wordCount = countWords(draft.body)
|
|
86
86
|
payload.content = {
|
|
87
87
|
bodyMarkdown: draft.body,
|
|
@@ -111,7 +111,7 @@ export function DraftPage({ doc, device }: DraftPageProps) {
|
|
|
111
111
|
</main>
|
|
112
112
|
|
|
113
113
|
{/* Premium paywall overlay */}
|
|
114
|
-
{layout.paywall && <PaywallOverlay />}
|
|
114
|
+
{layout.paywall && <PaywallOverlay type={doc.type} />}
|
|
115
115
|
</div>
|
|
116
116
|
</div>
|
|
117
117
|
)
|
|
@@ -193,7 +193,7 @@ function BodyPreview({ body, type }: { body: string; type: ContentType }) {
|
|
|
193
193
|
)
|
|
194
194
|
}
|
|
195
195
|
|
|
196
|
-
// article / newsletter — render body as preformatted prose preview
|
|
196
|
+
// article / newsletter / page — render body as preformatted prose preview
|
|
197
197
|
return <div style={proseStyle}>{body}</div>
|
|
198
198
|
}
|
|
199
199
|
|
|
@@ -220,7 +220,10 @@ function EmptyBody({ type }: { type: ContentType }) {
|
|
|
220
220
|
)
|
|
221
221
|
}
|
|
222
222
|
|
|
223
|
-
function PaywallOverlay() {
|
|
223
|
+
function PaywallOverlay({ type }: { type: ContentType }) {
|
|
224
|
+
const subscribeCopy =
|
|
225
|
+
type === 'page' ? 'Subscribe to read the full page.' : 'Subscribe to read the full article.'
|
|
226
|
+
|
|
224
227
|
return (
|
|
225
228
|
<div
|
|
226
229
|
style={{
|
|
@@ -253,7 +256,7 @@ function PaywallOverlay() {
|
|
|
253
256
|
Premium content
|
|
254
257
|
</div>
|
|
255
258
|
<div style={{ fontSize: 12, color: 'var(--ink-muted)', marginBottom: 14 }}>
|
|
256
|
-
|
|
259
|
+
{subscribeCopy}
|
|
257
260
|
</div>
|
|
258
261
|
<button
|
|
259
262
|
type="button"
|
|
@@ -51,6 +51,7 @@ const CONTENT_TYPE_LABELS: Record<ContentType, string> = {
|
|
|
51
51
|
video: 'Video',
|
|
52
52
|
podcast: 'Podcast',
|
|
53
53
|
newsletter: 'Newsletter',
|
|
54
|
+
page: 'Page',
|
|
54
55
|
}
|
|
55
56
|
|
|
56
57
|
const STATUS_PILL_COLORS: Record<ContentStatus, string> = {
|
|
@@ -472,6 +473,7 @@ function contentTypeIcon(type: ContentType): import('../icons.js').IconName {
|
|
|
472
473
|
video: 'video',
|
|
473
474
|
podcast: 'mic',
|
|
474
475
|
newsletter: 'mail',
|
|
476
|
+
page: 'doc',
|
|
475
477
|
}
|
|
476
478
|
return map[type]
|
|
477
479
|
}
|
|
@@ -50,6 +50,7 @@ const TYPE_OPTIONS: Array<{ value: ContentType | ''; label: string }> = [
|
|
|
50
50
|
{ value: 'video', label: 'Video' },
|
|
51
51
|
{ value: 'podcast', label: 'Podcast' },
|
|
52
52
|
{ value: 'newsletter', label: 'Newsletter' },
|
|
53
|
+
{ value: 'page', label: 'Page' },
|
|
53
54
|
]
|
|
54
55
|
|
|
55
56
|
const CONTENT_TYPE_ICONS: Record<ContentType, string> = {
|
|
@@ -57,6 +58,7 @@ const CONTENT_TYPE_ICONS: Record<ContentType, string> = {
|
|
|
57
58
|
video: 'video',
|
|
58
59
|
podcast: 'mic',
|
|
59
60
|
newsletter: 'mail',
|
|
61
|
+
page: 'doc',
|
|
60
62
|
}
|
|
61
63
|
|
|
62
64
|
// ---------------------------------------------------------------------------
|
|
@@ -261,7 +263,7 @@ export function LibraryScreen({ openDoc }: LibraryScreenProps) {
|
|
|
261
263
|
<div>
|
|
262
264
|
<h1 className="page-title">Content</h1>
|
|
263
265
|
<div className="page-sub">
|
|
264
|
-
{lib.counts.all} items across articles, video, podcasts and
|
|
266
|
+
{lib.counts.all} items across articles, video, podcasts, newsletters and pages.
|
|
265
267
|
</div>
|
|
266
268
|
</div>
|
|
267
269
|
<div style={{ display: 'flex', gap: 8 }}>
|
|
@@ -509,7 +511,7 @@ function NewContentMenu({ openDoc }: { openDoc: LibraryScreenProps['openDoc'] })
|
|
|
509
511
|
return () => document.removeEventListener('mousedown', handler)
|
|
510
512
|
}, [open])
|
|
511
513
|
|
|
512
|
-
const types: ContentType[] = ['article', 'video', 'podcast', 'newsletter']
|
|
514
|
+
const types: ContentType[] = ['article', 'video', 'podcast', 'newsletter', 'page']
|
|
513
515
|
|
|
514
516
|
return (
|
|
515
517
|
<div ref={ref} style={{ position: 'relative' }}>
|