@acms-subir/core 0.1.0 → 0.3.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.
Files changed (237) hide show
  1. package/CHANGELOG.md +106 -0
  2. package/cli/common.mjs +35 -0
  3. package/cli/legacy.mjs +317 -0
  4. package/cli/main.mjs +66 -0
  5. package/cli/palette.mjs +4 -0
  6. package/cli/preview.mjs +50 -0
  7. package/cli/remote.mjs +55 -0
  8. package/cli/themes.mjs +127 -0
  9. package/cli.mjs +2 -315
  10. package/deploy/Caddyfile +8 -0
  11. package/deploy/Dockerfile +34 -0
  12. package/deploy/compose.https.yml +21 -0
  13. package/deploy/compose.yml +46 -0
  14. package/docs/theme-authoring.md +103 -0
  15. package/email-runner.mjs +32 -0
  16. package/integration.mjs +193 -2
  17. package/ops-check.mjs +27 -0
  18. package/package.json +35 -12
  19. package/src/actions/index.ts +744 -38
  20. package/src/components/AiCleaner.tsx +291 -0
  21. package/src/components/AiSettings.tsx +575 -0
  22. package/src/components/CommandPalette.tsx +200 -0
  23. package/src/components/ContentList.astro +246 -0
  24. package/src/components/DesignAccess.tsx +182 -0
  25. package/src/components/DesignManager.tsx +455 -47
  26. package/src/components/EmailSettings.tsx +521 -0
  27. package/src/components/EventPage.astro +178 -0
  28. package/src/components/LegalPage.astro +27 -0
  29. package/src/components/LinksManager.tsx +216 -97
  30. package/src/components/NavManager.tsx +83 -38
  31. package/src/components/ThemeScript.astro +50 -0
  32. package/src/components/ThemeSections.astro +57 -0
  33. package/src/components/ThemeSectionsEditor.tsx +513 -0
  34. package/src/components/booking/BookingFlow.astro +150 -0
  35. package/src/components/booking/HoursEditor.astro +63 -0
  36. package/src/components/builder/PageBuilder.tsx +10 -2
  37. package/src/components/editor/EventPanel.tsx +401 -0
  38. package/src/components/editor/FormatDock.tsx +81 -0
  39. package/src/components/editor/LivePane.tsx +122 -0
  40. package/src/components/editor/MobileToolbar.tsx +93 -0
  41. package/src/components/editor/PostEditor.tsx +826 -271
  42. package/src/components/editor/PreviewOverlay.tsx +242 -43
  43. package/src/components/editor/extensions/calendar.tsx +51 -7
  44. package/src/components/editor/extensions/cards.tsx +17 -1
  45. package/src/components/editor/extensions/codeBlock.tsx +1 -1
  46. package/src/components/editor/extensions/events.tsx +153 -0
  47. package/src/components/editor/extensions/infoCards.tsx +16 -1
  48. package/src/components/editor/extensions/mediaCards.tsx +36 -2
  49. package/src/components/editor/extensions/slash.tsx +77 -9
  50. package/src/components/editor/extensions/toc.tsx +89 -0
  51. package/src/components/editor/extensions/toggle.tsx +70 -0
  52. package/src/components/site/Pager.astro +24 -0
  53. package/src/components/site/PostCard.astro +64 -0
  54. package/src/components/site/TagLabel.astro +11 -0
  55. package/src/layouts/Admin.astro +289 -49
  56. package/src/layouts/Site.astro +331 -57
  57. package/src/lib/agent-api.ts +13 -0
  58. package/src/lib/agent-discovery.ts +40 -0
  59. package/src/lib/agent.ts +114 -0
  60. package/src/lib/ai-config.ts +58 -0
  61. package/src/lib/ai-schema.ts +22 -0
  62. package/src/lib/ai.ts +90 -0
  63. package/src/lib/analytics.ts +121 -0
  64. package/src/lib/api-keys.ts +65 -0
  65. package/src/lib/api.ts +52 -0
  66. package/src/lib/auth.ts +25 -5
  67. package/src/lib/backup/content.ts +36 -0
  68. package/src/lib/backup/unzip.ts +12 -1
  69. package/src/lib/backup/zip.ts +1 -1
  70. package/src/lib/booking/agent.ts +108 -0
  71. package/src/lib/booking/availability.ts +221 -0
  72. package/src/lib/booking/emails.ts +115 -0
  73. package/src/lib/booking/forms.ts +77 -0
  74. package/src/lib/booking/service.ts +277 -0
  75. package/src/lib/booking/view.ts +54 -0
  76. package/src/lib/calendar/agent-schema.ts +48 -0
  77. package/src/lib/calendar/agent.ts +136 -0
  78. package/src/lib/calendar/api.ts +81 -0
  79. package/src/lib/calendar/cards.ts +102 -0
  80. package/src/lib/calendar/config.ts +52 -0
  81. package/src/lib/calendar/data.ts +130 -0
  82. package/src/lib/calendar/events.ts +261 -0
  83. package/src/lib/calendar/feeds.ts +43 -0
  84. package/src/lib/calendar/hooks.ts +38 -0
  85. package/src/lib/calendar/http.ts +30 -0
  86. package/src/lib/calendar/ics-write.ts +120 -0
  87. package/src/lib/calendar/ics.ts +15 -5
  88. package/src/lib/calendar/import.ts +203 -0
  89. package/src/lib/calendar/render.ts +120 -37
  90. package/src/lib/calendar/rrule.ts +149 -0
  91. package/src/lib/calendar/seo.ts +48 -0
  92. package/src/lib/design-service.ts +58 -0
  93. package/src/lib/designDiff.ts +145 -0
  94. package/src/lib/designPreview.ts +112 -0
  95. package/src/lib/documents/cleanup.ts +31 -0
  96. package/src/lib/documents/extract.ts +154 -0
  97. package/src/lib/documents/vendor.d.ts +4 -0
  98. package/src/lib/drafts.ts +131 -0
  99. package/src/lib/dynamicBlocks.ts +79 -7
  100. package/src/lib/email/accounts.ts +55 -0
  101. package/src/lib/email/config.ts +86 -0
  102. package/src/lib/email/migration.ts +24 -0
  103. package/src/lib/email/providers.ts +89 -0
  104. package/src/lib/email/service.ts +78 -0
  105. package/src/lib/email/store.ts +167 -0
  106. package/src/lib/email/templates.ts +18 -0
  107. package/src/lib/email/types.ts +32 -0
  108. package/src/lib/email/webhooks.ts +87 -0
  109. package/src/lib/email.ts +15 -0
  110. package/src/lib/env.ts +1 -1
  111. package/src/lib/legal/index.ts +25 -0
  112. package/src/lib/mobile/bookings.ts +73 -0
  113. package/src/lib/mobile/calendar.ts +85 -0
  114. package/src/lib/mobile/content.ts +85 -0
  115. package/src/lib/mobile/http.ts +63 -0
  116. package/src/lib/mobile/route.ts +20 -0
  117. package/src/lib/ops.ts +8 -0
  118. package/src/lib/preview.ts +21 -1
  119. package/src/lib/repo/migrations.ts +254 -0
  120. package/src/lib/repo/sql.ts +956 -46
  121. package/src/lib/repo/types.ts +140 -3
  122. package/src/lib/scheduler.ts +25 -0
  123. package/src/lib/schema.ts +362 -0
  124. package/src/lib/search-i18n.ts +3 -0
  125. package/src/lib/socialIcons.ts +32 -0
  126. package/src/lib/tagColor.ts +11 -0
  127. package/src/lib/theme.ts +59 -40
  128. package/src/lib/themeTokens.ts +174 -0
  129. package/src/lib/umami.ts +108 -0
  130. package/src/lib/webhooks.ts +139 -0
  131. package/src/middleware.ts +36 -3
  132. package/src/pages/404.astro +23 -0
  133. package/src/pages/[...slug].astro +10 -5
  134. package/src/pages/account/[action].astro +53 -0
  135. package/src/pages/admin/ai.astro +12 -0
  136. package/src/pages/admin/c/[collection]/[id].astro +14 -4
  137. package/src/pages/admin/c/[collection]/index.astro +127 -91
  138. package/src/pages/admin/calendar/bookings.astro +400 -0
  139. package/src/pages/admin/calendar/calendars.astro +265 -0
  140. package/src/pages/admin/calendar/events/[id].astro +56 -0
  141. package/src/pages/admin/calendar/events/index.astro +408 -0
  142. package/src/pages/admin/calendar/index.ts +4 -0
  143. package/src/pages/admin/calendar/integrations.astro +221 -0
  144. package/src/pages/admin/calendar/resources/[id].astro +180 -0
  145. package/src/pages/admin/calendar/resources/index.astro +106 -0
  146. package/src/pages/admin/calendar/resources/link.astro +196 -0
  147. package/src/pages/admin/design.astro +16 -4
  148. package/src/pages/admin/email.astro +13 -0
  149. package/src/pages/admin/index.astro +237 -87
  150. package/src/pages/admin/links.astro +3 -3
  151. package/src/pages/admin/login.astro +14 -10
  152. package/src/pages/admin/navigation.astro +8 -2
  153. package/src/pages/admin/pages/[id].astro +14 -3
  154. package/src/pages/admin/pages/index.astro +41 -47
  155. package/src/pages/admin/settings.astro +475 -276
  156. package/src/pages/admin/tags.astro +4 -4
  157. package/src/pages/admin/users.astro +38 -28
  158. package/src/pages/api/agent/docs.ts +33 -0
  159. package/src/pages/api/agent/index.ts +21 -0
  160. package/src/pages/api/ai/clean.ts +13 -0
  161. package/src/pages/api/ai/draft.ts +14 -0
  162. package/src/pages/api/ai/settings.ts +53 -0
  163. package/src/pages/api/ai/test.ts +12 -0
  164. package/src/pages/api/backup.ts +8 -21
  165. package/src/pages/api/calendar/availability.ts +35 -0
  166. package/src/pages/api/calendar/bookings/[id].ts +49 -0
  167. package/src/pages/api/calendar/bookings/index.ts +73 -0
  168. package/src/pages/api/calendar/calendars.ts +35 -0
  169. package/src/pages/api/calendar/docs.ts +64 -0
  170. package/src/pages/api/calendar/events/[id].ts +63 -0
  171. package/src/pages/api/calendar/events/index.ts +112 -0
  172. package/src/pages/api/calendar/resources.ts +34 -0
  173. package/src/pages/api/design/capabilities.ts +10 -0
  174. package/src/pages/api/design/index.ts +58 -0
  175. package/src/pages/api/design/keys.ts +24 -0
  176. package/src/pages/api/design/media.ts +32 -0
  177. package/src/pages/api/email/run.ts +11 -0
  178. package/src/pages/api/email/settings.ts +61 -0
  179. package/src/pages/api/email/webhooks/[provider].ts +4 -0
  180. package/src/pages/api/mcp.ts +59 -0
  181. package/src/pages/api/media/upload.ts +2 -2
  182. package/src/pages/api/ops/status.ts +39 -0
  183. package/src/pages/api/restore.ts +27 -37
  184. package/src/pages/api/search.ts +18 -4
  185. package/src/pages/api/v1/availability.ts +7 -0
  186. package/src/pages/api/v1/bookings.ts +17 -0
  187. package/src/pages/api/v1/calendars.ts +7 -0
  188. package/src/pages/api/v1/content/[id].ts +7 -0
  189. package/src/pages/api/v1/content/index.ts +7 -0
  190. package/src/pages/api/v1/events.ts +7 -0
  191. package/src/pages/api/v1/resources.ts +7 -0
  192. package/src/pages/author/[slug].astro +43 -35
  193. package/src/pages/blog/[slug].astro +140 -66
  194. package/src/pages/blog/index.astro +95 -30
  195. package/src/pages/blog/tag/[slug].astro +45 -40
  196. package/src/pages/book/[resource]/[link].astro +70 -0
  197. package/src/pages/book/[resource]/index.astro +63 -0
  198. package/src/pages/book/[resource].ics.ts +38 -0
  199. package/src/pages/book/index.astro +47 -0
  200. package/src/pages/book/manage/[token].astro +108 -0
  201. package/src/pages/calendar/[slug].ics.ts +27 -0
  202. package/src/pages/calendar/feed.ics.ts +15 -0
  203. package/src/pages/calendar/index.astro +191 -0
  204. package/src/pages/embed/book/[resource]/[link].astro +33 -0
  205. package/src/pages/embed/calendar/[slug].astro +43 -0
  206. package/src/pages/events/[slug].astro +74 -0
  207. package/src/pages/events/[slug].ics.ts +25 -0
  208. package/src/pages/index.astro +9 -3
  209. package/src/pages/legal/privacy.astro +4 -0
  210. package/src/pages/legal/terms.astro +4 -0
  211. package/src/pages/links.astro +33 -7
  212. package/src/pages/llms.txt.ts +4 -0
  213. package/src/pages/preview/[type]/[id].astro +34 -8
  214. package/src/pages/search.astro +40 -20
  215. package/src/pages/sitemap.xml.ts +16 -1
  216. package/src/styles/admin.css +928 -105
  217. package/src/styles/cards.css +101 -60
  218. package/src/styles/editor.css +532 -102
  219. package/src/styles/email-settings.css +117 -0
  220. package/src/styles/sections.css +12 -0
  221. package/src/styles/site.css +938 -278
  222. package/src/styles/theme-editor.css +135 -0
  223. package/src/theme-sdk/contract.d.mts +619 -0
  224. package/src/theme-sdk/contract.mjs +80 -0
  225. package/src/theme-sdk/index.ts +15 -0
  226. package/src/theme-sdk/tokens.d.mts +148 -0
  227. package/src/theme-sdk/tokens.mjs +52 -0
  228. package/src/themes/editorial/templates/home.astro +4 -4
  229. package/src/themes/editorial/templates/post-minimal.astro +2 -2
  230. package/src/themes/editorial/theme.css +22 -37
  231. package/src/themes/editorial/theme.json +23 -3
  232. package/src/themes/field/theme.json +26 -9
  233. package/src/themes/ink/theme.json +30 -12
  234. package/src/themes/night/theme.css +4 -7
  235. package/src/themes/night/theme.json +27 -10
  236. package/src/virtual.d.ts +14 -0
  237. package/src/worker.ts +5 -1
@@ -1,76 +1,275 @@
1
- import { useEffect, useState } from 'react';
1
+ import { useEffect, useRef, useState } from 'react';
2
2
 
3
- // Full-screen draft preview: live iframe of the public route with
4
- // desktop / tablet / mobile widths and a copyable share link that works
5
- // logged-out (signed token).
3
+ // Full-screen draft preview. Three views of the same draft:
4
+ // Page — live iframe of the public route at desktop / tablet / phone width
5
+ // Search — how the result renders on Google
6
+ // Social — the link card X / Facebook / iMessage build from OG tags
7
+ // plus a "Before you publish" checklist and a Share popover with the signed,
8
+ // login-free link. Search/Social read the same fields the public <head> uses
9
+ // (seoTitle → title, seoDesc → excerpt → first paragraph, cover image), and
10
+ // edit them in place under the preview so the card updates as you type.
6
11
 
7
12
  const DEVICES = [
8
- { key: 'desktop', label: 'Desktop', width: '100%' },
9
- { key: 'tablet', label: 'Tablet', width: '768px' },
10
- { key: 'mobile', label: 'Mobile', width: '390px' },
13
+ { key: 'desktop', label: 'Desktop', width: '100%', d: 'M3 4h18v12H3zM8 20h8M12 16v4' },
14
+ { key: 'tablet', label: 'Tablet', width: '768px', d: 'M5 3h14v18H5zM11 17h2' },
15
+ { key: 'mobile', label: 'Phone', width: '390px', d: 'M7 2h10v20H7zM11 18h2' },
11
16
  ] as const;
17
+ type View = 'page' | 'search' | 'social';
18
+
19
+ export interface PreviewMeta {
20
+ title: string;
21
+ seoTitle: string;
22
+ seoDesc: string;
23
+ excerpt: string;
24
+ cover: string;
25
+ coverAlt: string;
26
+ path: string; // public path the doc will live at
27
+ firstParagraph: string;
28
+ links: number;
29
+ imagesMissingAlt: number;
30
+ }
12
31
 
13
32
  interface Props {
14
33
  open: boolean;
15
34
  url: string; // admin-session preview (relative)
16
- shareUrl: string; // token preview (absolute-ish), safe to send around
35
+ shareUrl: string; // token preview, safe to send around
36
+ meta: PreviewMeta;
37
+ site: { title: string; url: string };
38
+ isPost: boolean;
39
+ startView?: View;
40
+ onChange: (patch: Partial<Pick<PreviewMeta, 'seoTitle' | 'seoDesc' | 'coverAlt'>>) => void;
41
+ onPickCover: () => void;
17
42
  onClose: () => void;
18
43
  }
19
44
 
20
- export function PreviewOverlay({ open, url, shareUrl, onClose }: Props) {
45
+ // character budget bar: fills toward the limit, amber past it
46
+ function Meter({ n, max }: { n: number; max: number }) {
47
+ const state = n === 0 ? 'empty' : n > max ? 'over' : 'ok';
48
+ return (
49
+ <span className={`pe-meter ${state}`} aria-hidden="true">
50
+ <i style={{ width: `${Math.min(100, (n / max) * 100)}%` }} />
51
+ </span>
52
+ );
53
+ }
54
+
55
+ export function PreviewOverlay({ open, url, shareUrl, meta, site, isPost, startView = 'page', onChange, onPickCover, onClose }: Props) {
56
+ const [view, setView] = useState<View>('page');
21
57
  const [device, setDevice] = useState<(typeof DEVICES)[number]>(DEVICES[0]);
22
58
  const [copied, setCopied] = useState(false);
59
+ const [shareOpen, setShareOpen] = useState(false);
23
60
  const [refreshKey, setRefreshKey] = useState(0);
61
+ const titleRef = useRef<HTMLInputElement>(null);
62
+ const descRef = useRef<HTMLTextAreaElement>(null);
63
+ const altRef = useRef<HTMLInputElement>(null);
64
+ // "Fix" jumps to the view that edits the field, then focuses it
65
+ const [focusField, setFocusField] = useState<'title' | 'desc' | 'alt' | null>(null);
66
+ useEffect(() => {
67
+ if (!focusField) return;
68
+ ({ title: titleRef, desc: descRef, alt: altRef })[focusField].current?.focus();
69
+ setFocusField(null);
70
+ }, [focusField, view]);
24
71
 
25
72
  useEffect(() => {
26
73
  if (!open) return;
27
74
  setRefreshKey((k) => k + 1); // fresh content each time it opens
28
- const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
75
+ setView(startView);
76
+ // eslint-disable-next-line react-hooks/exhaustive-deps
77
+ }, [open]);
78
+
79
+ useEffect(() => {
80
+ if (!open) return;
81
+ const onKey = (e: KeyboardEvent) => {
82
+ if (e.key !== 'Escape') return;
83
+ if (document.querySelector('.pe-modal-backdrop')) return; // the image picker closes first
84
+ if (shareOpen) setShareOpen(false);
85
+ else onClose();
86
+ };
29
87
  window.addEventListener('keydown', onKey);
30
88
  return () => window.removeEventListener('keydown', onKey);
31
- }, [open, onClose]);
89
+ }, [open, onClose, shareOpen]);
32
90
 
33
91
  if (!open) return null;
34
92
 
93
+ const origin = site.url ? site.url.replace(/\/+$/, '') : location.origin;
94
+ const host = origin.replace(/^https?:\/\//, '');
95
+ const shareHref = new URL(shareUrl, location.origin).href;
96
+ const title = meta.seoTitle || meta.title || 'Untitled';
97
+ const fullTitle = site.title && !title.includes(site.title) ? `${title} | ${site.title}` : title;
98
+ const desc = meta.seoDesc || meta.excerpt || meta.firstParagraph;
99
+ const crumbs = [host, ...meta.path.split('/').filter(Boolean)].join(' › ');
100
+ const clip = (s: string, n: number) => (s.length > n ? `${s.slice(0, n - 1).trimEnd()}…` : s);
101
+
102
+ const fixIn = (v: View, field: 'title' | 'desc' | 'alt' | null) => () => { setView(v); setFocusField(field); };
103
+ const checks: { ok: boolean | 'warn'; label: string; detail: string; fix?: () => void }[] = [
104
+ {
105
+ ok: title.length > 0 && title.length <= 60 ? true : 'warn',
106
+ label: 'Search title',
107
+ detail: `${title.length} characters${title.length > 60 ? ' — Google cuts titles around 60' : ', fits'}`,
108
+ fix: title.length > 60 ? fixIn('search', 'title') : undefined,
109
+ },
110
+ {
111
+ ok: meta.seoDesc || meta.excerpt ? true : 'warn',
112
+ label: 'Meta description',
113
+ detail: meta.seoDesc || meta.excerpt ? `${desc.length} characters` : 'Missing — search engines will use the first paragraph',
114
+ fix: !(meta.seoDesc || meta.excerpt) ? fixIn('search', 'desc') : undefined,
115
+ },
116
+ ...(isPost
117
+ ? [{
118
+ ok: meta.cover ? (meta.coverAlt ? true : 'warn') : 'warn',
119
+ label: 'Feature image',
120
+ detail: !meta.cover ? 'None — social cards will be text-only' : meta.coverAlt ? 'Set, with alt text' : 'Missing alt text',
121
+ fix: !meta.cover ? () => { setView('social'); onPickCover(); } : !meta.coverAlt ? fixIn('social', 'alt') : undefined,
122
+ } as const]
123
+ : []),
124
+ {
125
+ ok: meta.imagesMissingAlt === 0 ? true : 'warn',
126
+ label: 'Image alt text',
127
+ detail: meta.imagesMissingAlt === 0 ? 'Every image is described' : `${meta.imagesMissingAlt} image${meta.imagesMissingAlt === 1 ? '' : 's'} without alt text`,
128
+ },
129
+ { ok: true, label: 'Links', detail: meta.links ? `${meta.links} link${meta.links === 1 ? '' : 's'} in the body` : 'No links' },
130
+ ];
131
+ const passed = checks.filter((c) => c.ok === true).length;
132
+
133
+ const titleLen = (meta.seoTitle || meta.title).length;
134
+ const descLen = (meta.seoDesc || meta.excerpt || meta.firstParagraph).length;
135
+ const seoInputs = (
136
+ <>
137
+ <label className="pe-seo-field">
138
+ <span className="pe-seo-label">Meta title <em className={titleLen > 60 ? 'over' : ''}>{titleLen} / 60</em></span>
139
+ <input ref={titleRef} value={meta.seoTitle} placeholder={meta.title || 'Untitled'} onChange={(e) => onChange({ seoTitle: e.target.value })} />
140
+ <Meter n={titleLen} max={60} />
141
+ <small>{meta.seoTitle ? 'Overrides the title in search and social.' : 'Empty — uses the title.'}</small>
142
+ </label>
143
+ <label className="pe-seo-field">
144
+ <span className="pe-seo-label">Meta description <em className={descLen > 160 ? 'over' : ''}>{descLen} / 160</em></span>
145
+ <textarea ref={descRef} rows={3} value={meta.seoDesc} placeholder={meta.excerpt || meta.firstParagraph || 'One or two sentences that make people click'} onChange={(e) => onChange({ seoDesc: e.target.value })} />
146
+ <Meter n={descLen} max={160} />
147
+ <small>{meta.seoDesc ? 'Overrides the excerpt in search and social.' : meta.excerpt ? 'Empty — uses the excerpt.' : 'Empty — uses the first paragraph.'}</small>
148
+ </label>
149
+ </>
150
+ );
151
+ const seoFields = <section className="pe-seo-edit">{seoInputs}</section>;
152
+
35
153
  return (
36
154
  <div className="pe-preview">
37
155
  <div className="pe-preview-bar">
38
- <button type="button" className="pe-btn ghost sm" onClick={onClose}>‹ Back to editor</button>
39
- <div className="pe-devices">
40
- {DEVICES.map((d) => (
41
- <button
42
- key={d.key}
43
- type="button"
44
- className={device.key === d.key ? 'is-active' : ''}
45
- onClick={() => setDevice(d)}
46
- >
47
- {d.label}
48
- </button>
49
- ))}
156
+ <div className="pe-preview-left">
157
+ <button type="button" className="pe-btn ghost sm" onClick={onClose}>‹ <span className="pe-hide-sm">Back to editor</span></button>
158
+ </div>
159
+ <div className="pe-preview-center">
160
+ <div className="pe-seg" role="tablist" aria-label="Preview view">
161
+ {(['page', 'search', 'social'] as View[]).map((v) => (
162
+ <button key={v} type="button" role="tab" aria-selected={view === v} onClick={() => setView(v)}>
163
+ {v[0].toUpperCase() + v.slice(1)}
164
+ </button>
165
+ ))}
166
+ </div>
167
+ {view === 'page' && (
168
+ <div className="pe-seg pe-hide-sm" role="group" aria-label="Device width">
169
+ {DEVICES.map((d) => (
170
+ <button key={d.key} type="button" aria-pressed={device.key === d.key} title={d.label} onClick={() => setDevice(d)}>
171
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d={d.d} /></svg>
172
+ </button>
173
+ ))}
174
+ </div>
175
+ )}
50
176
  </div>
51
177
  <div className="pe-preview-actions">
52
- <button
53
- type="button"
54
- className="pe-btn ghost sm"
55
- onClick={async () => {
56
- await navigator.clipboard.writeText(new URL(shareUrl, location.origin).href);
57
- setCopied(true);
58
- setTimeout(() => setCopied(false), 1600);
59
- }}
60
- >
61
- {copied ? 'Copied ✓' : 'Copy share link'}
62
- </button>
63
- <a className="pe-btn ghost sm" href={url} target="_blank" rel="noopener">Open in tab ↗</a>
178
+ <div className="pe-share-wrap">
179
+ <button type="button" className="pe-btn sm" onClick={() => setShareOpen((v) => !v)} aria-expanded={shareOpen}>
180
+ Share <span className="pe-hide-sm">draft</span>
181
+ </button>
182
+ {shareOpen && (
183
+ <div className="pe-share" role="dialog" aria-label="Share preview link">
184
+ <b>Share a preview link</b>
185
+ <p>Anyone with this link can read the draft without signing in. It always shows the latest autosave.</p>
186
+ <div className="pe-share-row">
187
+ <input readOnly value={shareHref} onFocus={(e) => e.currentTarget.select()} aria-label="Preview link" />
188
+ <button
189
+ type="button"
190
+ className="pe-btn primary sm"
191
+ onClick={async () => {
192
+ if (navigator.share && matchMedia('(pointer: coarse)').matches) {
193
+ try { await navigator.share({ title: meta.title, url: shareHref }); return; } catch { /* cancelled */ }
194
+ }
195
+ await navigator.clipboard.writeText(shareHref);
196
+ setCopied(true);
197
+ setTimeout(() => setCopied(false), 1600);
198
+ }}
199
+ >
200
+ {copied ? 'Copied ✓' : 'Copy'}
201
+ </button>
202
+ </div>
203
+ </div>
204
+ )}
205
+ </div>
206
+ <a className="pe-btn ghost sm pe-hide-sm" href={url} target="_blank" rel="noopener">Open ↗</a>
64
207
  </div>
65
208
  </div>
66
- <div className="pe-preview-stage">
67
- <iframe
68
- key={refreshKey}
69
- src={url}
70
- title="Preview"
71
- style={{ width: device.width }}
72
- className={device.key !== 'desktop' ? 'is-framed' : ''}
73
- />
209
+ <div className="pe-preview-body">
210
+ <div className={`pe-preview-stage view-${view}`}>
211
+ {view === 'page' && (
212
+ <iframe
213
+ key={refreshKey}
214
+ src={url}
215
+ title="Preview"
216
+ style={{ width: device.width }}
217
+ className={device.key !== 'desktop' ? 'is-framed' : ''}
218
+ />
219
+ )}
220
+ {view === 'search' && (
221
+ <div className="pe-seo-view">
222
+ <div className="pe-serp">
223
+ <div className="pe-serp-site"><span className="pe-serp-fav"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8"><path d="M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18zM3 12h18M12 3c2.5 2.4 3.8 5.6 3.8 9S14.5 18.6 12 21c-2.5-2.4-3.8-5.6-3.8-9S9.5 5.4 12 3z" /></svg></span><span><b>{site.title || host}</b><small>{crumbs}</small></span></div>
224
+ <div className="pe-serp-title">{clip(fullTitle, 62)}</div>
225
+ <div className="pe-serp-desc">{desc ? clip(desc, 158) : <i>No description yet.</i>}</div>
226
+ </div>
227
+ {seoFields}
228
+ </div>
229
+ )}
230
+ {view === 'social' && (
231
+ <div className="pe-seo-view">
232
+ <div className="pe-og">
233
+ {meta.cover ? <img src={meta.cover} alt={meta.coverAlt} /> : <div className="pe-og-empty">No feature image — this card shows text only</div>}
234
+ <div className="pe-og-text">
235
+ <small>{host}</small>
236
+ <b>{clip(title, 70)}</b>
237
+ {desc && <span>{clip(desc, 110)}</span>}
238
+ </div>
239
+ </div>
240
+ <section className="pe-seo-edit">
241
+ {isPost && (
242
+ <div className="pe-seo-image">
243
+ {meta.cover ? <img src={meta.cover} alt="" /> : <span className="pe-seo-image-empty" aria-hidden="true" />}
244
+ <div>
245
+ <b>Feature image</b>
246
+ <small>{meta.cover ? 'Shown at 1.91 : 1 on X, Facebook, LinkedIn and iMessage' : 'Cards with an image get far more clicks'}</small>
247
+ </div>
248
+ <button type="button" className="pe-btn sm" onClick={onPickCover}>{meta.cover ? 'Change' : 'Add image'}</button>
249
+ </div>
250
+ )}
251
+ {isPost && meta.cover && (
252
+ <label className="pe-seo-field">
253
+ <span className="pe-seo-label">Image alt text</span>
254
+ <input ref={altRef} value={meta.coverAlt} placeholder="Describe the image for screen readers" onChange={(e) => onChange({ coverAlt: e.target.value })} />
255
+ </label>
256
+ )}
257
+ <p className="pe-seo-note">Social cards use the same title and description as search.</p>
258
+ {seoInputs}
259
+ </section>
260
+ </div>
261
+ )}
262
+ </div>
263
+ <aside className="pe-checks">
264
+ <div className="pe-checks-head"><b>Before you publish</b><span>{passed} / {checks.length}</span></div>
265
+ {checks.map((c) => (
266
+ <div className="pe-check-row" key={c.label}>
267
+ <i className={c.ok === true ? 'ok' : 'warn'}>{c.ok === true ? '✓' : '!'}</i>
268
+ <div><b>{c.label}</b><small>{c.detail}</small></div>
269
+ {c.fix && <button type="button" className="pe-btn sm" onClick={c.fix}>Fix</button>}
270
+ </div>
271
+ ))}
272
+ </aside>
74
273
  </div>
75
274
  </div>
76
275
  );
@@ -83,6 +83,18 @@ export const CalendarCard = Node.create({
83
83
  });
84
84
 
85
85
  interface PreviewItem { when: string; title: string; cal: number }
86
+ interface SiteCalendar { slug: string; name: string; color: string; visibility: string }
87
+
88
+ const isSiteSource = (s: string) => s === 'acms:all' || s.startsWith('acms:cal:');
89
+
90
+ // site calendars for the picker, fetched once per editor session
91
+ let siteCalendarsPromise: Promise<SiteCalendar[]> | null = null;
92
+ function loadSiteCalendars(): Promise<SiteCalendar[]> {
93
+ siteCalendarsPromise ??= actions.calendars.list({}).then((r) =>
94
+ r.error ? [] : r.data.calendars.filter((c) => c.visibility !== 'private' && c.kind === 'native')
95
+ );
96
+ return siteCalendarsPromise;
97
+ }
86
98
 
87
99
  function CalendarView({ node, updateAttributes, deleteNode, selected }: NodeViewProps) {
88
100
  const a = node.attrs;
@@ -92,6 +104,17 @@ function CalendarView({ node, updateAttributes, deleteNode, selected }: NodeView
92
104
  const [error, setError] = useState('');
93
105
  const [adding, setAdding] = useState(false);
94
106
  const [preview, setPreview] = useState<PreviewItem[] | null>(null);
107
+ const [site, setSite] = useState<SiteCalendar[]>([]);
108
+ useEffect(() => { void loadSiteCalendars().then(setSite); }, []);
109
+ const label = (s: string) =>
110
+ s === 'acms:all' ? 'All site events' : s.startsWith('acms:cal:') ? site.find((c) => `acms:cal:${c.slug}` === s)?.name ?? s.slice(9) : calendarSrcLabel(s);
111
+ const dot = (s: string, i: number) =>
112
+ s.startsWith('acms:cal:') ? site.find((c) => `acms:cal:${c.slug}` === s)?.color ?? CAL_DOT_COLORS[i % CAL_DOT_COLORS.length] : CAL_DOT_COLORS[i % CAL_DOT_COLORS.length];
113
+ const addSite = (value: string) => void addSource(value);
114
+ const unusedSite = [
115
+ ...(sources.includes('acms:all') ? [] : [{ value: 'acms:all', name: 'All site events' }]),
116
+ ...site.filter((c) => !sources.includes(`acms:cal:${c.slug}`)).map((c) => ({ value: `acms:cal:${c.slug}`, name: c.name })),
117
+ ];
95
118
 
96
119
  const addSource = async (raw: string) => {
97
120
  const value = raw.trim();
@@ -140,6 +163,15 @@ function CalendarView({ node, updateAttributes, deleteNode, selected }: NodeView
140
163
  />
141
164
  <button type="submit" className="pe-btn primary sm" disabled={busy}>{busy ? 'Checking…' : 'Add calendar'}</button>
142
165
  {error && <span className="pe-embed-error">{error}</span>}
166
+ <span className="pe-cal-site">
167
+ <span>Or show this site’s events:</span>
168
+ <button type="button" className="pe-btn sm" disabled={busy} onClick={() => addSite('acms:all')}>All events</button>
169
+ {site.map((c) => (
170
+ <button key={c.slug} type="button" className="pe-btn sm" disabled={busy} onClick={() => addSite(`acms:cal:${c.slug}`)}>
171
+ <i style={{ background: c.color }} aria-hidden="true" />{c.name}
172
+ </button>
173
+ ))}
174
+ </span>
143
175
  </form>
144
176
  </NodeViewWrapper>
145
177
  );
@@ -162,10 +194,12 @@ function CalendarView({ node, updateAttributes, deleteNode, selected }: NodeView
162
194
  {[3, 4, 5, 6, 8, 10, 15, 20].map((n) => <option key={n} value={n}>{n} events</option>)}
163
195
  </select>
164
196
  )}
165
- <label>
166
- <input type="checkbox" checked={a.showOpenLink} onChange={(e) => updateAttributes({ showOpenLink: e.target.checked })} />
167
- Google link
168
- </label>
197
+ {sources.some((x) => !isSiteSource(x)) && (
198
+ <label>
199
+ <input type="checkbox" checked={a.showOpenLink} onChange={(e) => updateAttributes({ showOpenLink: e.target.checked })} />
200
+ Google link
201
+ </label>
202
+ )}
169
203
  </div>
170
204
  <div className="pe-calendar-config" contentEditable={false}>
171
205
  <input
@@ -184,8 +218,8 @@ function CalendarView({ node, updateAttributes, deleteNode, selected }: NodeView
184
218
  <div className="pe-cal-sources" contentEditable={false}>
185
219
  {sources.map((s, i) => (
186
220
  <span className="pe-cal-chip" key={s}>
187
- <i style={{ background: CAL_DOT_COLORS[i % CAL_DOT_COLORS.length] }} />
188
- {calendarSrcLabel(s)}
221
+ <i style={{ background: dot(s, i) }} />
222
+ {label(s)}
189
223
  <button
190
224
  type="button" title="Remove calendar"
191
225
  onClick={() => updateAttributes({ sources: sources.filter((x) => x !== s) })}
@@ -203,7 +237,17 @@ function CalendarView({ node, updateAttributes, deleteNode, selected }: NodeView
203
237
  <button type="submit" className="pe-btn sm" disabled={busy}>{busy ? '…' : 'Add'}</button>
204
238
  </form>
205
239
  ) : (
206
- sources.length < 6 && <button type="button" className="pe-btn sm" onClick={() => setAdding(true)}>+ Add calendar</button>
240
+ sources.length < 6 && (
241
+ <>
242
+ <button type="button" className="pe-btn sm" onClick={() => setAdding(true)}>+ Add feed</button>
243
+ {unusedSite.length > 0 && (
244
+ <select className="pe-cal-sitepick" value="" onChange={(e) => e.target.value && addSite(e.target.value)} aria-label="Add a site calendar">
245
+ <option value="">+ Site calendar…</option>
246
+ {unusedSite.map((o) => <option key={o.value} value={o.value}>{o.name}</option>)}
247
+ </select>
248
+ )}
249
+ </>
250
+ )
207
251
  )}
208
252
  {error && <span className="pe-embed-error">{error}</span>}
209
253
  </div>
@@ -2,6 +2,7 @@ import { Node } from '@tiptap/react';
2
2
  import { NodeViewWrapper, NodeViewContent, ReactNodeViewRenderer } from '@tiptap/react';
3
3
  import type { NodeViewProps } from '@tiptap/react';
4
4
  import { useState } from 'react';
5
+ import { cleanEventName } from '../../../lib/analytics';
5
6
 
6
7
  // Card nodes shared by the post editor. renderHTML emits the public markup
7
8
  // (`.pc-*` classes, styled in src/styles/cards.css); parseHTML round-trips it
@@ -292,6 +293,8 @@ export const ButtonCard = Node.create({
292
293
  label: { default: '' },
293
294
  href: { default: '' },
294
295
  align: { default: 'center' },
296
+ // optional analytics event name → data-umami-event on the public <a>
297
+ event: { default: '' },
295
298
  };
296
299
  },
297
300
 
@@ -306,6 +309,7 @@ export const ButtonCard = Node.create({
306
309
  label: link?.textContent ?? '',
307
310
  href: link?.getAttribute('href') ?? '',
308
311
  align: e.getAttribute('data-align') ?? 'center',
312
+ event: link?.getAttribute('data-umami-event') ?? '',
309
313
  };
310
314
  },
311
315
  },
@@ -314,10 +318,15 @@ export const ButtonCard = Node.create({
314
318
 
315
319
  renderHTML({ node }) {
316
320
  const a = node.attrs;
321
+ const event = cleanEventName(a.event ?? '');
317
322
  return [
318
323
  'div',
319
324
  { class: 'pc-buttoncard', 'data-align': a.align, style: `text-align:${a.align}` },
320
- ['a', { class: 'pc-btncard', href: a.href }, a.label || 'Button'],
325
+ [
326
+ 'a',
327
+ { class: 'pc-btncard', href: a.href, ...(event ? { 'data-umami-event': event } : {}) },
328
+ a.label || 'Button',
329
+ ],
321
330
  ];
322
331
  },
323
332
 
@@ -353,6 +362,13 @@ function ButtonCardView({ node, updateAttributes, deleteNode, selected }: NodeVi
353
362
  value={a.href}
354
363
  onChange={(e) => updateAttributes({ href: e.target.value })}
355
364
  />
365
+ <input
366
+ type="text"
367
+ placeholder="Analytics event (optional)"
368
+ title="Clicks are counted under this name in Umami (and forwarded to enabled ad pixels)"
369
+ value={a.event}
370
+ onChange={(e) => updateAttributes({ event: e.target.value })}
371
+ />
356
372
  </div>
357
373
  <div className="pc-buttoncard" style={{ textAlign: a.align as 'center' | 'left' }}>
358
374
  <span className="pc-btncard">{a.label || 'Button'}</span>
@@ -54,7 +54,7 @@ function CodeBlockView({ node, updateAttributes }: NodeViewProps) {
54
54
  ))}
55
55
  </select>
56
56
  <pre>
57
- <NodeViewContent as="code" />
57
+ <NodeViewContent<'code'> as="code" />
58
58
  </pre>
59
59
  </NodeViewWrapper>
60
60
  );
@@ -0,0 +1,153 @@
1
+ import { useEffect, useState } from 'react';
2
+ import { Node } from '@tiptap/core';
3
+ import { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react';
4
+ import type { NodeViewProps } from '@tiptap/react';
5
+ import { actions } from 'astro:actions';
6
+
7
+ // Event card: a dynamic block (data-dyn="events") rendered at request time by
8
+ // lib/calendar/cards.ts, so dates, images and "cancelled" stay current.
9
+ // Two modes: one chosen event (with its button), or the next few events from
10
+ // a calendar as image cards — the "Coming up" strip for a home page.
11
+
12
+ export const EventCard = Node.create({
13
+ name: 'eventCard',
14
+ group: 'block',
15
+ atom: true,
16
+ draggable: true,
17
+
18
+ addAttributes() {
19
+ return {
20
+ mode: { default: 'upcoming' }, // upcoming | single
21
+ eventId: { default: '' },
22
+ calendar: { default: '' }, // calendar slug; '' = all public
23
+ limit: { default: 3 },
24
+ layout: { default: 'grid' }, // grid | list
25
+ showImage: { default: true },
26
+ };
27
+ },
28
+
29
+ parseHTML() {
30
+ return [
31
+ {
32
+ tag: 'div[data-dyn="events"]',
33
+ getAttrs: (el) => {
34
+ const e = el as HTMLElement;
35
+ return {
36
+ mode: e.getAttribute('data-mode') === 'single' ? 'single' : 'upcoming',
37
+ eventId: e.getAttribute('data-event') ?? '',
38
+ calendar: e.getAttribute('data-calendar') ?? '',
39
+ limit: Number(e.getAttribute('data-limit') ?? 3),
40
+ layout: e.getAttribute('data-layout') === 'list' ? 'list' : 'grid',
41
+ showImage: e.getAttribute('data-show-image') !== '0',
42
+ };
43
+ },
44
+ },
45
+ ];
46
+ },
47
+
48
+ renderHTML({ node }) {
49
+ const a = node.attrs;
50
+ return [
51
+ 'div',
52
+ {
53
+ 'data-dyn': 'events',
54
+ 'data-mode': a.mode,
55
+ 'data-event': a.eventId,
56
+ 'data-calendar': a.calendar,
57
+ 'data-limit': String(a.limit),
58
+ 'data-layout': a.layout,
59
+ 'data-show-image': a.showImage ? '1' : '0',
60
+ },
61
+ ];
62
+ },
63
+
64
+ addNodeView() {
65
+ return ReactNodeViewRenderer(EventCardView);
66
+ },
67
+ });
68
+
69
+ interface EventOption { id: string; title: string; when: string; status: string; cover: string }
70
+ interface CalOption { slug: string; name: string; color: string }
71
+
72
+ let cache: Promise<{ events: EventOption[]; calendars: CalOption[] }> | null = null;
73
+ function loadOptions() {
74
+ cache ??= Promise.all([actions.events.list({ publishedOnly: false }), actions.calendars.list({})]).then(([ev, cal]) => ({
75
+ events: ev.error ? [] : ev.data.events,
76
+ calendars: cal.error ? [] : cal.data.calendars.filter((c) => c.kind === 'native' && c.visibility !== 'private'),
77
+ }));
78
+ return cache;
79
+ }
80
+
81
+ function EventCardView({ node, updateAttributes, deleteNode, selected }: NodeViewProps) {
82
+ const a = node.attrs;
83
+ const [opts, setOpts] = useState<{ events: EventOption[]; calendars: CalOption[] } | null>(null);
84
+ useEffect(() => { void loadOptions().then(setOpts); }, []);
85
+ const chosen = opts?.events.find((e) => e.id === a.eventId);
86
+ const count = Math.min(12, Math.max(1, Number(a.limit) || 3));
87
+ const calName = a.calendar ? opts?.calendars.find((c) => c.slug === a.calendar)?.name ?? a.calendar : 'all calendars';
88
+
89
+ return (
90
+ <NodeViewWrapper className={`pe-card pe-postlist pe-evcard ${selected ? 'is-selected' : ''}`} data-drag-handle>
91
+ <div className="pe-card-tools" contentEditable={false}>
92
+ <button type="button" title="Remove" onClick={() => deleteNode()}>✕</button>
93
+ </div>
94
+ <div className="pe-postlist-config" contentEditable={false}>
95
+ <span className="pe-postlist-badge">Events · dynamic</span>
96
+ <select value={a.mode} onChange={(e) => updateAttributes({ mode: e.target.value })}>
97
+ <option value="upcoming">Coming up</option>
98
+ <option value="single">One event</option>
99
+ </select>
100
+ {a.mode === 'single' ? (
101
+ <select value={a.eventId} onChange={(e) => updateAttributes({ eventId: e.target.value })}>
102
+ <option value="">Choose an event…</option>
103
+ {opts?.events.map((e) => (
104
+ <option key={e.id} value={e.id}>{e.title}{e.when ? ` — ${e.when}` : ''}{e.status !== 'published' ? ` (${e.status})` : ''}</option>
105
+ ))}
106
+ </select>
107
+ ) : (
108
+ <>
109
+ <select value={a.calendar} onChange={(e) => updateAttributes({ calendar: e.target.value })}>
110
+ <option value="">All calendars</option>
111
+ {opts?.calendars.map((c) => <option key={c.slug} value={c.slug}>{c.name}</option>)}
112
+ </select>
113
+ <select value={count} onChange={(e) => updateAttributes({ limit: Number(e.target.value) })}>
114
+ {[1, 2, 3, 4, 6, 8, 12].map((n) => <option key={n} value={n}>{n} event{n > 1 ? 's' : ''}</option>)}
115
+ </select>
116
+ <select value={a.layout} onChange={(e) => updateAttributes({ layout: e.target.value })}>
117
+ <option value="grid">Grid</option>
118
+ <option value="list">List</option>
119
+ </select>
120
+ </>
121
+ )}
122
+ <label><input type="checkbox" checked={a.showImage} onChange={(e) => updateAttributes({ showImage: e.target.checked })} /> Images</label>
123
+ </div>
124
+ <div className={`pe-postlist-ghost layout-${a.mode === 'single' ? 'feature' : a.layout}`} contentEditable={false}>
125
+ {a.mode === 'single' ? (
126
+ chosen ? (
127
+ <div className="pe-evcard-one">
128
+ {a.showImage && (chosen.cover ? <img src={chosen.cover} alt="" /> : <div className="g-img" />)}
129
+ <div>
130
+ <small>{chosen.when}</small>
131
+ <b>{chosen.title}</b>
132
+ {chosen.status !== 'published' && <em>Not published yet — hidden on the site until it is.</em>}
133
+ </div>
134
+ </div>
135
+ ) : (
136
+ <p className="pe-postlist-note">{opts && !opts.events.length ? 'No events yet — create one under Calendar → Events.' : 'Pick the event to feature.'}</p>
137
+ )
138
+ ) : (
139
+ <>
140
+ {Array.from({ length: Math.min(count, 4) }, (_, i) => (
141
+ <div className="pe-postlist-ghostcard" key={i}>
142
+ {a.showImage && <div className="g-img" />}
143
+ <div className="g-line w40" />
144
+ <div className="g-line w90" />
145
+ </div>
146
+ ))}
147
+ <p className="pe-postlist-note">Next {count} event{count > 1 ? 's' : ''} from {calName} — renders live on the site (see preview)</p>
148
+ </>
149
+ )}
150
+ </div>
151
+ </NodeViewWrapper>
152
+ );
153
+ }