@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,46 +1,70 @@
1
- import { useCallback, useEffect, useRef, useState } from 'react';
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
2
  import { useEditor, EditorContent } from '@tiptap/react';
3
3
  import { BubbleMenu, FloatingMenu } from '@tiptap/react/menus';
4
4
  import type { Range } from '@tiptap/react';
5
5
  import StarterKit from '@tiptap/starter-kit';
6
+ import Highlight from '@tiptap/extension-highlight';
6
7
  import { TableKit } from '@tiptap/extension-table';
7
8
  import { Placeholder, CharacterCount } from '@tiptap/extensions';
8
9
  import { actions } from 'astro:actions';
9
- import type { Page, Post, Status } from '../../lib/schema';
10
+ import type { CalEvent, Page, Post, Status } from '../../lib/schema';
10
11
  import { ImageCard, EmbedCard, ButtonCard, Callout, HtmlCard, detectEmbed } from './extensions/cards';
11
12
  import { FileCard, GalleryCard, BookmarkCard } from './extensions/mediaCards';
12
13
  import { CodeBlock } from './extensions/codeBlock';
13
14
  import { PostListCard } from './extensions/postList';
15
+ import { ToggleCard } from './extensions/toggle';
16
+ import { TocCard } from './extensions/toc';
14
17
  import { CalendarCard } from './extensions/calendar';
18
+ import { EventCard } from './extensions/events';
15
19
  import { InfoCards } from './extensions/infoCards';
16
- import { SlashMenu } from './extensions/slash';
20
+ import { SlashMenu, type SlashSnippet } from './extensions/slash';
17
21
  import { TableTyping } from './extensions/tableTyping';
18
22
  import { ImagePicker, type PickedImage } from './ImagePicker';
19
- import { PreviewOverlay } from './PreviewOverlay';
23
+ import { PreviewOverlay, type PreviewMeta } from './PreviewOverlay';
24
+ import { LivePane } from './LivePane';
25
+ import { MobileToolbar } from './MobileToolbar';
26
+ import { FormatDock } from './FormatDock';
20
27
  import { renderMarkdown } from '../../lib/markdown';
21
28
  import { CONTENT, collectionUrl } from '../../lib/content';
22
29
  import { I18N, localeHomeUrl } from '../../lib/i18n';
30
+ import { mergeEventDraft, mergePageDraft, mergePostDraft } from '../../lib/drafts';
31
+ import { CALENDAR, eventUrl, type EventFieldConfig } from '../../lib/calendar/config';
32
+ import { EventPanel, eventWhenLabel, type CalendarOption, type EventMeta } from './EventPanel';
23
33
 
24
34
  // The Ghost-style writing surface, shared by posts AND pages: distraction-free
25
35
  // column, cards via "/", autosave, a collapsible settings sidebar, and an
26
36
  // optional side-by-side live preview. `kind` switches the save action and
27
37
  // which settings fields exist — everything else is identical.
38
+ //
39
+ // Live (published/scheduled) documents autosave into a private pending draft;
40
+ // the live version only changes when the user hits Update (applyUpdate).
28
41
 
29
42
  type SaveState = 'saved' | 'dirty' | 'saving' | 'error';
30
43
 
31
44
  interface Props {
32
- kind?: 'post' | 'page';
45
+ kind?: 'post' | 'page' | 'event';
33
46
  post?: Post;
34
47
  page?: Page;
48
+ event?: CalEvent;
49
+ // events only: native calendars to file under, config custom fields, and
50
+ // the site zone (the default when an event has none)
51
+ calendars?: CalendarOption[];
52
+ eventFields?: EventFieldConfig[];
53
+ siteTimezone?: string;
35
54
  initialHTML: string;
36
55
  unsplashEnabled: boolean;
37
56
  previewPath: string;
38
57
  sharePath: string;
58
+ // public URL of the LIVE version — may differ from the staged slug while
59
+ // edits are pending; falls back to the editor state's URL when omitted
60
+ livePath?: string;
39
61
  // theme-provided template names for this document kind; empty = no picker
40
62
  templates?: string[];
63
+ // public identity for the Search / Social preview tabs
64
+ site?: { title: string; url: string };
41
65
  }
42
66
 
43
- interface Meta {
67
+ interface Meta extends EventMeta {
44
68
  title: string;
45
69
  slug: string;
46
70
  collection: string;
@@ -112,18 +136,41 @@ function joinBrokenTableRows(text: string): string {
112
136
  return out.join('\n');
113
137
  }
114
138
 
115
- export default function PostEditor({ kind = 'post', post, page, initialHTML, unsplashEnabled, previewPath, sharePath, templates = [] }: Props) {
116
- const doc = (kind === 'page' ? page : post)!;
139
+ const NO_EVENT: EventMeta = {
140
+ start: '', end: '', allDay: false, timezone: '', rrule: '', exdates: [], cancelled: false,
141
+ location: { name: '', address: '', url: '' }, cta: { label: '', href: '', style: 'primary' }, calendarIds: [], fields: {},
142
+ rsvp: { enabled: false, capacity: 0, autoConfirm: true, maxSeats: 4 },
143
+ };
144
+
145
+ export default function PostEditor({ kind = 'post', post, page, event, calendars = [], eventFields = [], siteTimezone = '', initialHTML, unsplashEnabled, previewPath, sharePath, livePath: livePathProp, templates = [], site = { title: '', url: '' } }: Props) {
146
+ const doc = (kind === 'page' ? page : kind === 'event' ? event : post)!;
147
+ // posts and events share the feature image, excerpt, tags and "featured"
148
+ const postLike = kind !== 'page';
117
149
  const docId = doc.id;
150
+ // pending = staged edits exist that the live site doesn't show yet
151
+ const [pending, setPending] = useState(() => Boolean(doc.draft));
152
+ const [livePath, setLivePath] = useState(livePathProp ?? '');
118
153
 
119
154
  const [meta, setMetaState] = useState<Meta>(() =>
120
155
  kind === 'page'
121
156
  ? {
157
+ ...NO_EVENT,
122
158
  title: page!.title, slug: page!.slug, collection: 'posts', tags: [], excerpt: '', cover: '', coverAlt: '',
123
159
  seoTitle: page!.seoTitle, seoDesc: page!.seoDesc, status: page!.status,
124
160
  featured: false, isHome: page!.isHome, locale: page!.locale, authorId: null, authorIds: [], template: page!.template, publishedAt: null, scheduledAt: null,
125
161
  }
162
+ : kind === 'event'
163
+ ? {
164
+ title: event!.title, slug: event!.slug, collection: 'posts', tags: event!.tags, excerpt: event!.excerpt,
165
+ cover: event!.cover, coverAlt: event!.coverAlt, seoTitle: event!.seoTitle, seoDesc: event!.seoDesc,
166
+ status: event!.status, featured: event!.featured, isHome: false, locale: '', authorId: null, authorIds: [],
167
+ template: event!.template, publishedAt: event!.publishedAt, scheduledAt: event!.scheduledAt,
168
+ start: event!.start, end: event!.end, allDay: event!.allDay, timezone: event!.timezone, rrule: event!.rrule,
169
+ exdates: event!.exdates, cancelled: event!.cancelled, location: event!.location, cta: event!.cta,
170
+ calendarIds: event!.calendarIds, fields: event!.fields, rsvp: event!.rsvp,
171
+ }
126
172
  : {
173
+ ...NO_EVENT,
127
174
  title: post!.title, slug: post!.slug, collection: post!.collection, tags: post!.tags, excerpt: post!.excerpt,
128
175
  cover: post!.cover, coverAlt: post!.coverAlt, seoTitle: post!.seoTitle, seoDesc: post!.seoDesc,
129
176
  status: post!.status, featured: post!.featured, isHome: false, locale: '', authorId: post!.authorId,
@@ -133,16 +180,28 @@ export default function PostEditor({ kind = 'post', post, page, initialHTML, uns
133
180
  }
134
181
  );
135
182
  // labels + URL base follow the post's collection (config key on the row)
136
- const col = kind === 'page' ? CONTENT.posts : (CONTENT.collections[meta.collection] ?? CONTENT.posts);
137
- const listHref = kind === 'page' ? '/admin/pages' : `/admin/c/${meta.collection}`;
183
+ const col = kind === 'event'
184
+ ? { singular: 'Event', plural: 'Events', base: CALENDAR.eventsBase, redirectFrom: [] }
185
+ : kind === 'page' ? CONTENT.posts : (CONTENT.collections[meta.collection] ?? CONTENT.posts);
186
+ const listHref = kind === 'page' ? '/admin/pages' : kind === 'event' ? '/admin/calendar/events' : `/admin/c/${meta.collection}`;
138
187
  const noun = kind === 'page' ? 'page' : col.singular.toLowerCase();
139
188
 
140
189
  const [authors, setAuthors] = useState<AuthorOption[]>([]);
141
190
  const [tagSuggestions, setTagSuggestions] = useState<string[]>([]);
142
191
  const metaRef = useRef(meta);
192
+ // Leave guard: the state the editor opened with (for "Undo" on drafts),
193
+ // whether anything was edited this visit, and where "leave" should go.
194
+ const initialMetaRef = useRef(meta);
195
+ const editedRef = useRef(false);
196
+ const leavingRef = useRef(false);
197
+ const pendingRef = useRef(pending);
198
+ pendingRef.current = pending;
199
+ const [leaveOpen, setLeaveOpen] = useState<null | 'list' | 'back'>(null);
200
+ const [leaveBusy, setLeaveBusy] = useState('');
143
201
  const [saveState, setSaveState] = useState<SaveState>('saved');
144
202
  const saveStateRef = useRef<SaveState>('saved');
145
- const [sidebarOpen, setSidebarOpen] = useState(false);
203
+ // a fresh event opens with its settings showing: the date is the point
204
+ const [sidebarOpen, setSidebarOpen] = useState(() => kind === 'event' && event!.createdAt === event!.updatedAt);
146
205
  const [publishOpen, setPublishOpen] = useState(false);
147
206
  const [previewOpen, setPreviewOpen] = useState(false);
148
207
  const [split, setSplit] = useState(() => {
@@ -154,31 +213,48 @@ export default function PostEditor({ kind = 'post', post, page, initialHTML, uns
154
213
  const [scheduleDraft, setScheduleDraft] = useState('');
155
214
  const [linkEdit, setLinkEdit] = useState(false);
156
215
  const [linkDraft, setLinkDraft] = useState('');
216
+ const [linkResults, setLinkResults] = useState<{ kind: string; title: string; url: string }[]>([]);
217
+ const [linkSel, setLinkSel] = useState(-1);
157
218
  const [shortcutsOpen, setShortcutsOpen] = useState(false);
158
219
  const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
220
+ // saved snippets for the "/" menu; a ref so the (non-React) suggestion
221
+ // plugin always reads the current list
222
+ const snippetsRef = useRef<SlashSnippet[]>([]);
159
223
 
160
224
  const setSave = (s: SaveState) => { saveStateRef.current = s; setSaveState(s); };
161
- const toggleSplit = () => {
162
- setSplit((v) => {
163
- try { localStorage.setItem('pe-split', v ? '0' : '1'); } catch { /* private mode */ }
164
- return !v;
165
- });
225
+ // server-side validation message for the last failed save (events)
226
+ const [saveError, setSaveError] = useState('');
227
+ const setSplitTo = (on: boolean) => {
228
+ setSplit(on);
229
+ try { localStorage.setItem('pe-split', on ? '1' : '0'); } catch { /* private mode */ }
166
230
  };
231
+ // excerpt line under the title: shown when it has text or was asked for
232
+ const [excerptOpen, setExcerptOpen] = useState(false);
233
+ // sidebar: reveal the date input for drafts that have no date yet
234
+ const [dateOpen, setDateOpen] = useState(false);
235
+ // which Preview tab opens (the sidebar's search card jumps to Search)
236
+ const [previewView, setPreviewView] = useState<'page' | 'search' | 'social'>('page');
237
+ // text of the block holding the caret, for the live pane to follow
238
+ const [focusText, setFocusText] = useState('');
239
+ const focusTimer = useRef<ReturnType<typeof setTimeout>>(undefined);
240
+ const splitRef = useRef(split);
241
+ splitRef.current = split;
167
242
 
168
243
  const editor = useEditor({
169
244
  extensions: [
170
245
  StarterKit.configure({
171
- heading: { levels: [2, 3] },
246
+ heading: { levels: [1, 2, 3] },
172
247
  link: { openOnClick: false },
173
248
  codeBlock: false,
174
249
  }),
175
250
  CodeBlock,
251
+ Highlight,
176
252
  Placeholder.configure({
177
- // Only the empty document gets the invitation; empty paragraphs in an
178
- // existing doc get the floating quick-insert menu instead (the two
179
- // rendered on top of each other otherwise).
253
+ // The empty doc gets the full invitation; an empty line in an existing
254
+ // doc gets a short hint next to the gutter "+" (current line only).
180
255
  placeholder: ({ editor: ed, node }) =>
181
- ed.isEmpty && node.type.name === 'paragraph' ? `Begin writing your ${noun} — type “/” for cards, paste markdown…` : '',
256
+ node.type.name !== 'paragraph' ? '' :
257
+ ed.isEmpty ? `Begin writing your ${noun} — type “/” for cards, paste markdown…` : 'Type “/” for cards',
182
258
  }),
183
259
  CharacterCount,
184
260
  TableKit.configure({ table: { resizable: false } }),
@@ -189,7 +265,10 @@ export default function PostEditor({ kind = 'post', post, page, initialHTML, uns
189
265
  Callout,
190
266
  HtmlCard,
191
267
  PostListCard,
268
+ ToggleCard,
269
+ TocCard,
192
270
  CalendarCard,
271
+ EventCard,
193
272
  InfoCards,
194
273
  FileCard,
195
274
  BookmarkCard,
@@ -200,6 +279,11 @@ export default function PostEditor({ kind = 'post', post, page, initialHTML, uns
200
279
  pickerRangeRef.current = range;
201
280
  setPicker('editor');
202
281
  },
282
+ getSnippets: () => snippetsRef.current,
283
+ deleteSnippet: (id) => {
284
+ snippetsRef.current = snippetsRef.current.filter((s) => s.id !== id);
285
+ actions.snippets.delete({ id });
286
+ },
203
287
  },
204
288
  }),
205
289
  ],
@@ -220,8 +304,8 @@ export default function PostEditor({ kind = 'post', post, page, initialHTML, uns
220
304
  return true;
221
305
  }
222
306
  }
223
- // plain-text markdown → rich content; headings clamp to the editor's
224
- // h2/h3 schema. Rich clipboards keep ProseMirror's own HTML handling,
307
+ // plain-text markdown → rich content; h4–h6 clamp to h3, the editor's
308
+ // deepest level. Rich clipboards keep ProseMirror's own HTML handling,
225
309
  // EXCEPT when the plain text carries a markdown table — copied
226
310
  // markdown source usually has an HTML flavor alongside it (code
227
311
  // editors, chat apps, this editor's own output) that would otherwise
@@ -231,7 +315,6 @@ export default function PostEditor({ kind = 'post', post, page, initialHTML, uns
231
315
  const hasMarkdownTable = /^\s*\|.+\|\s*\n\s*\|(\s*:?-+:?\s*\|)+\s*$/m.test(joined);
232
316
  if (text && looksLikeMarkdown(text) && (!html || hasMarkdownTable)) {
233
317
  const rich = renderMarkdown(joined)
234
- .replace(/<(\/?)h1\b[^>]*>/g, '<$1h2>')
235
318
  .replace(/<(\/?)h[4-6]\b[^>]*>/g, '<$1h3>');
236
319
  editorRef.current?.chain().insertContent(rich).run();
237
320
  return true;
@@ -240,6 +323,16 @@ export default function PostEditor({ kind = 'post', post, page, initialHTML, uns
240
323
  },
241
324
  },
242
325
  onUpdate: () => queueSave(),
326
+ onSelectionUpdate: ({ editor: ed }) => {
327
+ // the top-level block holding the caret; the live pane scrolls to it.
328
+ // Debounced and split-only so typing doesn't re-render the editor.
329
+ if (!splitRef.current) return;
330
+ clearTimeout(focusTimer.current);
331
+ focusTimer.current = setTimeout(() => {
332
+ const { $anchor } = ed.state.selection;
333
+ setFocusText($anchor.depth >= 1 ? $anchor.node(1).textContent : '');
334
+ }, 250);
335
+ },
243
336
  immediatelyRender: false,
244
337
  });
245
338
  const editorRef = useRef(editor);
@@ -253,7 +346,51 @@ export default function PostEditor({ kind = 'post', post, page, initialHTML, uns
253
346
  setSave('saving');
254
347
  const bodyJson = JSON.stringify(ed.getJSON());
255
348
  const bodyHtml = ed.getHTML();
256
- if (kind === 'page') {
349
+ if (kind === 'event') {
350
+ const r = await actions.events.save({
351
+ id: docId,
352
+ title: m.title,
353
+ slug: m.slug,
354
+ bodyJson,
355
+ bodyHtml,
356
+ excerpt: m.excerpt,
357
+ tags: m.tags,
358
+ cover: m.cover,
359
+ coverAlt: m.coverAlt,
360
+ seoTitle: m.seoTitle,
361
+ seoDesc: m.seoDesc,
362
+ status: m.status,
363
+ featured: m.featured,
364
+ template: m.template,
365
+ start: m.start,
366
+ end: m.end,
367
+ allDay: m.allDay,
368
+ timezone: m.timezone,
369
+ rrule: m.rrule,
370
+ exdates: m.exdates,
371
+ cancelled: m.cancelled,
372
+ location: m.location,
373
+ cta: m.cta,
374
+ calendarIds: m.calendarIds,
375
+ fields: m.fields,
376
+ rsvp: m.rsvp,
377
+ scheduledAt: m.scheduledAt,
378
+ });
379
+ if (r.error) {
380
+ setSave('error');
381
+ setSaveError(r.error.message);
382
+ return;
383
+ }
384
+ setSaveError('');
385
+ // the server normalizes slugs and time pairs; sync from the merged view
386
+ const ev = mergeEventDraft(r.data.event);
387
+ setPending(Boolean(r.data.event.draft));
388
+ setMetaState((prev) => {
389
+ const next = { ...prev, slug: ev.slug, start: ev.start, end: ev.end, publishedAt: ev.publishedAt };
390
+ metaRef.current = next;
391
+ return next;
392
+ });
393
+ } else if (kind === 'page') {
257
394
  const r = await actions.pages.save({
258
395
  id: docId,
259
396
  title: m.title,
@@ -271,8 +408,10 @@ export default function PostEditor({ kind = 'post', post, page, initialHTML, uns
271
408
  setSave('error');
272
409
  return;
273
410
  }
274
- // server normalizes the locale (default stores as '')
275
- const p = r.data.page;
411
+ // server normalizes the locale (default stores as ''); a staged save
412
+ // returns the live row + pending draft, so sync from the merged view
413
+ const p = mergePageDraft(r.data.page);
414
+ setPending(Boolean(r.data.page.draft));
276
415
  setMetaState((prev) => {
277
416
  const next = { ...prev, slug: p.slug, isHome: p.isHome, locale: p.locale };
278
417
  metaRef.current = next;
@@ -303,8 +442,10 @@ export default function PostEditor({ kind = 'post', post, page, initialHTML, uns
303
442
  setSave('error');
304
443
  return;
305
444
  }
306
- // server may have re-slugified / auto-set publish date
307
- const p = r.data.post;
445
+ // server may have re-slugified / auto-set publish date; a staged save
446
+ // returns the live row + pending draft, so sync from the merged view
447
+ const p = mergePostDraft(r.data.post);
448
+ setPending(Boolean(r.data.post.draft));
308
449
  setMetaState((prev) => {
309
450
  const next = { ...prev, slug: p.slug, publishedAt: p.publishedAt, collection: p.collection };
310
451
  metaRef.current = next;
@@ -316,6 +457,7 @@ export default function PostEditor({ kind = 'post', post, page, initialHTML, uns
316
457
  }, [docId, kind]);
317
458
 
318
459
  const queueSave = useCallback(() => {
460
+ editedRef.current = true;
319
461
  setSave('dirty');
320
462
  if (saveTimer.current) clearTimeout(saveTimer.current);
321
463
  saveTimer.current = setTimeout(() => save(), 1200);
@@ -330,14 +472,38 @@ export default function PostEditor({ kind = 'post', post, page, initialHTML, uns
330
472
  queueSave();
331
473
  }, [queueSave]);
332
474
 
333
- // Author + tag-registry options for the settings sidebar, loaded on first open.
334
- const sidebarLoaded = useRef(false);
475
+ // Saved snippets for the "/" menu, loaded once per mount.
476
+ useEffect(() => {
477
+ actions.snippets.list({}).then((r) => {
478
+ if (!r.error) snippetsRef.current = r.data.snippets;
479
+ });
480
+ }, []);
481
+
482
+ const saveSnippet = useCallback(async () => {
483
+ const ed = editorRef.current;
484
+ if (!ed || ed.state.selection.empty) return;
485
+ const name = window.prompt('Snippet name — it will appear in the “/” menu:');
486
+ if (!name?.trim()) return;
487
+ const nodes = ed.state.selection.content().content.toJSON() as unknown[] | null;
488
+ if (!nodes?.length) return;
489
+ const r = await actions.snippets.create({ name: name.trim(), contentJson: JSON.stringify(nodes) });
490
+ if (r.error) return;
491
+ snippetsRef.current = [...snippetsRef.current, r.data.snippet].sort((a, b) => a.name.localeCompare(b.name));
492
+ showToast({ text: `Snippet “${r.data.snippet.name}” saved — insert it with /` });
493
+ }, []);
494
+
495
+ // Authors load up front (the byline under the title names the primary one);
496
+ // the tag registry only matters once the settings sidebar opens.
335
497
  useEffect(() => {
336
- if (kind !== 'post' || !sidebarOpen || sidebarLoaded.current) return;
337
- sidebarLoaded.current = true;
498
+ if (kind !== 'post') return;
338
499
  actions.users.list({}).then((r) => {
339
500
  if (!r.error) setAuthors(r.data.users.map((u) => ({ id: u.id, name: u.name })));
340
501
  });
502
+ }, [kind]);
503
+ const tagsLoaded = useRef(false);
504
+ useEffect(() => {
505
+ if (!postLike || !sidebarOpen || tagsLoaded.current) return;
506
+ tagsLoaded.current = true;
341
507
  actions.tags.list({}).then((r) => {
342
508
  if (!r.error) setTagSuggestions(r.data.tags.map((t) => t.name));
343
509
  });
@@ -353,6 +519,29 @@ export default function PostEditor({ kind = 'post', post, page, initialHTML, uns
353
519
  setLinkEdit(true);
354
520
  }, []);
355
521
 
522
+ const applyLink = useCallback((raw: string) => {
523
+ const ed = editorRef.current;
524
+ if (!ed) return;
525
+ const url = raw.trim();
526
+ if (url === '' || url === 'https://') ed.chain().focus().extendMarkRange('link').unsetLink().run();
527
+ else ed.chain().focus().extendMarkRange('link').setLink({ href: url }).run();
528
+ setLinkEdit(false);
529
+ }, []);
530
+
531
+ // Internal link search: typing a title into the ⌘K box surfaces published
532
+ // posts/pages to link to; URL-ish input (or the seeded https://) skips it.
533
+ useEffect(() => {
534
+ if (!linkEdit) { setLinkResults([]); setLinkSel(-1); return; }
535
+ const q = linkDraft.trim();
536
+ if (q.length < 2 || /^(https?:\/\/|\/|#|mailto:|tel:)/i.test(q)) { setLinkResults([]); setLinkSel(-1); return; }
537
+ const t = setTimeout(() => {
538
+ actions.linkTargets.search({ query: q }).then((r) => {
539
+ if (!r.error) { setLinkResults(r.data.targets); setLinkSel(r.data.targets.length ? 0 : -1); }
540
+ });
541
+ }, 200);
542
+ return () => clearTimeout(t);
543
+ }, [linkDraft, linkEdit]);
544
+
356
545
  // Cmd+S flush, Cmd+K link, Cmd+/ shortcut sheet + unsaved-changes guard
357
546
  useEffect(() => {
358
547
  const onKey = (e: KeyboardEvent) => {
@@ -360,8 +549,11 @@ export default function PostEditor({ kind = 'post', post, page, initialHTML, uns
360
549
  if ((e.metaKey || e.ctrlKey) && e.key === 'k') { e.preventDefault(); openLinkEdit(); }
361
550
  if ((e.metaKey || e.ctrlKey) && e.key === '/') { e.preventDefault(); setShortcutsOpen((v) => !v); }
362
551
  };
552
+ // closing the tab / reloading: browsers only allow their own prompt
363
553
  const onLeave = (e: BeforeUnloadEvent) => {
364
- if (saveStateRef.current === 'dirty' || saveStateRef.current === 'saving') e.preventDefault();
554
+ if (leavingRef.current) return;
555
+ const busy = saveStateRef.current === 'dirty' || saveStateRef.current === 'saving';
556
+ if (busy || leaveReason() === 'live') e.preventDefault();
365
557
  };
366
558
  window.addEventListener('keydown', onKey);
367
559
  window.addEventListener('beforeunload', onLeave);
@@ -386,37 +578,204 @@ export default function PostEditor({ kind = 'post', post, page, initialHTML, uns
386
578
  const toastTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
387
579
 
388
580
  function publicPath(m: Meta) {
581
+ if (kind === 'event') return eventUrl(m.slug);
389
582
  return kind === 'post' ? collectionUrl(m.collection, m.slug) : m.isHome ? localeHomeUrl(m.locale) : `/${m.slug}`;
390
583
  }
391
584
 
585
+ function showToast(msg: { text: string; href?: string }) {
586
+ setToast(msg);
587
+ if (toastTimer.current) clearTimeout(toastTimer.current);
588
+ toastTimer.current = setTimeout(() => setToast(null), 4500);
589
+ }
590
+
392
591
  async function transition(patch: Partial<Meta>) {
393
592
  setPublishOpen(false);
394
593
  const next = { ...metaRef.current, ...patch };
395
594
  metaRef.current = next;
396
595
  setMetaState(next);
596
+ // a status change writes the full editor state live (any staged edits are
597
+ // folded in — the payload IS the staged content) and clears the draft
397
598
  await save(patch);
398
599
  if (saveStateRef.current === 'error') return;
600
+ if (patch.status === 'published') setLivePath(publicPath(next));
399
601
  const messages: Partial<Record<Status, { text: string; href?: string }>> = {
400
- published: { text: `${kind === 'page' ? 'Page' : (CONTENT.collections[next.collection] ?? CONTENT.posts).singular} published`, href: publicPath(next) },
602
+ published: { text: `${kind === 'page' ? 'Page' : kind === 'event' ? 'Event' : (CONTENT.collections[next.collection] ?? CONTENT.posts).singular} published`, href: publicPath(next) },
401
603
  scheduled: { text: `Scheduled for ${next.scheduledAt ? new Date(next.scheduledAt).toLocaleString() : 'later'}` },
402
604
  draft: { text: 'Back to private draft' },
403
605
  archived: { text: 'Archived — hidden from the site' },
404
606
  };
405
607
  const msg = patch.status ? messages[patch.status] : undefined;
406
- if (msg) {
407
- setToast(msg);
408
- if (toastTimer.current) clearTimeout(toastTimer.current);
409
- toastTimer.current = setTimeout(() => setToast(null), 4500);
608
+ if (msg) showToast(msg);
609
+ }
610
+
611
+ // Update button: flush the current edits into the pending draft, then ask
612
+ // the server to copy the draft onto the live version.
613
+ async function applyUpdate() {
614
+ setPublishOpen(false);
615
+ await save();
616
+ if (saveStateRef.current === 'error') return;
617
+ const r = kind === 'page'
618
+ ? await actions.pages.applyUpdate({ id: docId })
619
+ : kind === 'event'
620
+ ? await actions.events.applyUpdate({ id: docId })
621
+ : await actions.posts.applyUpdate({ id: docId });
622
+ if (r.error) {
623
+ setSave('error');
624
+ return;
625
+ }
626
+ setPending(false);
627
+ editedRef.current = false;
628
+ const href = publicPath(metaRef.current);
629
+ setLivePath(href);
630
+ setPreviewTick((t) => t + 1);
631
+ showToast({ text: 'Changes are live', href });
632
+ }
633
+
634
+ async function discardChanges() {
635
+ if (!window.confirm(`Discard the pending changes and go back to the live ${noun}? This cannot be undone.`)) return;
636
+ if (await discardPending()) {
637
+ leavingRef.current = true;
638
+ location.reload(); // rehydrate the editor from the live version
639
+ }
640
+ }
641
+
642
+ async function discardPending(): Promise<boolean> {
643
+ setPublishOpen(false);
644
+ // cancel any queued autosave so it can't re-stage the edits after the
645
+ // discard, and clear the dirty flag so the reload isn't blocked
646
+ if (saveTimer.current) { clearTimeout(saveTimer.current); saveTimer.current = null; }
647
+ setSave('saved');
648
+ const r = kind === 'page'
649
+ ? await actions.pages.discardChanges({ id: docId })
650
+ : kind === 'event'
651
+ ? await actions.events.discardChanges({ id: docId })
652
+ : await actions.posts.discardChanges({ id: docId });
653
+ if (r.error) {
654
+ setSave('error');
655
+ return false;
410
656
  }
657
+ setPending(false);
658
+ return true;
659
+ }
660
+
661
+ // ---- leave guard ----------------------------------------------------------
662
+ // 'live' — a published/scheduled doc has edits the site doesn't show yet
663
+ // 'draft' — a draft was edited during this visit (saved, but maybe by accident)
664
+ function leaveReason(): 'live' | 'draft' | null {
665
+ const st = metaRef.current.status;
666
+ const busy = saveStateRef.current === 'dirty' || saveStateRef.current === 'saving';
667
+ if ((st === 'published' || st === 'scheduled') && (pendingRef.current || busy)) return 'live';
668
+ if (st === 'draft' && editedRef.current) return 'draft';
669
+ return null;
670
+ }
671
+
672
+ function goAway(to: 'list' | 'back') {
673
+ leavingRef.current = true;
674
+ // 'back' comes from the history trap below: skip the guard entry too
675
+ if (to === 'back') history.go(-2);
676
+ else location.href = listHref;
677
+ }
678
+
679
+ function requestLeave(to: 'list' | 'back') {
680
+ if (leaveReason()) setLeaveOpen(to);
681
+ else if (saveStateRef.current === 'dirty' || saveStateRef.current === 'saving') save().then(() => goAway(to));
682
+ else goAway(to);
411
683
  }
412
684
 
685
+ async function leaveWith(action: 'keep' | 'update' | 'publish' | 'discard' | 'undo') {
686
+ const to = leaveOpen ?? 'list';
687
+ if (action === 'keep') {
688
+ setLeaveBusy('Saving…');
689
+ if (saveStateRef.current !== 'saved') await save();
690
+ } else if (action === 'update') {
691
+ setLeaveBusy('Updating…');
692
+ await applyUpdate();
693
+ } else if (action === 'publish') {
694
+ setLeaveBusy('Publishing…');
695
+ await transition({ status: 'published', scheduledAt: null });
696
+ } else if (action === 'discard') {
697
+ setLeaveBusy('Discarding…');
698
+ await discardPending();
699
+ } else {
700
+ // put the draft back the way it was when the editor opened
701
+ setLeaveBusy('Undoing…');
702
+ if (saveTimer.current) { clearTimeout(saveTimer.current); saveTimer.current = null; }
703
+ editorRef.current?.commands.setContent(initialHTML, { emitUpdate: false });
704
+ metaRef.current = initialMetaRef.current;
705
+ setMetaState(initialMetaRef.current);
706
+ await save();
707
+ }
708
+ setLeaveBusy('');
709
+ if (saveStateRef.current === 'error') return; // stay: the dialog shows the failure
710
+ goAway(to);
711
+ }
712
+
713
+ // Browser Back: park a guard entry on top of the editor. Going back pops it;
714
+ // if there's something to confirm, re-park it and ask, otherwise keep going.
715
+ useEffect(() => {
716
+ if (!history.state?.peGuard) history.pushState({ ...(history.state ?? {}), peGuard: true }, '');
717
+ const onPop = () => {
718
+ if (leavingRef.current) return;
719
+ if (leaveReason()) {
720
+ history.pushState({ ...(history.state ?? {}), peGuard: true }, '');
721
+ setLeaveOpen('back');
722
+ } else {
723
+ leavingRef.current = true;
724
+ history.back();
725
+ }
726
+ };
727
+ window.addEventListener('popstate', onPop);
728
+ return () => window.removeEventListener('popstate', onPop);
729
+ // eslint-disable-next-line react-hooks/exhaustive-deps
730
+ }, []);
731
+
732
+ useEffect(() => {
733
+ if (!leaveOpen) return;
734
+ const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape' && !leaveBusy) setLeaveOpen(null); };
735
+ window.addEventListener('keydown', onKey);
736
+ return () => window.removeEventListener('keydown', onKey);
737
+ }, [leaveOpen, leaveBusy]);
738
+
413
739
  const words = editor?.storage.characterCount?.words() ?? 0;
414
740
  const saveLabel =
415
- saveState === 'saved' ? 'Saved' :
741
+ saveState === 'saved' ? (pending ? 'Saved — not live yet' : 'Saved') :
416
742
  saveState === 'saving' ? 'Saving…' :
417
- saveState === 'dirty' ? 'Unsaved changes' : 'Save failed — retrying on next change';
743
+ saveState === 'dirty' ? 'Unsaved changes' : saveError ? `Not saved — ${saveError}` : 'Save failed — retrying on next change';
418
744
 
419
745
  const publishLabel = meta.status === 'published' ? 'Update' : 'Publish';
746
+ // publish menu (a bottom sheet on phones): outside tap or Escape closes it
747
+ useEffect(() => {
748
+ if (!publishOpen) return;
749
+ const onDown = (e: PointerEvent) => {
750
+ if (!(e.target instanceof Element && e.target.closest('.pe-publish-wrap'))) setPublishOpen(false);
751
+ };
752
+ const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setPublishOpen(false); };
753
+ document.addEventListener('pointerdown', onDown);
754
+ document.addEventListener('keydown', onKey);
755
+ return () => {
756
+ document.removeEventListener('pointerdown', onDown);
757
+ document.removeEventListener('keydown', onKey);
758
+ };
759
+ }, [publishOpen]);
760
+
761
+ // body-derived facts are measured once per open; the meta fields stay live
762
+ // so the Search / Social cards update while they're edited in the overlay
763
+ const bodyFacts = useMemo(() => {
764
+ const html = previewOpen && editor ? editor.getHTML() : '';
765
+ const dom = html ? new DOMParser().parseFromString(html, 'text/html') : null;
766
+ return {
767
+ firstParagraph: [...(dom?.querySelectorAll('p') ?? [])].map((p) => p.textContent?.trim() ?? '').find(Boolean) ?? '',
768
+ links: dom?.querySelectorAll('a[href]').length ?? 0,
769
+ imagesMissingAlt: dom ? [...dom.querySelectorAll('img')].filter((i) => !i.getAttribute('alt')?.trim()).length : 0,
770
+ };
771
+ // eslint-disable-next-line react-hooks/exhaustive-deps
772
+ }, [previewOpen]);
773
+ const previewMeta: PreviewMeta = {
774
+ title: meta.title, seoTitle: meta.seoTitle, seoDesc: meta.seoDesc, excerpt: meta.excerpt,
775
+ cover: meta.cover, coverAlt: meta.coverAlt, path: publicPath(meta), ...bodyFacts,
776
+ };
777
+ const liveHref = livePath || publicPath(meta);
778
+ const primaryAuthor = authors.find((a) => a.id === meta.authorIds[0])?.name ?? '';
420
779
 
421
780
  return (
422
781
  <div className={`pe-shell ${sidebarOpen ? 'has-sidebar' : ''} ${split ? 'has-split' : ''}`}>
@@ -429,23 +788,27 @@ export default function PostEditor({ kind = 'post', post, page, initialHTML, uns
429
788
  )}
430
789
  <header className="pe-topbar">
431
790
  <div className="pe-topbar-left">
432
- <a className="pe-btn ghost sm" href={listHref}>{kind === 'page' ? '‹ Pages' : '‹ Posts'}</a>
791
+ <a className="pe-btn ghost sm" href={listHref} onClick={(e) => { if (e.metaKey || e.ctrlKey || e.shiftKey) return; e.preventDefault(); requestLeave('list'); }} aria-label={kind === 'page' ? 'Back to pages' : `Back to ${col.plural.toLowerCase()}`}>‹<span className="pe-hide-sm">{kind === 'page' ? 'Pages' : col.plural}</span></a>
433
792
  <span className={`pe-savestate ${saveState}`}>
434
- {STATUS_LABEL[meta.status]} — {saveLabel}
793
+ <i className="pe-save-dot" aria-hidden="true" />
794
+ {STATUS_LABEL[meta.status]}{pending && <em className="pe-edited-dot"> · edited</em>}<span className="pe-hide-sm"> · {saveLabel}</span>
435
795
  </span>
436
796
  </div>
437
- <div className="pe-topbar-right">
438
- <button
439
- type="button"
440
- className={`pe-btn ghost sm ${split ? 'is-active' : ''}`}
441
- title="Side-by-side preview"
442
- onClick={toggleSplit}
443
- >
444
- <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8">
445
- <rect x="3" y="4" width="18" height="16" rx="2" /><path d="M12 4v16" />
446
- </svg>
797
+ <div className="pe-seg pe-modes" role="group" aria-label="Editor view">
798
+ <button type="button" aria-pressed={!split} title="Just the page" onClick={() => setSplitTo(false)}>
799
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M12 20h9M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4z" /></svg>
800
+ Write
447
801
  </button>
448
- <button type="button" className="pe-btn ghost sm" onClick={() => setPreviewOpen(true)}>Preview</button>
802
+ <button type="button" className="pe-mode-split" aria-pressed={split} title="Page and live site side by side" onClick={() => setSplitTo(true)}>
803
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinejoin="round"><rect x="3" y="4" width="18" height="16" rx="2" /><path d="M12 4v16" /></svg>
804
+ Split
805
+ </button>
806
+ <button type="button" aria-pressed={false} title="Desktop, phone, search and social previews" onClick={() => { setPreviewView('page'); setPreviewOpen(true); }}>
807
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M2 12s3.6-7 10-7 10 7 10 7-3.6 7-10 7S2 12 2 12zM12 9a3 3 0 1 0 0 6 3 3 0 0 0 0-6z" /></svg>
808
+ Preview
809
+ </button>
810
+ </div>
811
+ <div className="pe-topbar-right">
449
812
  <div className="pe-publish-wrap">
450
813
  <button type="button" className="pe-btn primary sm" onClick={() => setPublishOpen((v) => !v)}>
451
814
  {publishLabel} ▾
@@ -465,7 +828,7 @@ export default function PostEditor({ kind = 'post', post, page, initialHTML, uns
465
828
  >
466
829
  Publish now
467
830
  </button>
468
- {kind === 'post' && (
831
+ {postLike && (
469
832
  <div className="pe-pop-schedule">
470
833
  <span className="pe-pop-label">Or schedule for later</span>
471
834
  <div className="pe-pop-schedrow">
@@ -492,16 +855,29 @@ export default function PostEditor({ kind = 'post', post, page, initialHTML, uns
492
855
  {meta.status === 'scheduled' && (
493
856
  <>
494
857
  <div className="pe-pop-head">
495
- <b>Scheduled</b>
496
- <span>Goes live {meta.scheduledAt ? new Date(meta.scheduledAt).toLocaleString() : ''}.</span>
858
+ <b>Scheduled{pending ? ' · unpublished edits' : ''}</b>
859
+ <span>
860
+ Goes live {meta.scheduledAt ? new Date(meta.scheduledAt).toLocaleString() : ''}.
861
+ {pending && ' Edits are saved privately — press Update to include them.'}
862
+ </span>
497
863
  </div>
864
+ {pending && (
865
+ <button type="button" className="pe-btn primary pe-pop-cta" onClick={applyUpdate}>
866
+ Update scheduled version
867
+ </button>
868
+ )}
498
869
  <button
499
870
  type="button"
500
- className="pe-btn primary pe-pop-cta"
871
+ className={`pe-btn ${pending ? '' : 'primary '}pe-pop-cta`}
501
872
  onClick={() => transition({ status: 'published', scheduledAt: null })}
502
873
  >
503
874
  Publish now instead
504
875
  </button>
876
+ {pending && (
877
+ <button type="button" className="pe-pop-item" onClick={discardChanges}>
878
+ <b>Discard pending changes</b><span>Back to the scheduled version</span>
879
+ </button>
880
+ )}
505
881
  <button type="button" className="pe-pop-item" onClick={() => transition({ status: 'draft', scheduledAt: null })}>
506
882
  <b>Unschedule</b><span>Back to a private draft</span>
507
883
  </button>
@@ -510,12 +886,26 @@ export default function PostEditor({ kind = 'post', post, page, initialHTML, uns
510
886
  {meta.status === 'published' && (
511
887
  <>
512
888
  <div className="pe-pop-head">
513
- <b>Published</b>
514
- <span>Live on the site — edits save and go live automatically.</span>
889
+ <b>{pending ? 'Published · unpublished edits' : 'Published'}</b>
890
+ <span>
891
+ {pending
892
+ ? `Changes are saved privately — the live ${noun} hasn't changed yet.`
893
+ : 'Live on the site — edits stay private until you press Update.'}
894
+ </span>
515
895
  </div>
516
- <a className="pe-btn pe-pop-cta" href={publicPath(meta)} target="_blank" rel="noopener">
517
- View on site ↗
896
+ {pending && (
897
+ <button type="button" className="pe-btn primary pe-pop-cta" onClick={applyUpdate}>
898
+ Update — push changes live
899
+ </button>
900
+ )}
901
+ <a className="pe-btn pe-pop-cta" href={liveHref} target="_blank" rel="noopener">
902
+ {pending ? 'View live version ↗' : 'View on site ↗'}
518
903
  </a>
904
+ {pending && (
905
+ <button type="button" className="pe-pop-item" onClick={discardChanges}>
906
+ <b>Discard pending changes</b><span>Back to the live version</span>
907
+ </button>
908
+ )}
519
909
  <div className="pe-pop-sep" />
520
910
  <button type="button" className="pe-pop-item" onClick={() => transition({ status: 'draft' })}>
521
911
  <b>Unpublish</b><span>Revert to a private draft</span>
@@ -548,21 +938,23 @@ export default function PostEditor({ kind = 'post', post, page, initialHTML, uns
548
938
  </div>
549
939
  <button
550
940
  type="button"
551
- className={`pe-btn ghost sm pe-sidebar-toggle ${sidebarOpen ? 'is-active' : ''}`}
941
+ className={`pe-btn ghost sm pe-icon-btn pe-sidebar-toggle ${sidebarOpen ? 'is-active' : ''}`}
552
942
  title={kind === 'page' ? 'Page settings' : `${col.singular} settings`}
943
+ aria-label={kind === 'page' ? 'Page settings' : `${col.singular} settings`}
553
944
  onClick={() => setSidebarOpen((v) => !v)}
554
945
  >
555
- <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8">
556
- <rect x="3" y="4" width="18" height="16" rx="2" /><path d="M15 4v16" />
946
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
947
+ <path d="M4 6h10M18 6h2M4 12h4M12 12h8M4 18h12M20 18h0" /><circle cx="16" cy="6" r="2" /><circle cx="10" cy="12" r="2" /><circle cx="18" cy="18" r="2" />
557
948
  </svg>
558
949
  </button>
559
950
  </div>
560
951
  </header>
561
952
 
562
953
  <div className="pe-body">
954
+ <section className="pe-editor-pane">
563
955
  <main className="pe-main">
564
956
  <div className="pe-column">
565
- {kind === 'post' && (meta.cover ? (
957
+ {postLike && (meta.cover ? (
566
958
  <figure className="pe-feature">
567
959
  <img src={meta.cover} alt={meta.coverAlt} />
568
960
  <div className="pe-feature-tools">
@@ -571,21 +963,32 @@ export default function PostEditor({ kind = 'post', post, page, initialHTML, uns
571
963
  </div>
572
964
  <figcaption className="pe-feature-label">Feature image</figcaption>
573
965
  </figure>
574
- ) : (
575
- <button type="button" className="pe-add-feature" onClick={() => setPicker('feature')}>
576
- <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6">
577
- <rect x="3" y="5" width="18" height="14" rx="2" />
578
- <circle cx="8.5" cy="10" r="1.5" />
579
- <path d="M21 15.5 16 11l-6.5 6.5M3 17l4-4 3 3" />
580
- </svg>
581
- <span>Add feature image</span>
582
- <em>Shown at the top of the {noun} and in link previews</em>
583
- </button>
584
- ))}
966
+ ) : null)}
967
+
968
+ {postLike && (!meta.cover || (!meta.excerpt && !excerptOpen)) && (
969
+ <div className="pe-addons">
970
+ {!meta.cover && (
971
+ <button type="button" title={`Shown at the top of the ${noun} and in link previews`} onClick={() => setPicker('feature')}>
972
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="5" width="18" height="14" rx="2" /><circle cx="8.5" cy="10" r="1.5" /><path d="M21 15.5 16 11l-6.5 6.5" /></svg>
973
+ Feature image
974
+ </button>
975
+ )}
976
+ {!meta.excerpt && !excerptOpen && (
977
+ <button type="button" title="A short teaser under the title, on post cards and in link previews" onClick={() => setExcerptOpen(true)}>
978
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round"><path d="M4 7h16M4 12h10M4 17h7" /></svg>
979
+ Excerpt
980
+ </button>
981
+ )}
982
+ </div>
983
+ )}
585
984
 
586
985
  <textarea
587
986
  className="pe-title"
588
987
  rows={1}
988
+ ref={(el) => {
989
+ // grow to fit on mount and on narrow screens, not just while typing
990
+ if (el) { el.style.height = 'auto'; el.style.height = `${el.scrollHeight}px`; }
991
+ }}
589
992
  placeholder={kind === 'page' ? 'Page title' : `${col.singular} title`}
590
993
  value={meta.title}
591
994
  onChange={(e) => {
@@ -598,16 +1001,82 @@ export default function PostEditor({ kind = 'post', post, page, initialHTML, uns
598
1001
  }}
599
1002
  />
600
1003
 
1004
+ {postLike && (meta.excerpt || excerptOpen) && (
1005
+ <textarea
1006
+ className="pe-excerpt"
1007
+ rows={1}
1008
+ maxLength={300}
1009
+ autoFocus={excerptOpen && !meta.excerpt}
1010
+ ref={(el) => {
1011
+ if (el) { el.style.height = 'auto'; el.style.height = `${el.scrollHeight}px`; }
1012
+ }}
1013
+ placeholder="Add a short excerpt…"
1014
+ value={meta.excerpt}
1015
+ onChange={(e) => {
1016
+ e.target.style.height = 'auto';
1017
+ e.target.style.height = `${e.target.scrollHeight}px`;
1018
+ setMeta({ excerpt: e.target.value });
1019
+ }}
1020
+ onBlur={() => { if (!meta.excerpt) setExcerptOpen(false); }}
1021
+ onKeyDown={(e) => {
1022
+ if (e.key === 'Enter') { e.preventDefault(); editor?.commands.focus('start'); }
1023
+ }}
1024
+ />
1025
+ )}
1026
+
1027
+ {kind === 'event' && (
1028
+ <div className="pe-byline pe-ev-byline">
1029
+ <button type="button" className={`pe-ev-when ${meta.cancelled ? 'is-cancelled' : ''}`} title="Edit date & time" onClick={() => setSidebarOpen(true)}>
1030
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><rect x="3" y="4.5" width="18" height="16" rx="2" /><path d="M3 9.5h18M8 2.5v4M16 2.5v4" /></svg>
1031
+ {meta.cancelled && <b>Cancelled · </b>}{eventWhenLabel(meta)}
1032
+ </button>
1033
+ {meta.rrule && <span className="pe-byline-meta">Repeats</span>}
1034
+ {meta.location.name ? (
1035
+ <button type="button" className="pe-byline-tag" title="Edit location" onClick={() => setSidebarOpen(true)}>{meta.location.name}</button>
1036
+ ) : (
1037
+ <button type="button" className="pe-byline-add" onClick={() => setSidebarOpen(true)}>+ Location</button>
1038
+ )}
1039
+ {calendars.filter((c) => meta.calendarIds.includes(c.id)).map((c) => (
1040
+ <button key={c.id} type="button" className="pe-byline-tag pe-ev-cal" onClick={() => setSidebarOpen(true)}><i style={{ background: c.color }} aria-hidden="true" />{c.name}</button>
1041
+ ))}
1042
+ </div>
1043
+ )}
1044
+
1045
+ {kind === 'post' && (
1046
+ <div className="pe-byline">
1047
+ {primaryAuthor && <span className="pe-byline-author"><i aria-hidden="true">{primaryAuthor.slice(0, 1).toUpperCase()}</i>{primaryAuthor}</span>}
1048
+ {meta.tags.map((t) => (
1049
+ <button key={t} type="button" className="pe-byline-tag" title="Edit tags" onClick={() => setSidebarOpen(true)}>{t}</button>
1050
+ ))}
1051
+ {meta.tags.length === 0 && (
1052
+ <button type="button" className="pe-byline-add" onClick={() => setSidebarOpen(true)}>+ Tags</button>
1053
+ )}
1054
+ <span className="pe-byline-meta">{Math.max(1, Math.round(words / 220))} min read</span>
1055
+ </div>
1056
+ )}
1057
+
601
1058
  {editor && (
602
1059
  <BubbleMenu editor={editor} className="pe-bubble" options={{ placement: 'top' }}>
603
1060
  <button type="button" className={editor.isActive('bold') ? 'is-active' : ''} onClick={() => editor.chain().focus().toggleBold().run()}><b>B</b></button>
604
1061
  <button type="button" className={editor.isActive('italic') ? 'is-active' : ''} onClick={() => editor.chain().focus().toggleItalic().run()}><i>I</i></button>
605
1062
  <button type="button" className={editor.isActive('strike') ? 'is-active' : ''} onClick={() => editor.chain().focus().toggleStrike().run()}><s>S</s></button>
606
1063
  <button type="button" className={editor.isActive('code') ? 'is-active' : ''} onClick={() => editor.chain().focus().toggleCode().run()}>{'<>'}</button>
1064
+ <button type="button" className={editor.isActive('highlight') ? 'is-active' : ''} title="Highlight (==text==)" onClick={() => editor.chain().focus().toggleHighlight().run()}><mark>H</mark></button>
607
1065
  <span className="pe-bubble-sep" />
1066
+ <button type="button" className={editor.isActive('heading', { level: 1 }) ? 'is-active' : ''} onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}>H1</button>
608
1067
  <button type="button" className={editor.isActive('heading', { level: 2 }) ? 'is-active' : ''} onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}>H2</button>
609
1068
  <button type="button" className={editor.isActive('heading', { level: 3 }) ? 'is-active' : ''} onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}>H3</button>
610
1069
  <button type="button" className={editor.isActive('blockquote') ? 'is-active' : ''} onClick={() => editor.chain().focus().toggleBlockquote().run()}>❝</button>
1070
+ <button
1071
+ type="button"
1072
+ title="Regular text"
1073
+ onClick={() => {
1074
+ const c = editor.chain().focus().setParagraph();
1075
+ (editor.isActive('blockquote') ? c.toggleBlockquote() : c).run();
1076
+ }}
1077
+ >
1078
+
1079
+ </button>
611
1080
  <span className="pe-bubble-sep" />
612
1081
  <button
613
1082
  type="button"
@@ -617,6 +1086,10 @@ export default function PostEditor({ kind = 'post', post, page, initialHTML, uns
617
1086
  >
618
1087
  Link
619
1088
  </button>
1089
+ <span className="pe-bubble-sep" />
1090
+ <button type="button" title="Save selection as a snippet — reusable from the “/” menu" onClick={saveSnippet}>
1091
+ + Snippet
1092
+ </button>
620
1093
  </BubbleMenu>
621
1094
  )}
622
1095
 
@@ -642,10 +1115,10 @@ export default function PostEditor({ kind = 'post', post, page, initialHTML, uns
642
1115
  <FloatingMenu
643
1116
  editor={editor}
644
1117
  className="pe-floating"
645
- options={{ placement: 'left' }}
1118
+ options={{ placement: 'left', offset: 14 }}
646
1119
  shouldShow={({ editor: ed, view, state }) => {
647
1120
  // empty paragraph in a non-empty doc — the empty doc shows the
648
- // placeholder instead, so the two never overlap
1121
+ // long placeholder instead
649
1122
  const { $anchor, empty } = state.selection;
650
1123
  return (
651
1124
  view.hasFocus() && empty && !ed.isEmpty && $anchor.depth === 1 &&
@@ -653,66 +1126,67 @@ export default function PostEditor({ kind = 'post', post, page, initialHTML, uns
653
1126
  );
654
1127
  }}
655
1128
  >
656
- {([
657
- ['Image', () => { pickerRangeRef.current = null; setPicker('editor'); }],
658
- ['Gallery', () => editor.chain().focus().insertContent({ type: 'galleryCard' }).run()],
659
- ['Embed', () => editor.chain().focus().insertContent({ type: 'embedCard' }).run()],
660
- ['Bookmark', () => editor.chain().focus().insertContent({ type: 'bookmarkCard' }).run()],
661
- ['File', () => editor.chain().focus().insertContent({ type: 'fileCard' }).run()],
662
- ['Button', () => editor.chain().focus().insertContent({ type: 'buttonCard' }).run()],
663
- ['Callout', () => editor.chain().focus().insertContent({ type: 'callout' }).run()],
664
- ['Calendar', () => editor.chain().focus().insertContent({ type: 'calendarCard' }).run()],
665
- ['Info cards', () => editor.chain().focus().insertContent({ type: 'infoCards' }).run()],
666
- ['Table', () => editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run()],
667
- ['HTML', () => editor.chain().focus().insertContent({ type: 'htmlCard' }).run()],
668
- ['— Divider', () => editor.chain().focus().setHorizontalRule().run()],
669
- ] as [string, () => void][]).map(([label, run]) => (
670
- <button key={label} type="button" onMouseDown={(e) => { e.preventDefault(); run(); }}>{label}</button>
671
- ))}
672
- <span className="pe-floating-hint">or type /</span>
1129
+ <button
1130
+ type="button"
1131
+ className="pe-gutter-add"
1132
+ title="Insert a card"
1133
+ aria-label="Insert a card"
1134
+ onMouseDown={(e) => { e.preventDefault(); editor.chain().focus().insertContent('/').run(); }}
1135
+ >
1136
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><path d="M12 5v14M5 12h14" /></svg>
1137
+ </button>
673
1138
  </FloatingMenu>
674
1139
  )}
675
1140
 
676
1141
  {linkEdit && editor && (
677
1142
  <div className="pe-linkedit" role="dialog" aria-label="Edit link">
678
- <input
679
- autoFocus
680
- type="url"
681
- placeholder="https://… (empty removes the link)"
682
- value={linkDraft}
683
- onChange={(e) => setLinkDraft(e.target.value)}
684
- onKeyDown={(e) => {
685
- if (e.key === 'Enter') {
686
- e.preventDefault();
687
- const url = linkDraft.trim();
688
- if (url === '' || url === 'https://') editor.chain().focus().extendMarkRange('link').unsetLink().run();
689
- else editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run();
690
- setLinkEdit(false);
691
- }
692
- if (e.key === 'Escape') { e.preventDefault(); setLinkEdit(false); editor.commands.focus(); }
693
- }}
694
- />
695
- <button
696
- type="button"
697
- className="pe-btn primary sm"
698
- onClick={() => {
699
- const url = linkDraft.trim();
700
- if (url === '' || url === 'https://') editor.chain().focus().extendMarkRange('link').unsetLink().run();
701
- else editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run();
702
- setLinkEdit(false);
703
- }}
704
- >
705
- Apply
706
- </button>
707
- <button type="button" className="pe-btn ghost sm" onClick={() => { setLinkEdit(false); editor.commands.focus(); }}>✕</button>
1143
+ <div className="pe-linkedit-row">
1144
+ <input
1145
+ autoFocus
1146
+ type="text"
1147
+ placeholder="Paste a URL, or type to search your content… (empty removes the link)"
1148
+ value={linkDraft}
1149
+ onChange={(e) => setLinkDraft(e.target.value)}
1150
+ onKeyDown={(e) => {
1151
+ if (e.key === 'ArrowDown' && linkResults.length) { e.preventDefault(); setLinkSel((s) => (s + 1) % linkResults.length); }
1152
+ if (e.key === 'ArrowUp' && linkResults.length) { e.preventDefault(); setLinkSel((s) => (s - 1 + linkResults.length) % linkResults.length); }
1153
+ if (e.key === 'Enter') {
1154
+ e.preventDefault();
1155
+ applyLink(linkSel >= 0 && linkResults[linkSel] ? linkResults[linkSel].url : linkDraft);
1156
+ }
1157
+ if (e.key === 'Escape') { e.preventDefault(); setLinkEdit(false); editor.commands.focus(); }
1158
+ }}
1159
+ />
1160
+ <button
1161
+ type="button"
1162
+ className="pe-btn primary sm"
1163
+ onClick={() => applyLink(linkSel >= 0 && linkResults[linkSel] ? linkResults[linkSel].url : linkDraft)}
1164
+ >
1165
+ Apply
1166
+ </button>
1167
+ <button type="button" className="pe-btn ghost sm" onClick={() => { setLinkEdit(false); editor.commands.focus(); }}>✕</button>
1168
+ </div>
1169
+ {linkResults.length > 0 && (
1170
+ <div className="pe-link-results">
1171
+ {linkResults.map((t, i) => (
1172
+ <button
1173
+ key={t.url}
1174
+ type="button"
1175
+ className={i === linkSel ? 'is-selected' : ''}
1176
+ onMouseEnter={() => setLinkSel(i)}
1177
+ onMouseDown={(e) => { e.preventDefault(); applyLink(t.url); }}
1178
+ >
1179
+ <span className="pe-link-title">{t.title}</span>
1180
+ <span className="pe-link-url">{t.url}</span>
1181
+ <span className="pe-link-kind">{t.kind}</span>
1182
+ </button>
1183
+ ))}
1184
+ </div>
1185
+ )}
708
1186
  </div>
709
1187
  )}
710
1188
 
711
1189
  <EditorContent editor={editor} />
712
- <div className="pe-wordcount">
713
- {words.toLocaleString()} words
714
- <button type="button" className="pe-shortcuts-btn" title="Keyboard shortcuts (⌘/)" onClick={() => setShortcutsOpen(true)}>⌘/</button>
715
- </div>
716
1190
 
717
1191
  {shortcutsOpen && (
718
1192
  <div className="pe-shortcuts-overlay" onClick={() => setShortcutsOpen(false)}>
@@ -726,7 +1200,8 @@ export default function PostEditor({ kind = 'post', post, page, initialHTML, uns
726
1200
  ['⌘B / ⌘I', 'Bold / italic'],
727
1201
  ['⌘⇧X', 'Strikethrough'],
728
1202
  ['⌘E', 'Inline code'],
729
- ['⌘K', 'Add or edit a link'],
1203
+ ['==text==', 'Highlight'],
1204
+ ['⌘K', 'Link — paste a URL or search your own content'],
730
1205
  ['/', 'Insert a card (image, gallery, embed…)'],
731
1206
  ]],
732
1207
  ['Markdown as you type', [
@@ -757,196 +1232,263 @@ export default function PostEditor({ kind = 'post', post, page, initialHTML, uns
757
1232
  )}
758
1233
  </div>
759
1234
  </main>
1235
+ {editor && <FormatDock editor={editor} words={words} onLink={openLinkEdit} onShortcuts={() => setShortcutsOpen(true)} />}
1236
+ </section>
760
1237
 
761
- {split && (
762
- <aside className="pe-splitpane">
763
- <div className="pe-split-bar">
764
- <span className="pe-split-label">Live preview</span>
765
- <div className="pe-split-actions">
766
- <button type="button" className="pe-btn ghost sm" title="Refresh" onClick={() => setPreviewTick((t) => t + 1)}>↻</button>
767
- <a className="pe-btn ghost sm" href={previewPath} target="_blank" rel="noopener" title="Open in tab">↗</a>
768
- <button type="button" className="pe-btn ghost sm" title="Close preview" onClick={toggleSplit}>✕</button>
769
- </div>
770
- </div>
771
- <iframe key={previewTick} src={previewPath} title="Live preview" />
772
- </aside>
773
- )}
1238
+ {split && <LivePane src={previewPath} path={liveHref} tick={previewTick} focusText={focusText} />}
774
1239
  </div>
775
1240
 
776
1241
  {sidebarOpen && (
777
- <aside className="pe-sidebar">
1242
+ <aside className="pe-sidebar" aria-label={kind === 'page' ? 'Page settings' : `${col.singular} settings`}>
778
1243
  <div className="pe-sidebar-head">
779
1244
  <span>{kind === 'page' ? 'Page settings' : `${col.singular} settings`}</span>
780
- <button type="button" className="pe-btn ghost sm" onClick={() => setSidebarOpen(false)}>✕</button>
1245
+ <button type="button" className="pe-btn ghost sm pe-icon-btn" aria-label="Close settings" onClick={() => setSidebarOpen(false)}>
1246
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><path d="M6 6l12 12M18 6 6 18" /></svg>
1247
+ </button>
781
1248
  </div>
782
1249
 
783
- <label className="pe-field">
784
- <span>{kind === 'page' ? 'Page URL' : `${col.singular} URL`}</span>
785
- <div className="pe-slug">
786
- <span className="pe-slug-prefix">{kind === 'page' ? '/' : `${col.base}/`}</span>
787
- <input type="text" value={meta.slug} onChange={(e) => setMeta({ slug: e.target.value })} />
788
- </div>
789
- </label>
790
-
791
- {kind === 'post' && Object.keys(CONTENT.collections).length > 1 && (
1250
+ <section className="pe-sb-section">
792
1251
  <label className="pe-field">
793
- <span>Collection</span>
794
- <select value={meta.collection} onChange={(e) => setMeta({ collection: e.target.value })}>
795
- {Object.entries(CONTENT.collections).map(([k, c]) => (
796
- <option key={k} value={k}>{c.plural}</option>
797
- ))}
798
- </select>
799
- {meta.status === 'published' && (
800
- <em className="hint">Moving a published {noun} leaves a redirect at its old URL.</em>
801
- )}
1252
+ <span>URL</span>
1253
+ <div className="pe-slug">
1254
+ <span className="pe-slug-prefix">{kind === 'page' ? '/' : `/${col.base.replace(/^\/+|\/+$/g, '')}/`}</span>
1255
+ <input type="text" value={meta.slug} spellCheck={false} onChange={(e) => setMeta({ slug: e.target.value })} />
1256
+ </div>
802
1257
  </label>
803
- )}
804
1258
 
805
- {kind === 'page' && I18N.enabled && (
806
- <label className="pe-field">
807
- <span>Language</span>
808
- <select
809
- value={meta.locale || I18N.defaultLocale}
810
- onChange={(e) => setMeta({ locale: e.target.value })}
811
- >
812
- {I18N.locales.map((l) => (
813
- <option key={l} value={l}>{l.toUpperCase()}{l === I18N.defaultLocale ? ' — default' : ''}</option>
814
- ))}
815
- </select>
816
- </label>
817
- )}
1259
+ {kind === 'post' && (
1260
+ <div className="pe-field">
1261
+ <span>Publish date</span>
1262
+ {meta.publishedAt || dateOpen ? (
1263
+ <div className="pe-date">
1264
+ <input
1265
+ type="datetime-local"
1266
+ autoFocus={dateOpen && !meta.publishedAt}
1267
+ value={tsToLocal(meta.publishedAt)}
1268
+ onChange={(e) => setMeta({ publishedAt: localToTs(e.target.value) })}
1269
+ />
1270
+ <button type="button" className="pe-btn ghost sm" title="Clear — use the time it's published" onClick={() => { setMeta({ publishedAt: null }); setDateOpen(false); }}>Clear</button>
1271
+ </div>
1272
+ ) : (
1273
+ <button type="button" className="pe-date-empty" onClick={() => setDateOpen(true)}>
1274
+ <span>When you publish</span>
1275
+ <em>Set a date</em>
1276
+ </button>
1277
+ )}
1278
+ </div>
1279
+ )}
818
1280
 
819
- {kind === 'page' && (
820
- <label className="pe-field pe-check">
821
- <input
822
- type="checkbox"
823
- checked={meta.isHome}
824
- onChange={(e) => setMeta({ isHome: e.target.checked })}
825
- />
826
- {I18N.enabled ? (
827
- <span>Serve this page at <code>{localeHomeUrl(meta.locale)}</code> ({(meta.locale || I18N.defaultLocale).toUpperCase()} home page)</span>
828
- ) : (
829
- <span>Serve this page at <code>/</code> (home page)</span>
830
- )}
831
- </label>
832
- )}
1281
+ {postLike && (
1282
+ <label className="pe-field">
1283
+ <span>Tags</span>
1284
+ <TagInput tags={meta.tags} suggestions={tagSuggestions} onChange={(tags) => setMeta({ tags })} />
1285
+ </label>
1286
+ )}
833
1287
 
834
- {templates.length > 0 && (
835
- <label className="pe-field">
836
- <span>Template <em className="pe-check-hint">from the active theme</em></span>
837
- <select value={meta.template} onChange={(e) => setMeta({ template: e.target.value })}>
838
- <option value="">Default</option>
839
- {templates.map((t) => (
840
- <option key={t} value={t}>{t}</option>
841
- ))}
842
- </select>
843
- </label>
844
- )}
1288
+ {kind === 'post' && authors.length > 0 && (
1289
+ <div className="pe-field">
1290
+ <span>Authors <em>first is the byline</em></span>
1291
+ <div className="pe-authors">
1292
+ {authors.map((a) => {
1293
+ const checked = meta.authorIds.includes(a.id);
1294
+ return (
1295
+ <label key={a.id} className={`pe-author ${checked ? 'is-on' : ''}`}>
1296
+ <input
1297
+ type="checkbox"
1298
+ checked={checked}
1299
+ onChange={(e) =>
1300
+ setMeta({
1301
+ authorIds: e.target.checked
1302
+ ? [...meta.authorIds, a.id]
1303
+ : meta.authorIds.filter((id) => id !== a.id),
1304
+ })
1305
+ }
1306
+ />
1307
+ <i aria-hidden="true">{a.name.slice(0, 1).toUpperCase()}</i>
1308
+ <span>{a.name}</span>
1309
+ {checked && meta.authorIds[0] === a.id && meta.authorIds.length > 1 && <em>Primary</em>}
1310
+ </label>
1311
+ );
1312
+ })}
1313
+ </div>
1314
+ </div>
1315
+ )}
845
1316
 
846
- {kind === 'post' && (
847
- <>
1317
+ {kind === 'post' && Object.keys(CONTENT.collections).length > 1 && (
848
1318
  <label className="pe-field">
849
- <span>Publish date</span>
850
- <input
851
- type="datetime-local"
852
- value={tsToLocal(meta.publishedAt)}
853
- onChange={(e) => setMeta({ publishedAt: localToTs(e.target.value) })}
854
- />
1319
+ <span>Collection</span>
1320
+ <select value={meta.collection} onChange={(e) => setMeta({ collection: e.target.value })}>
1321
+ {Object.entries(CONTENT.collections).map(([k, c]) => (
1322
+ <option key={k} value={k}>{c.plural}</option>
1323
+ ))}
1324
+ </select>
1325
+ {meta.status === 'published' && (
1326
+ <em className="hint">Moving a published {noun} leaves a redirect at its old URL.</em>
1327
+ )}
855
1328
  </label>
1329
+ )}
856
1330
 
1331
+ {kind === 'page' && I18N.enabled && (
857
1332
  <label className="pe-field">
858
- <span>Tags</span>
859
- <TagInput tags={meta.tags} suggestions={tagSuggestions} onChange={(tags) => setMeta({ tags })} />
1333
+ <span>Language</span>
1334
+ <select value={meta.locale || I18N.defaultLocale} onChange={(e) => setMeta({ locale: e.target.value })}>
1335
+ {I18N.locales.map((l) => (
1336
+ <option key={l} value={l}>{l.toUpperCase()}{l === I18N.defaultLocale ? ' — default' : ''}</option>
1337
+ ))}
1338
+ </select>
860
1339
  </label>
1340
+ )}
861
1341
 
862
- <label className="pe-field pe-check">
863
- <input
864
- type="checkbox"
865
- checked={meta.featured}
866
- onChange={(e) => setMeta({ featured: e.target.checked })}
867
- />
868
- <span>Feature this post <em className="pe-check-hint">highlighted on the homepage and in featured lists</em></span>
1342
+ {templates.length > 0 && (
1343
+ <label className="pe-field">
1344
+ <span>Template <em>from the active theme</em></span>
1345
+ <select value={meta.template} onChange={(e) => setMeta({ template: e.target.value })}>
1346
+ <option value="">Default</option>
1347
+ {templates.map((t) => (
1348
+ <option key={t} value={t}>{t}</option>
1349
+ ))}
1350
+ </select>
869
1351
  </label>
1352
+ )}
870
1353
 
871
- {authors.length > 0 && (
872
- <div className="pe-field">
873
- <span>Authors <em className="pe-check-hint">first is the primary byline</em></span>
874
- <div className="pe-authors">
875
- {authors.map((a) => {
876
- const checked = meta.authorIds.includes(a.id);
877
- return (
878
- <label key={a.id} className="pe-check pe-author-check">
879
- <input
880
- type="checkbox"
881
- checked={checked}
882
- onChange={(e) =>
883
- setMeta({
884
- authorIds: e.target.checked
885
- ? [...meta.authorIds, a.id]
886
- : meta.authorIds.filter((id) => id !== a.id),
887
- })
888
- }
889
- />
890
- <span>{a.name}{checked && meta.authorIds[0] === a.id && meta.authorIds.length > 1 ? ' · primary' : ''}</span>
891
- </label>
892
- );
893
- })}
894
- </div>
1354
+ {postLike && (
1355
+ <label className="pe-switch-row">
1356
+ <span><b>Feature this {noun}</b><small>Highlighted on the homepage and in featured lists</small></span>
1357
+ <input type="checkbox" role="switch" className="pe-switch" checked={meta.featured} onChange={(e) => setMeta({ featured: e.target.checked })} />
1358
+ </label>
1359
+ )}
1360
+
1361
+ {kind === 'page' && (
1362
+ <label className="pe-switch-row">
1363
+ <span>
1364
+ <b>Use as home page</b>
1365
+ <small>
1366
+ Served at <code>{I18N.enabled ? localeHomeUrl(meta.locale) : '/'}</code>
1367
+ {I18N.enabled && ` (${(meta.locale || I18N.defaultLocale).toUpperCase()})`}
1368
+ </small>
1369
+ </span>
1370
+ <input type="checkbox" role="switch" className="pe-switch" checked={meta.isHome} onChange={(e) => setMeta({ isHome: e.target.checked })} />
1371
+ </label>
1372
+ )}
1373
+ </section>
1374
+
1375
+ {kind === 'event' && (
1376
+ <EventPanel
1377
+ meta={meta}
1378
+ onChange={(patch) => setMeta(patch)}
1379
+ calendars={calendars}
1380
+ fields={eventFields}
1381
+ siteTimezone={siteTimezone}
1382
+ />
1383
+ )}
1384
+
1385
+ {postLike && (
1386
+ <section className="pe-sb-section">
1387
+ <h3 className="pe-sb-title">Feature image &amp; excerpt</h3>
1388
+ <div className="pe-sb-image">
1389
+ {meta.cover ? <img src={meta.cover} alt="" /> : <span className="pe-sb-image-empty" aria-hidden="true" />}
1390
+ <div className="pe-sb-image-actions">
1391
+ <button type="button" className="pe-btn sm" onClick={() => setPicker('feature')}>{meta.cover ? 'Change' : 'Add image'}</button>
1392
+ {meta.cover && <button type="button" className="pe-btn ghost sm" onClick={() => setMeta({ cover: '', coverAlt: '' })}>Remove</button>}
895
1393
  </div>
1394
+ </div>
1395
+ {meta.cover && (
1396
+ <label className="pe-field">
1397
+ <span>Alt text</span>
1398
+ <input type="text" placeholder="Describe the image for screen readers" value={meta.coverAlt} onChange={(e) => setMeta({ coverAlt: e.target.value })} />
1399
+ </label>
896
1400
  )}
897
-
898
1401
  <label className="pe-field">
899
- <span>Excerpt <em>{meta.excerpt.length}/300</em></span>
1402
+ <span>Excerpt <em>{meta.excerpt.length} / 300</em></span>
900
1403
  <textarea
901
1404
  rows={3}
902
1405
  maxLength={300}
903
- placeholder="A short teaser shown on the blog index and in link previews"
1406
+ placeholder="A short teaser for post cards and link previews"
904
1407
  value={meta.excerpt}
905
1408
  onChange={(e) => setMeta({ excerpt: e.target.value })}
906
1409
  />
907
1410
  </label>
908
-
909
- <div className="pe-field">
910
- <span>Feature image alt</span>
911
- <input
912
- type="text"
913
- placeholder="Describe the feature image"
914
- value={meta.coverAlt}
915
- onChange={(e) => setMeta({ coverAlt: e.target.value })}
916
- />
917
- </div>
918
- </>
1411
+ </section>
919
1412
  )}
920
1413
 
921
- <details className="pe-seo">
922
- <summary>Search engine optimization</summary>
923
- <label className="pe-field">
924
- <span>Meta title <em>{(meta.seoTitle || meta.title).length}/60</em></span>
925
- <input type="text" placeholder={meta.title} value={meta.seoTitle} onChange={(e) => setMeta({ seoTitle: e.target.value })} />
926
- </label>
927
- <label className="pe-field">
928
- <span>Meta description <em>{(meta.seoDesc || meta.excerpt).length}/160</em></span>
929
- <textarea rows={3} placeholder={meta.excerpt} value={meta.seoDesc} onChange={(e) => setMeta({ seoDesc: e.target.value })} />
930
- </label>
931
- </details>
1414
+ <section className="pe-sb-section">
1415
+ <h3 className="pe-sb-title">Search &amp; social</h3>
1416
+ <button type="button" className="pe-sb-serp" onClick={() => { setPreviewView('search'); setPreviewOpen(true); }}>
1417
+ <small>{(site.url || (typeof location !== 'undefined' ? location.origin : '')).replace(/^https?:\/\//, '').replace(/\/+$/, '')}{publicPath(meta)}</small>
1418
+ <b>{meta.seoTitle || meta.title || 'Untitled'}</b>
1419
+ <span>{meta.seoDesc || meta.excerpt || 'No description yet — search engines will use the first paragraph.'}</span>
1420
+ <em>Edit search &amp; social preview →</em>
1421
+ </button>
1422
+ </section>
932
1423
 
933
- <div className="pe-danger">
1424
+ <section className="pe-sb-section pe-sb-danger">
934
1425
  <button
935
1426
  type="button"
936
1427
  className="pe-btn danger sm"
937
1428
  onClick={async () => {
938
1429
  if (!window.confirm(`Delete this ${noun} permanently? This cannot be undone.`)) return;
939
1430
  if (kind === 'page') await actions.pages.delete({ id: docId });
1431
+ else if (kind === 'event') await actions.events.delete({ id: docId });
940
1432
  else await actions.posts.delete({ id: docId });
1433
+ leavingRef.current = true;
941
1434
  location.href = listHref;
942
1435
  }}
943
1436
  >
944
1437
  Delete {noun}
945
1438
  </button>
946
- </div>
1439
+ </section>
947
1440
  </aside>
948
1441
  )}
949
1442
 
1443
+ {leaveOpen && (() => {
1444
+ const reason = leaveReason() ?? 'draft';
1445
+ const live = reason === 'live';
1446
+ const failed = saveState === 'error';
1447
+ return (
1448
+ <div className="pe-leave-backdrop" onMouseDown={(e) => { if (e.target === e.currentTarget && !leaveBusy) setLeaveOpen(null); }}>
1449
+ <div className="pe-leave" role="alertdialog" aria-modal="true" aria-labelledby="pe-leave-title" aria-describedby="pe-leave-desc">
1450
+ <h2 id="pe-leave-title">{live ? 'Your changes aren’t live yet' : `Leave this ${noun}?`}</h2>
1451
+ <p id="pe-leave-desc">
1452
+ {live
1453
+ ? `The live ${noun} still shows the old version. Your edits are saved privately.`
1454
+ : `Your edits are saved, and the ${noun} is still an unpublished draft.`}
1455
+ </p>
1456
+ {failed && <p className="pe-leave-error">Saving failed — check your connection before leaving.</p>}
1457
+ <div className="pe-leave-actions">
1458
+ {live ? (
1459
+ <>
1460
+ <button type="button" className="pe-btn primary pe-leave-cta" autoFocus disabled={!!leaveBusy} onClick={() => leaveWith('update')}>
1461
+ {leaveBusy || (meta.status === 'scheduled' ? 'Update scheduled version & leave' : 'Update & leave')}
1462
+ </button>
1463
+ <button type="button" className="pe-pop-item" disabled={!!leaveBusy} onClick={() => leaveWith('keep')}>
1464
+ <b>Leave, keep as draft</b><span>Edits stay private until you press Update</span>
1465
+ </button>
1466
+ <button type="button" className="pe-pop-item pe-leave-danger" disabled={!!leaveBusy} onClick={() => leaveWith('discard')}>
1467
+ <b>Discard changes & leave</b><span>Go back to the live version</span>
1468
+ </button>
1469
+ </>
1470
+ ) : (
1471
+ <>
1472
+ <button type="button" className="pe-btn primary pe-leave-cta" autoFocus disabled={!!leaveBusy} onClick={() => leaveWith('keep')}>
1473
+ {leaveBusy || 'Leave, keep as draft'}
1474
+ </button>
1475
+ <button type="button" className="pe-pop-item" disabled={!!leaveBusy} onClick={() => leaveWith('publish')}>
1476
+ <b>Publish & leave</b><span>Put it live on the site now</span>
1477
+ </button>
1478
+ <button type="button" className="pe-pop-item pe-leave-danger" disabled={!!leaveBusy} onClick={() => leaveWith('undo')}>
1479
+ <b>Undo my changes & leave</b><span>Back to how it was when you opened it</span>
1480
+ </button>
1481
+ </>
1482
+ )}
1483
+ <button type="button" className="pe-btn ghost pe-leave-cta" disabled={!!leaveBusy} onClick={() => setLeaveOpen(null)}>
1484
+ Keep editing
1485
+ </button>
1486
+ </div>
1487
+ </div>
1488
+ </div>
1489
+ );
1490
+ })()}
1491
+
950
1492
  <ImagePicker
951
1493
  open={picker !== 'closed'}
952
1494
  unsplashEnabled={unsplashEnabled}
@@ -957,8 +1499,21 @@ export default function PostEditor({ kind = 'post', post, page, initialHTML, uns
957
1499
  open={previewOpen}
958
1500
  url={previewPath}
959
1501
  shareUrl={sharePath}
1502
+ meta={previewMeta}
1503
+ site={site}
1504
+ isPost={postLike}
1505
+ startView={previewView}
1506
+ onChange={setMeta}
1507
+ onPickCover={() => setPicker('feature')}
960
1508
  onClose={() => setPreviewOpen(false)}
961
1509
  />
1510
+ {editor && (
1511
+ <MobileToolbar
1512
+ editor={editor}
1513
+ onLink={openLinkEdit}
1514
+ onImage={() => setPicker('editor')}
1515
+ />
1516
+ )}
962
1517
  </div>
963
1518
  );
964
1519
  }