@growth-labs/cms 0.1.0 → 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.
Files changed (68) hide show
  1. package/README.md +13 -4
  2. package/dist/engine/publisher.d.ts +14 -3
  3. package/dist/engine/publisher.d.ts.map +1 -1
  4. package/dist/engine/publisher.js +11 -5
  5. package/dist/engine/publisher.js.map +1 -1
  6. package/dist/routes/content.d.ts +1 -1
  7. package/dist/routes/content.d.ts.map +1 -1
  8. package/dist/routes/content.js +30 -23
  9. package/dist/routes/content.js.map +1 -1
  10. package/dist/schema/insights-ingest.d.ts +16 -16
  11. package/dist/schema/migrations.d.ts +5 -3
  12. package/dist/schema/migrations.d.ts.map +1 -1
  13. package/dist/schema/migrations.js +148 -4
  14. package/dist/schema/migrations.js.map +1 -1
  15. package/dist/schema/types.d.ts +2 -2
  16. package/dist/schema/types.d.ts.map +1 -1
  17. package/dist/ui/commands.d.ts +2 -1
  18. package/dist/ui/commands.d.ts.map +1 -1
  19. package/dist/ui/commands.js +1 -2
  20. package/dist/ui/commands.js.map +1 -1
  21. package/dist/ui/components/SharePickers.d.ts.map +1 -1
  22. package/dist/ui/components/SharePickers.js +11 -7
  23. package/dist/ui/components/SharePickers.js.map +1 -1
  24. package/dist/ui/editor/ContentForm.d.ts +1 -1
  25. package/dist/ui/editor/ContentForm.d.ts.map +1 -1
  26. package/dist/ui/editor/ContentForm.js +5 -5
  27. package/dist/ui/editor/ContentForm.js.map +1 -1
  28. package/dist/ui/editor/content-payload.js +2 -2
  29. package/dist/ui/editor/content-payload.js.map +1 -1
  30. package/dist/ui/preview/draft-page.js +5 -4
  31. package/dist/ui/preview/draft-page.js.map +1 -1
  32. package/dist/ui/screens/EditorScreen.d.ts.map +1 -1
  33. package/dist/ui/screens/EditorScreen.js +4 -1
  34. package/dist/ui/screens/EditorScreen.js.map +1 -1
  35. package/dist/ui/screens/LibraryScreen.d.ts.map +1 -1
  36. package/dist/ui/screens/LibraryScreen.js +6 -3
  37. package/dist/ui/screens/LibraryScreen.js.map +1 -1
  38. package/dist/ui/screens/SocialShareScreen.d.ts.map +1 -1
  39. package/dist/ui/screens/SocialShareScreen.js +4 -3
  40. package/dist/ui/screens/SocialShareScreen.js.map +1 -1
  41. package/dist/ui/screens/content-view.d.ts +2 -1
  42. package/dist/ui/screens/content-view.d.ts.map +1 -1
  43. package/dist/ui/screens/content-view.js.map +1 -1
  44. package/dist/ui/screens/public-url.d.ts.map +1 -1
  45. package/dist/ui/screens/public-url.js +2 -0
  46. package/dist/ui/screens/public-url.js.map +1 -1
  47. package/dist/ui/screens/social-share-data.d.ts +2 -2
  48. package/dist/ui/screens/social-share-data.d.ts.map +1 -1
  49. package/dist/ui/screens/social-share-data.js +3 -12
  50. package/dist/ui/screens/social-share-data.js.map +1 -1
  51. package/migrations/0001_create_cms_tables.sql +4 -4
  52. package/migrations/0017_content_pages.sql +138 -0
  53. package/package.json +1 -1
  54. package/src/engine/publisher.ts +45 -9
  55. package/src/routes/content.ts +40 -26
  56. package/src/schema/migrations.ts +148 -4
  57. package/src/schema/types.ts +2 -2
  58. package/src/ui/commands.ts +3 -1
  59. package/src/ui/components/SharePickers.tsx +13 -10
  60. package/src/ui/editor/ContentForm.tsx +7 -7
  61. package/src/ui/editor/content-payload.ts +2 -2
  62. package/src/ui/preview/draft-page.tsx +7 -4
  63. package/src/ui/screens/EditorScreen.tsx +13 -7
  64. package/src/ui/screens/LibraryScreen.tsx +6 -3
  65. package/src/ui/screens/SocialShareScreen.tsx +4 -2
  66. package/src/ui/screens/content-view.ts +3 -1
  67. package/src/ui/screens/public-url.ts +2 -0
  68. package/src/ui/screens/social-share-data.ts +4 -13
@@ -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: 'article' | 'video' | 'podcast'
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 — four-type content form with Tiptap RTE.
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 article/newsletter) */}
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
- Subscribe to read the full article.
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> = {
@@ -67,6 +68,7 @@ const STATUS_PILL_COLORS: Record<ContentStatus, string> = {
67
68
 
68
69
  export function EditorScreen({ docRef, onClose }: EditorScreenProps) {
69
70
  const contentType: ContentType = (docRef.contentType as ContentType | undefined) ?? 'article'
71
+ const siteBase = typeof window !== 'undefined' ? window.location.origin : undefined
70
72
 
71
73
  // Resolved doc id — starts as null for new docs; set by onDocCreated
72
74
  const [docId, setDocId] = useState<string | null>(docRef.id)
@@ -385,13 +387,16 @@ export function EditorScreen({ docRef, onClose }: EditorScreenProps) {
385
387
  {/* Share modal — pre-filled from the open doc's slug + type */}
386
388
  {shareOpen && (
387
389
  <ShareModal
388
- prefill={buildSharePrefill({
389
- type: previewDoc.type,
390
- slug: previewDoc.slug,
391
- authorId: previewDoc.authorId,
392
- authorName: previewDoc.authorName,
393
- authorSlug: previewDoc.authorSlug,
394
- })}
390
+ prefill={buildSharePrefill(
391
+ {
392
+ type: previewDoc.type,
393
+ slug: previewDoc.slug,
394
+ authorId: previewDoc.authorId,
395
+ authorName: previewDoc.authorName,
396
+ authorSlug: previewDoc.authorSlug,
397
+ },
398
+ siteBase,
399
+ )}
395
400
  onClose={() => setShareOpen(false)}
396
401
  />
397
402
  )}
@@ -468,6 +473,7 @@ function contentTypeIcon(type: ContentType): import('../icons.js').IconName {
468
473
  video: 'video',
469
474
  podcast: 'mic',
470
475
  newsletter: 'mail',
476
+ page: 'doc',
471
477
  }
472
478
  return map[type]
473
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
  // ---------------------------------------------------------------------------
@@ -198,6 +200,7 @@ function getVisibleRows(state: LibraryState): LibraryRow[] {
198
200
 
199
201
  export function LibraryScreen({ openDoc }: LibraryScreenProps) {
200
202
  const { state: ws } = useWorkspace()
203
+ const siteBase = typeof window !== 'undefined' ? window.location.origin : undefined
201
204
  const [lib, dispatch] = useReducer(libraryReducer, undefined, initLibrary)
202
205
  const searchRef = useRef<HTMLInputElement>(null)
203
206
 
@@ -260,7 +263,7 @@ export function LibraryScreen({ openDoc }: LibraryScreenProps) {
260
263
  <div>
261
264
  <h1 className="page-title">Content</h1>
262
265
  <div className="page-sub">
263
- {lib.counts.all} items across articles, video, podcasts and newsletters.
266
+ {lib.counts.all} items across articles, video, podcasts, newsletters and pages.
264
267
  </div>
265
268
  </div>
266
269
  <div style={{ display: 'flex', gap: 8 }}>
@@ -483,7 +486,7 @@ export function LibraryScreen({ openDoc }: LibraryScreenProps) {
483
486
  {/* Share modal — opened by the per-row Share button */}
484
487
  {lib.shareRow && (
485
488
  <ShareModal
486
- prefill={buildSharePrefill(lib.shareRow)}
489
+ prefill={buildSharePrefill(lib.shareRow, siteBase)}
487
490
  onClose={() => dispatch({ type: 'close-share' })}
488
491
  />
489
492
  )}
@@ -508,7 +511,7 @@ function NewContentMenu({ openDoc }: { openDoc: LibraryScreenProps['openDoc'] })
508
511
  return () => document.removeEventListener('mousedown', handler)
509
512
  }, [open])
510
513
 
511
- const types: ContentType[] = ['article', 'video', 'podcast', 'newsletter']
514
+ const types: ContentType[] = ['article', 'video', 'podcast', 'newsletter', 'page']
512
515
 
513
516
  return (
514
517
  <div ref={ref} style={{ position: 'relative' }}>
@@ -105,9 +105,10 @@ export function SocialShareScreen() {
105
105
  void fetchRecentLinks()
106
106
  }, [fetchRecentLinks])
107
107
 
108
+ const siteBase = typeof window !== 'undefined' ? window.location.origin : undefined
108
109
  // Derive goBase from the current origin — relative /go path until cutover.
109
110
  // At cutover this becomes 'https://go.fronts.co' via workspace/site config.
110
- const goBase = typeof window !== 'undefined' ? `${window.location.origin}/go` : '/go'
111
+ const goBase = siteBase ? `${siteBase}/go` : '/go'
111
112
 
112
113
  const fields = relevantFields(state.mode)
113
114
 
@@ -187,7 +188,7 @@ export function SocialShareScreen() {
187
188
  </div>
188
189
 
189
190
  {/* Content picker — pre-fills destination URL + slug + type from content library */}
190
- <ContentPicker dispatch={dispatch} />
191
+ <ContentPicker siteBase={siteBase} dispatch={dispatch} />
191
192
 
192
193
  {/* Destination URL — always visible; may be pre-filled by ContentPicker */}
193
194
  <div>
@@ -410,6 +411,7 @@ function PerModeFields({
410
411
  <option value="video">Video</option>
411
412
  <option value="podcast">Podcast</option>
412
413
  <option value="newsletter">Newsletter</option>
414
+ <option value="page">Page</option>
413
415
  </select>
414
416
  </div>
415
417
  </div>
@@ -2,7 +2,9 @@
2
2
  // ContentRoute owns a ContentView state; dispatching actions moves between the
3
3
  // library list (open === null) and the editor (open !== null).
4
4
 
5
- export type ContentType = 'article' | 'video' | 'podcast' | 'newsletter'
5
+ import type { ContentType } from '../../schema/types.js'
6
+
7
+ export type { ContentType } from '../../schema/types.js'
6
8
 
7
9
  export interface ContentView {
8
10
  /** null = create new; string = open existing by id */
@@ -10,6 +10,7 @@
10
10
  // video → /video/<slug> (src/pages/video/[slug].astro)
11
11
  // newsletter → /warfronts-weekly/<slug>
12
12
  // (src/pages/warfronts-weekly/[slug].astro)
13
+ // page → /<slug>
13
14
  //
14
15
  // SOURCE OF TRUTH for the path scheme: the Fronts site page structure at
15
16
  // /Users/zephyr/projects/fulcrum-labs/fronts/src/pages/
@@ -44,6 +45,7 @@ const CONTENT_PUBLIC_PATH: Record<ContentType, (slug: string) => string> = {
44
45
  // SOURCE: fronts/src/pages/warfronts-weekly/[slug].astro
45
46
  // fronts/src/lib/content.ts ~L779
46
47
  newsletter: (slug) => `/warfronts-weekly/${slug}`,
48
+ page: (slug) => `/${slug.replace(/^\/+/, '')}`,
47
49
  }
48
50
 
49
51
  // ---------------------------------------------------------------------------
@@ -358,7 +358,7 @@ export interface AuthorSocialBatchPayload {
358
358
  property?: string
359
359
  sourcePath?: string
360
360
  author: AuthorRef
361
- handle: HandleRef
361
+ handle?: HandleRef
362
362
  }
363
363
 
364
364
  /** owned-social batch request payload */
@@ -474,7 +474,7 @@ export function buildBatchPayloadFrom(state: ShareFormState): BuildPayloadResult
474
474
  ...(state.boostMode && state.boostMode !== 'none' ? { boostMode: state.boostMode } : {}),
475
475
  ...(nonEmpty(state.promoCode) ? { promoCode: state.promoCode } : {}),
476
476
  author: state.author,
477
- handle: state.handle,
477
+ ...(refHasContent(state.handle) ? { handle: state.handle } : {}),
478
478
  }
479
479
  return { ok: true, payload }
480
480
  }
@@ -701,7 +701,7 @@ export function toPlainText(output: ShareOutput): string {
701
701
  * given mode. The React form renders only these inputs; the request builders
702
702
  * read only these fields.
703
703
  *
704
- * 'author-social' → campaign, platform, author, handle, destinationUrl, advanced
704
+ * 'author-social' → campaign, platform, author, destinationUrl, advanced
705
705
  * 'owned-social' → campaign, platform, handle, (optional author), destinationUrl, advanced
706
706
  * 'youtube' → youtubeVideoId, placements, (optional handle/author), destinationUrl (NO campaign)
707
707
  * 'newsletter' → issue, (optional author/handle), destinationUrl (NO campaign/platform/boostMode)
@@ -711,16 +711,7 @@ export function relevantFields(mode: ShareMode): string[] {
711
711
 
712
712
  switch (mode) {
713
713
  case 'author-social':
714
- return [
715
- ...shared,
716
- 'campaign',
717
- 'platform',
718
- 'author',
719
- 'handle',
720
- 'placement',
721
- 'boostMode',
722
- 'promoCode',
723
- ]
714
+ return [...shared, 'campaign', 'platform', 'author', 'placement', 'boostMode', 'promoCode']
724
715
 
725
716
  case 'owned-social':
726
717
  return [