@acms-subir/core 0.2.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 (230) hide show
  1. package/CHANGELOG.md +78 -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 -369
  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 +182 -2
  17. package/ops-check.mjs +27 -0
  18. package/package.json +27 -5
  19. package/src/actions/index.ts +575 -47
  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 +418 -46
  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 +114 -57
  30. package/src/components/NavManager.tsx +42 -43
  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 +605 -223
  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/events.tsx +153 -0
  45. package/src/components/editor/extensions/slash.tsx +7 -1
  46. package/src/components/site/Pager.astro +24 -0
  47. package/src/components/site/PostCard.astro +64 -0
  48. package/src/components/site/TagLabel.astro +11 -0
  49. package/src/layouts/Admin.astro +289 -49
  50. package/src/layouts/Site.astro +324 -59
  51. package/src/lib/agent-api.ts +13 -0
  52. package/src/lib/agent-discovery.ts +40 -0
  53. package/src/lib/agent.ts +114 -0
  54. package/src/lib/ai-config.ts +58 -0
  55. package/src/lib/ai-schema.ts +22 -0
  56. package/src/lib/ai.ts +90 -0
  57. package/src/lib/api-keys.ts +65 -0
  58. package/src/lib/api.ts +52 -0
  59. package/src/lib/auth.ts +25 -5
  60. package/src/lib/backup/content.ts +36 -0
  61. package/src/lib/backup/unzip.ts +12 -1
  62. package/src/lib/backup/zip.ts +1 -1
  63. package/src/lib/booking/agent.ts +108 -0
  64. package/src/lib/booking/availability.ts +221 -0
  65. package/src/lib/booking/emails.ts +115 -0
  66. package/src/lib/booking/forms.ts +77 -0
  67. package/src/lib/booking/service.ts +277 -0
  68. package/src/lib/booking/view.ts +54 -0
  69. package/src/lib/calendar/agent-schema.ts +48 -0
  70. package/src/lib/calendar/agent.ts +136 -0
  71. package/src/lib/calendar/api.ts +81 -0
  72. package/src/lib/calendar/cards.ts +102 -0
  73. package/src/lib/calendar/config.ts +52 -0
  74. package/src/lib/calendar/data.ts +130 -0
  75. package/src/lib/calendar/events.ts +261 -0
  76. package/src/lib/calendar/feeds.ts +43 -0
  77. package/src/lib/calendar/hooks.ts +38 -0
  78. package/src/lib/calendar/http.ts +30 -0
  79. package/src/lib/calendar/ics-write.ts +120 -0
  80. package/src/lib/calendar/ics.ts +15 -5
  81. package/src/lib/calendar/import.ts +203 -0
  82. package/src/lib/calendar/render.ts +120 -37
  83. package/src/lib/calendar/rrule.ts +149 -0
  84. package/src/lib/calendar/seo.ts +48 -0
  85. package/src/lib/design-service.ts +58 -0
  86. package/src/lib/designDiff.ts +145 -0
  87. package/src/lib/designPreview.ts +110 -12
  88. package/src/lib/documents/cleanup.ts +31 -0
  89. package/src/lib/documents/extract.ts +154 -0
  90. package/src/lib/documents/vendor.d.ts +4 -0
  91. package/src/lib/drafts.ts +60 -6
  92. package/src/lib/dynamicBlocks.ts +40 -8
  93. package/src/lib/email/accounts.ts +55 -0
  94. package/src/lib/email/config.ts +86 -0
  95. package/src/lib/email/migration.ts +24 -0
  96. package/src/lib/email/providers.ts +89 -0
  97. package/src/lib/email/service.ts +78 -0
  98. package/src/lib/email/store.ts +167 -0
  99. package/src/lib/email/templates.ts +18 -0
  100. package/src/lib/email/types.ts +32 -0
  101. package/src/lib/email/webhooks.ts +87 -0
  102. package/src/lib/email.ts +15 -0
  103. package/src/lib/env.ts +1 -1
  104. package/src/lib/legal/index.ts +25 -0
  105. package/src/lib/mobile/bookings.ts +73 -0
  106. package/src/lib/mobile/calendar.ts +85 -0
  107. package/src/lib/mobile/content.ts +85 -0
  108. package/src/lib/mobile/http.ts +63 -0
  109. package/src/lib/mobile/route.ts +20 -0
  110. package/src/lib/ops.ts +8 -0
  111. package/src/lib/preview.ts +21 -1
  112. package/src/lib/repo/migrations.ts +217 -0
  113. package/src/lib/repo/sql.ts +853 -60
  114. package/src/lib/repo/types.ts +122 -5
  115. package/src/lib/scheduler.ts +25 -0
  116. package/src/lib/schema.ts +309 -0
  117. package/src/lib/search-i18n.ts +3 -0
  118. package/src/lib/socialIcons.ts +32 -0
  119. package/src/lib/tagColor.ts +11 -0
  120. package/src/lib/theme.ts +59 -40
  121. package/src/lib/themeTokens.ts +174 -0
  122. package/src/lib/umami.ts +108 -0
  123. package/src/lib/webhooks.ts +139 -0
  124. package/src/middleware.ts +36 -3
  125. package/src/pages/404.astro +23 -0
  126. package/src/pages/[...slug].astro +8 -5
  127. package/src/pages/account/[action].astro +53 -0
  128. package/src/pages/admin/ai.astro +12 -0
  129. package/src/pages/admin/c/[collection]/[id].astro +4 -1
  130. package/src/pages/admin/c/[collection]/index.astro +127 -92
  131. package/src/pages/admin/calendar/bookings.astro +400 -0
  132. package/src/pages/admin/calendar/calendars.astro +265 -0
  133. package/src/pages/admin/calendar/events/[id].astro +56 -0
  134. package/src/pages/admin/calendar/events/index.astro +408 -0
  135. package/src/pages/admin/calendar/index.ts +4 -0
  136. package/src/pages/admin/calendar/integrations.astro +221 -0
  137. package/src/pages/admin/calendar/resources/[id].astro +180 -0
  138. package/src/pages/admin/calendar/resources/index.astro +106 -0
  139. package/src/pages/admin/calendar/resources/link.astro +196 -0
  140. package/src/pages/admin/design.astro +10 -3
  141. package/src/pages/admin/email.astro +13 -0
  142. package/src/pages/admin/index.astro +232 -85
  143. package/src/pages/admin/links.astro +3 -3
  144. package/src/pages/admin/login.astro +14 -10
  145. package/src/pages/admin/navigation.astro +8 -2
  146. package/src/pages/admin/pages/[id].astro +4 -1
  147. package/src/pages/admin/pages/index.astro +41 -48
  148. package/src/pages/admin/settings.astro +418 -367
  149. package/src/pages/admin/tags.astro +4 -4
  150. package/src/pages/admin/users.astro +38 -28
  151. package/src/pages/api/agent/docs.ts +33 -0
  152. package/src/pages/api/agent/index.ts +21 -0
  153. package/src/pages/api/ai/clean.ts +13 -0
  154. package/src/pages/api/ai/draft.ts +14 -0
  155. package/src/pages/api/ai/settings.ts +53 -0
  156. package/src/pages/api/ai/test.ts +12 -0
  157. package/src/pages/api/backup.ts +8 -21
  158. package/src/pages/api/calendar/availability.ts +35 -0
  159. package/src/pages/api/calendar/bookings/[id].ts +49 -0
  160. package/src/pages/api/calendar/bookings/index.ts +73 -0
  161. package/src/pages/api/calendar/calendars.ts +35 -0
  162. package/src/pages/api/calendar/docs.ts +64 -0
  163. package/src/pages/api/calendar/events/[id].ts +63 -0
  164. package/src/pages/api/calendar/events/index.ts +112 -0
  165. package/src/pages/api/calendar/resources.ts +34 -0
  166. package/src/pages/api/design/capabilities.ts +10 -0
  167. package/src/pages/api/design/index.ts +58 -0
  168. package/src/pages/api/design/keys.ts +24 -0
  169. package/src/pages/api/design/media.ts +32 -0
  170. package/src/pages/api/email/run.ts +11 -0
  171. package/src/pages/api/email/settings.ts +61 -0
  172. package/src/pages/api/email/webhooks/[provider].ts +4 -0
  173. package/src/pages/api/mcp.ts +59 -0
  174. package/src/pages/api/media/upload.ts +2 -2
  175. package/src/pages/api/ops/status.ts +39 -0
  176. package/src/pages/api/restore.ts +27 -37
  177. package/src/pages/api/search.ts +18 -4
  178. package/src/pages/api/v1/availability.ts +7 -0
  179. package/src/pages/api/v1/bookings.ts +17 -0
  180. package/src/pages/api/v1/calendars.ts +7 -0
  181. package/src/pages/api/v1/content/[id].ts +7 -0
  182. package/src/pages/api/v1/content/index.ts +7 -0
  183. package/src/pages/api/v1/events.ts +7 -0
  184. package/src/pages/api/v1/resources.ts +7 -0
  185. package/src/pages/author/[slug].astro +43 -35
  186. package/src/pages/blog/[slug].astro +137 -66
  187. package/src/pages/blog/index.astro +95 -30
  188. package/src/pages/blog/tag/[slug].astro +45 -40
  189. package/src/pages/book/[resource]/[link].astro +70 -0
  190. package/src/pages/book/[resource]/index.astro +63 -0
  191. package/src/pages/book/[resource].ics.ts +38 -0
  192. package/src/pages/book/index.astro +47 -0
  193. package/src/pages/book/manage/[token].astro +108 -0
  194. package/src/pages/calendar/[slug].ics.ts +27 -0
  195. package/src/pages/calendar/feed.ics.ts +15 -0
  196. package/src/pages/calendar/index.astro +191 -0
  197. package/src/pages/embed/book/[resource]/[link].astro +33 -0
  198. package/src/pages/embed/calendar/[slug].astro +43 -0
  199. package/src/pages/events/[slug].astro +74 -0
  200. package/src/pages/events/[slug].ics.ts +25 -0
  201. package/src/pages/index.astro +6 -3
  202. package/src/pages/legal/privacy.astro +4 -0
  203. package/src/pages/legal/terms.astro +4 -0
  204. package/src/pages/links.astro +23 -3
  205. package/src/pages/llms.txt.ts +4 -0
  206. package/src/pages/preview/[type]/[id].astro +27 -6
  207. package/src/pages/search.astro +40 -20
  208. package/src/pages/sitemap.xml.ts +16 -1
  209. package/src/styles/admin.css +924 -105
  210. package/src/styles/cards.css +92 -74
  211. package/src/styles/editor.css +492 -114
  212. package/src/styles/email-settings.css +117 -0
  213. package/src/styles/sections.css +12 -0
  214. package/src/styles/site.css +938 -281
  215. package/src/styles/theme-editor.css +135 -0
  216. package/src/theme-sdk/contract.d.mts +619 -0
  217. package/src/theme-sdk/contract.mjs +80 -0
  218. package/src/theme-sdk/index.ts +15 -0
  219. package/src/theme-sdk/tokens.d.mts +148 -0
  220. package/src/theme-sdk/tokens.mjs +52 -0
  221. package/src/themes/editorial/templates/home.astro +4 -4
  222. package/src/themes/editorial/templates/post-minimal.astro +2 -2
  223. package/src/themes/editorial/theme.css +22 -37
  224. package/src/themes/editorial/theme.json +23 -3
  225. package/src/themes/field/theme.json +26 -9
  226. package/src/themes/ink/theme.json +30 -12
  227. package/src/themes/night/theme.css +4 -7
  228. package/src/themes/night/theme.json +27 -10
  229. package/src/virtual.d.ts +14 -0
  230. 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>
@@ -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
+ }
@@ -3,6 +3,7 @@ import type { Editor, Range } from '@tiptap/react';
3
3
  import Suggestion from '@tiptap/suggestion';
4
4
  import { forwardRef, useEffect, useImperativeHandle, useState } from 'react';
5
5
  import { CONTENT } from '../../../lib/content';
6
+ import { CALENDAR } from '../../../lib/calendar/config';
6
7
 
7
8
  // "/" command menu, Ghost-style: type / on a line to insert cards and blocks.
8
9
 
@@ -86,10 +87,15 @@ const ITEMS: SlashItem[] = [
86
87
  run: (editor, range) => editor.chain().focus().deleteRange(range).insertContent({ type: 'htmlCard' }).run(),
87
88
  },
88
89
  {
89
- title: 'Calendar', description: 'Google Calendar or ICS feed (dynamic)', keywords: 'calendar events google ics schedule month week upcoming dynamic', group: 'Cards',
90
+ title: 'Calendar', description: 'Site calendars, Google Calendar or any ICS feed (dynamic)', keywords: 'calendar events google ics schedule month week upcoming dynamic', group: 'Cards',
90
91
  icon: 'M3 5h18v16H3zM3 9h18M8 3v4M16 3v4M7 13h2M12 13h2M7 17h2M12 17h2',
91
92
  run: (editor, range) => editor.chain().focus().deleteRange(range).insertContent({ type: 'calendarCard' }).run(),
92
93
  },
94
+ ...(CALENDAR.enabled ? [{
95
+ title: 'Events', description: 'Coming-up event cards, or one event with its button (dynamic)', keywords: 'events event upcoming coming up calendar card rsvp cta dynamic', group: 'Cards' as const,
96
+ icon: 'M3 5h18v16H3zM3 9h18M8 3v4M16 3v4M7 13h4v4H7z',
97
+ run: (editor: Editor, range: Range) => editor.chain().focus().deleteRange(range).insertContent({ type: 'eventCard' }).run(),
98
+ } satisfies SlashItem] : []),
93
99
  {
94
100
  title: 'Info cards', description: 'Grid of cards: title, badge, details', keywords: 'info cards grid services hours schedule team features locations badge', group: 'Cards',
95
101
  icon: 'M3 5h18v14H3zM7 9h6M15.5 9h2M7 13h7M7 16h5',
@@ -0,0 +1,24 @@
1
+ ---
2
+ // Numbered pagination: ← Newer · 1 2 … n · Older →. `base` is the page-1 URL;
3
+ // later pages append ?page=N.
4
+ interface Props { base: string; page: number; count: number }
5
+ const { base, page, count } = Astro.props;
6
+ const href = (n: number) => (n === 1 ? base : `${base}${base.includes('?') ? '&' : '?'}page=${n}`);
7
+ // window of pages around the current one, with first/last always shown
8
+ const nums: (number | '…')[] = [];
9
+ for (let n = 1; n <= count; n++) {
10
+ if (n === 1 || n === count || Math.abs(n - page) <= 1) nums.push(n);
11
+ else if (nums[nums.length - 1] !== '…') nums.push('…');
12
+ }
13
+ ---
14
+ {count > 1 && (
15
+ <nav class="pager" aria-label="Pagination">
16
+ {page > 1 ? <a class="pager-step" href={href(page - 1)} rel="prev">← Newer</a> : <span class="pager-step is-off">← Newer</span>}
17
+ <span class="pager-nums">
18
+ {nums.map((n) => n === '…'
19
+ ? <span class="pager-gap">…</span>
20
+ : <a href={href(n)} aria-current={n === page ? 'page' : undefined}>{n}</a>)}
21
+ </span>
22
+ {page < count ? <a class="pager-step" href={href(page + 1)} rel="next">Older →</a> : <span class="pager-step is-off">Older →</span>}
23
+ </nav>
24
+ )}