@acms-subir/core 0.2.0 → 0.3.1

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 (254) hide show
  1. package/CHANGELOG.md +87 -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/live-video.md +91 -0
  15. package/docs/theme-authoring.md +103 -0
  16. package/email-runner.mjs +32 -0
  17. package/integration.mjs +189 -2
  18. package/ops-check.mjs +27 -0
  19. package/package.json +27 -5
  20. package/src/actions/index.ts +578 -49
  21. package/src/components/AiCleaner.tsx +291 -0
  22. package/src/components/AiSettings.tsx +575 -0
  23. package/src/components/CommandPalette.tsx +200 -0
  24. package/src/components/ContentList.astro +246 -0
  25. package/src/components/DesignAccess.tsx +182 -0
  26. package/src/components/DesignManager.tsx +418 -46
  27. package/src/components/EmailSettings.tsx +521 -0
  28. package/src/components/EventPage.astro +178 -0
  29. package/src/components/LegalPage.astro +27 -0
  30. package/src/components/LinksManager.tsx +119 -58
  31. package/src/components/LiveCard.astro +174 -0
  32. package/src/components/LiveManager.tsx +910 -0
  33. package/src/components/NavManager.tsx +42 -43
  34. package/src/components/ThemeScript.astro +50 -0
  35. package/src/components/ThemeSections.astro +58 -0
  36. package/src/components/ThemeSectionsEditor.tsx +513 -0
  37. package/src/components/WidgetRenderer.astro +3 -0
  38. package/src/components/booking/BookingFlow.astro +150 -0
  39. package/src/components/booking/HoursEditor.astro +63 -0
  40. package/src/components/builder/PageBuilder.tsx +10 -2
  41. package/src/components/editor/EventPanel.tsx +401 -0
  42. package/src/components/editor/FormatDock.tsx +81 -0
  43. package/src/components/editor/LivePane.tsx +122 -0
  44. package/src/components/editor/MobileToolbar.tsx +93 -0
  45. package/src/components/editor/PostEditor.tsx +605 -223
  46. package/src/components/editor/PreviewOverlay.tsx +242 -43
  47. package/src/components/editor/extensions/calendar.tsx +51 -7
  48. package/src/components/editor/extensions/events.tsx +153 -0
  49. package/src/components/editor/extensions/slash.tsx +7 -1
  50. package/src/components/site/Pager.astro +24 -0
  51. package/src/components/site/PostCard.astro +64 -0
  52. package/src/components/site/TagLabel.astro +11 -0
  53. package/src/layouts/Admin.astro +289 -49
  54. package/src/layouts/Site.astro +352 -59
  55. package/src/lib/agent-api.ts +13 -0
  56. package/src/lib/agent-discovery.ts +40 -0
  57. package/src/lib/agent.ts +114 -0
  58. package/src/lib/ai-config.ts +58 -0
  59. package/src/lib/ai-schema.ts +22 -0
  60. package/src/lib/ai.ts +90 -0
  61. package/src/lib/api-keys.ts +65 -0
  62. package/src/lib/api.ts +56 -0
  63. package/src/lib/auth.ts +25 -5
  64. package/src/lib/backup/content.ts +36 -0
  65. package/src/lib/backup/unzip.ts +12 -1
  66. package/src/lib/backup/zip.ts +1 -1
  67. package/src/lib/booking/agent.ts +108 -0
  68. package/src/lib/booking/availability.ts +221 -0
  69. package/src/lib/booking/emails.ts +115 -0
  70. package/src/lib/booking/forms.ts +77 -0
  71. package/src/lib/booking/service.ts +277 -0
  72. package/src/lib/booking/view.ts +54 -0
  73. package/src/lib/calendar/agent-schema.ts +48 -0
  74. package/src/lib/calendar/agent.ts +136 -0
  75. package/src/lib/calendar/api.ts +81 -0
  76. package/src/lib/calendar/cards.ts +102 -0
  77. package/src/lib/calendar/config.ts +52 -0
  78. package/src/lib/calendar/data.ts +130 -0
  79. package/src/lib/calendar/events.ts +261 -0
  80. package/src/lib/calendar/feeds.ts +43 -0
  81. package/src/lib/calendar/hooks.ts +38 -0
  82. package/src/lib/calendar/http.ts +30 -0
  83. package/src/lib/calendar/ics-write.ts +120 -0
  84. package/src/lib/calendar/ics.ts +15 -5
  85. package/src/lib/calendar/import.ts +203 -0
  86. package/src/lib/calendar/render.ts +120 -37
  87. package/src/lib/calendar/rrule.ts +149 -0
  88. package/src/lib/calendar/seo.ts +48 -0
  89. package/src/lib/design-service.ts +58 -0
  90. package/src/lib/designDiff.ts +145 -0
  91. package/src/lib/designPreview.ts +110 -12
  92. package/src/lib/documents/cleanup.ts +31 -0
  93. package/src/lib/documents/extract.ts +154 -0
  94. package/src/lib/documents/vendor.d.ts +4 -0
  95. package/src/lib/drafts.ts +60 -6
  96. package/src/lib/dynamicBlocks.ts +40 -8
  97. package/src/lib/email/accounts.ts +55 -0
  98. package/src/lib/email/config.ts +86 -0
  99. package/src/lib/email/migration.ts +24 -0
  100. package/src/lib/email/providers.ts +89 -0
  101. package/src/lib/email/service.ts +78 -0
  102. package/src/lib/email/store.ts +167 -0
  103. package/src/lib/email/templates.ts +18 -0
  104. package/src/lib/email/types.ts +32 -0
  105. package/src/lib/email/webhooks.ts +87 -0
  106. package/src/lib/email.ts +15 -0
  107. package/src/lib/env.ts +1 -1
  108. package/src/lib/legal/index.ts +25 -0
  109. package/src/lib/live/client.ts +88 -0
  110. package/src/lib/live/crypto.ts +28 -0
  111. package/src/lib/live/migration.ts +15 -0
  112. package/src/lib/live/providers.ts +152 -0
  113. package/src/lib/live/service.ts +170 -0
  114. package/src/lib/live/signals.ts +89 -0
  115. package/src/lib/live/store.ts +49 -0
  116. package/src/lib/live/types.ts +52 -0
  117. package/src/lib/live/view.ts +78 -0
  118. package/src/lib/mobile/bookings.ts +73 -0
  119. package/src/lib/mobile/calendar.ts +85 -0
  120. package/src/lib/mobile/content.ts +85 -0
  121. package/src/lib/mobile/http.ts +63 -0
  122. package/src/lib/mobile/route.ts +20 -0
  123. package/src/lib/ops.ts +8 -0
  124. package/src/lib/preview.ts +21 -1
  125. package/src/lib/repo/migrations.ts +221 -0
  126. package/src/lib/repo/sql.ts +907 -68
  127. package/src/lib/repo/types.ts +125 -5
  128. package/src/lib/scheduler.ts +27 -0
  129. package/src/lib/schema.ts +316 -1
  130. package/src/lib/search-i18n.ts +3 -0
  131. package/src/lib/socialIcons.ts +32 -0
  132. package/src/lib/tagColor.ts +11 -0
  133. package/src/lib/theme.ts +59 -40
  134. package/src/lib/themeTokens.ts +183 -0
  135. package/src/lib/umami.ts +108 -0
  136. package/src/lib/webhook-sql.ts +68 -0
  137. package/src/lib/webhooks.ts +147 -0
  138. package/src/lib/widgets.ts +1 -0
  139. package/src/middleware.ts +45 -3
  140. package/src/pages/404.astro +23 -0
  141. package/src/pages/[...slug].astro +14 -5
  142. package/src/pages/account/[action].astro +53 -0
  143. package/src/pages/admin/ai.astro +12 -0
  144. package/src/pages/admin/c/[collection]/[id].astro +4 -1
  145. package/src/pages/admin/c/[collection]/index.astro +127 -92
  146. package/src/pages/admin/calendar/bookings.astro +400 -0
  147. package/src/pages/admin/calendar/calendars.astro +265 -0
  148. package/src/pages/admin/calendar/events/[id].astro +56 -0
  149. package/src/pages/admin/calendar/events/index.astro +408 -0
  150. package/src/pages/admin/calendar/index.ts +4 -0
  151. package/src/pages/admin/calendar/integrations.astro +4 -0
  152. package/src/pages/admin/calendar/resources/[id].astro +180 -0
  153. package/src/pages/admin/calendar/resources/index.astro +106 -0
  154. package/src/pages/admin/calendar/resources/link.astro +196 -0
  155. package/src/pages/admin/design.astro +10 -3
  156. package/src/pages/admin/email.astro +13 -0
  157. package/src/pages/admin/index.astro +232 -85
  158. package/src/pages/admin/integrations.astro +237 -0
  159. package/src/pages/admin/links.astro +3 -3
  160. package/src/pages/admin/live.astro +12 -0
  161. package/src/pages/admin/login.astro +14 -10
  162. package/src/pages/admin/navigation.astro +8 -2
  163. package/src/pages/admin/pages/[id].astro +4 -1
  164. package/src/pages/admin/pages/index.astro +41 -48
  165. package/src/pages/admin/settings.astro +418 -367
  166. package/src/pages/admin/tags.astro +4 -4
  167. package/src/pages/admin/users.astro +38 -28
  168. package/src/pages/api/agent/docs.ts +33 -0
  169. package/src/pages/api/agent/index.ts +21 -0
  170. package/src/pages/api/ai/clean.ts +13 -0
  171. package/src/pages/api/ai/draft.ts +14 -0
  172. package/src/pages/api/ai/settings.ts +53 -0
  173. package/src/pages/api/ai/test.ts +12 -0
  174. package/src/pages/api/backup.ts +8 -21
  175. package/src/pages/api/calendar/availability.ts +35 -0
  176. package/src/pages/api/calendar/bookings/[id].ts +49 -0
  177. package/src/pages/api/calendar/bookings/index.ts +73 -0
  178. package/src/pages/api/calendar/calendars.ts +35 -0
  179. package/src/pages/api/calendar/docs.ts +66 -0
  180. package/src/pages/api/calendar/events/[id].ts +63 -0
  181. package/src/pages/api/calendar/events/index.ts +112 -0
  182. package/src/pages/api/calendar/resources.ts +34 -0
  183. package/src/pages/api/design/capabilities.ts +10 -0
  184. package/src/pages/api/design/index.ts +58 -0
  185. package/src/pages/api/design/keys.ts +24 -0
  186. package/src/pages/api/design/media.ts +32 -0
  187. package/src/pages/api/email/run.ts +11 -0
  188. package/src/pages/api/email/settings.ts +61 -0
  189. package/src/pages/api/email/webhooks/[provider].ts +4 -0
  190. package/src/pages/api/live/oauth/youtube/callback.ts +19 -0
  191. package/src/pages/api/live/settings.ts +45 -0
  192. package/src/pages/api/live/status.ts +7 -0
  193. package/src/pages/api/live/thumb.ts +48 -0
  194. package/src/pages/api/mcp.ts +59 -0
  195. package/src/pages/api/media/upload.ts +2 -2
  196. package/src/pages/api/ops/status.ts +39 -0
  197. package/src/pages/api/restore.ts +27 -37
  198. package/src/pages/api/search.ts +18 -4
  199. package/src/pages/api/v1/availability.ts +7 -0
  200. package/src/pages/api/v1/bookings.ts +17 -0
  201. package/src/pages/api/v1/calendars.ts +7 -0
  202. package/src/pages/api/v1/content/[id].ts +7 -0
  203. package/src/pages/api/v1/content/index.ts +7 -0
  204. package/src/pages/api/v1/events.ts +7 -0
  205. package/src/pages/api/v1/resources.ts +7 -0
  206. package/src/pages/api/webhooks/docs.ts +61 -0
  207. package/src/pages/author/[slug].astro +43 -35
  208. package/src/pages/blog/[slug].astro +137 -66
  209. package/src/pages/blog/index.astro +95 -30
  210. package/src/pages/blog/tag/[slug].astro +45 -40
  211. package/src/pages/book/[resource]/[link].astro +70 -0
  212. package/src/pages/book/[resource]/index.astro +63 -0
  213. package/src/pages/book/[resource].ics.ts +38 -0
  214. package/src/pages/book/index.astro +47 -0
  215. package/src/pages/book/manage/[token].astro +108 -0
  216. package/src/pages/calendar/[slug].ics.ts +27 -0
  217. package/src/pages/calendar/feed.ics.ts +15 -0
  218. package/src/pages/calendar/index.astro +191 -0
  219. package/src/pages/embed/book/[resource]/[link].astro +33 -0
  220. package/src/pages/embed/calendar/[slug].astro +43 -0
  221. package/src/pages/events/[slug].astro +74 -0
  222. package/src/pages/events/[slug].ics.ts +25 -0
  223. package/src/pages/index.astro +12 -3
  224. package/src/pages/legal/privacy.astro +4 -0
  225. package/src/pages/legal/terms.astro +4 -0
  226. package/src/pages/links.astro +29 -3
  227. package/src/pages/llms.txt.ts +4 -0
  228. package/src/pages/preview/[type]/[id].astro +27 -6
  229. package/src/pages/search.astro +40 -20
  230. package/src/pages/sitemap.xml.ts +16 -1
  231. package/src/styles/admin.css +924 -105
  232. package/src/styles/cards.css +92 -74
  233. package/src/styles/editor.css +492 -114
  234. package/src/styles/email-settings.css +117 -0
  235. package/src/styles/live-admin.css +126 -0
  236. package/src/styles/live.css +199 -0
  237. package/src/styles/sections.css +12 -0
  238. package/src/styles/site.css +938 -281
  239. package/src/styles/theme-editor.css +135 -0
  240. package/src/theme-sdk/contract.d.mts +619 -0
  241. package/src/theme-sdk/contract.mjs +81 -0
  242. package/src/theme-sdk/index.ts +15 -0
  243. package/src/theme-sdk/tokens.d.mts +148 -0
  244. package/src/theme-sdk/tokens.mjs +55 -0
  245. package/src/themes/editorial/templates/home.astro +4 -4
  246. package/src/themes/editorial/templates/post-minimal.astro +2 -2
  247. package/src/themes/editorial/theme.css +27 -37
  248. package/src/themes/editorial/theme.json +25 -3
  249. package/src/themes/field/theme.json +27 -9
  250. package/src/themes/ink/theme.json +30 -12
  251. package/src/themes/night/theme.css +4 -7
  252. package/src/themes/night/theme.json +28 -10
  253. package/src/virtual.d.ts +14 -0
  254. package/src/worker.ts +5 -1
@@ -1,4 +1,4 @@
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';
@@ -7,7 +7,7 @@ import Highlight from '@tiptap/extension-highlight';
7
7
  import { TableKit } from '@tiptap/extension-table';
8
8
  import { Placeholder, CharacterCount } from '@tiptap/extensions';
9
9
  import { actions } from 'astro:actions';
10
- import type { Page, Post, Status } from '../../lib/schema';
10
+ import type { CalEvent, Page, Post, Status } from '../../lib/schema';
11
11
  import { ImageCard, EmbedCard, ButtonCard, Callout, HtmlCard, detectEmbed } from './extensions/cards';
12
12
  import { FileCard, GalleryCard, BookmarkCard } from './extensions/mediaCards';
13
13
  import { CodeBlock } from './extensions/codeBlock';
@@ -15,15 +15,21 @@ import { PostListCard } from './extensions/postList';
15
15
  import { ToggleCard } from './extensions/toggle';
16
16
  import { TocCard } from './extensions/toc';
17
17
  import { CalendarCard } from './extensions/calendar';
18
+ import { EventCard } from './extensions/events';
18
19
  import { InfoCards } from './extensions/infoCards';
19
20
  import { SlashMenu, type SlashSnippet } from './extensions/slash';
20
21
  import { TableTyping } from './extensions/tableTyping';
21
22
  import { ImagePicker, type PickedImage } from './ImagePicker';
22
- 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';
23
27
  import { renderMarkdown } from '../../lib/markdown';
24
28
  import { CONTENT, collectionUrl } from '../../lib/content';
25
29
  import { I18N, localeHomeUrl } from '../../lib/i18n';
26
- import { mergePageDraft, mergePostDraft } from '../../lib/drafts';
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';
27
33
 
28
34
  // The Ghost-style writing surface, shared by posts AND pages: distraction-free
29
35
  // column, cards via "/", autosave, a collapsible settings sidebar, and an
@@ -36,9 +42,15 @@ import { mergePageDraft, mergePostDraft } from '../../lib/drafts';
36
42
  type SaveState = 'saved' | 'dirty' | 'saving' | 'error';
37
43
 
38
44
  interface Props {
39
- kind?: 'post' | 'page';
45
+ kind?: 'post' | 'page' | 'event';
40
46
  post?: Post;
41
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;
42
54
  initialHTML: string;
43
55
  unsplashEnabled: boolean;
44
56
  previewPath: string;
@@ -48,9 +60,11 @@ interface Props {
48
60
  livePath?: string;
49
61
  // theme-provided template names for this document kind; empty = no picker
50
62
  templates?: string[];
63
+ // public identity for the Search / Social preview tabs
64
+ site?: { title: string; url: string };
51
65
  }
52
66
 
53
- interface Meta {
67
+ interface Meta extends EventMeta {
54
68
  title: string;
55
69
  slug: string;
56
70
  collection: string;
@@ -122,8 +136,16 @@ function joinBrokenTableRows(text: string): string {
122
136
  return out.join('\n');
123
137
  }
124
138
 
125
- export default function PostEditor({ kind = 'post', post, page, initialHTML, unsplashEnabled, previewPath, sharePath, livePath: livePathProp, templates = [] }: Props) {
126
- 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';
127
149
  const docId = doc.id;
128
150
  // pending = staged edits exist that the live site doesn't show yet
129
151
  const [pending, setPending] = useState(() => Boolean(doc.draft));
@@ -132,11 +154,23 @@ export default function PostEditor({ kind = 'post', post, page, initialHTML, uns
132
154
  const [meta, setMetaState] = useState<Meta>(() =>
133
155
  kind === 'page'
134
156
  ? {
157
+ ...NO_EVENT,
135
158
  title: page!.title, slug: page!.slug, collection: 'posts', tags: [], excerpt: '', cover: '', coverAlt: '',
136
159
  seoTitle: page!.seoTitle, seoDesc: page!.seoDesc, status: page!.status,
137
160
  featured: false, isHome: page!.isHome, locale: page!.locale, authorId: null, authorIds: [], template: page!.template, publishedAt: null, scheduledAt: null,
138
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
+ }
139
172
  : {
173
+ ...NO_EVENT,
140
174
  title: post!.title, slug: post!.slug, collection: post!.collection, tags: post!.tags, excerpt: post!.excerpt,
141
175
  cover: post!.cover, coverAlt: post!.coverAlt, seoTitle: post!.seoTitle, seoDesc: post!.seoDesc,
142
176
  status: post!.status, featured: post!.featured, isHome: false, locale: '', authorId: post!.authorId,
@@ -146,16 +180,28 @@ export default function PostEditor({ kind = 'post', post, page, initialHTML, uns
146
180
  }
147
181
  );
148
182
  // labels + URL base follow the post's collection (config key on the row)
149
- const col = kind === 'page' ? CONTENT.posts : (CONTENT.collections[meta.collection] ?? CONTENT.posts);
150
- 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}`;
151
187
  const noun = kind === 'page' ? 'page' : col.singular.toLowerCase();
152
188
 
153
189
  const [authors, setAuthors] = useState<AuthorOption[]>([]);
154
190
  const [tagSuggestions, setTagSuggestions] = useState<string[]>([]);
155
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('');
156
201
  const [saveState, setSaveState] = useState<SaveState>('saved');
157
202
  const saveStateRef = useRef<SaveState>('saved');
158
- 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);
159
205
  const [publishOpen, setPublishOpen] = useState(false);
160
206
  const [previewOpen, setPreviewOpen] = useState(false);
161
207
  const [split, setSplit] = useState(() => {
@@ -176,12 +222,23 @@ export default function PostEditor({ kind = 'post', post, page, initialHTML, uns
176
222
  const snippetsRef = useRef<SlashSnippet[]>([]);
177
223
 
178
224
  const setSave = (s: SaveState) => { saveStateRef.current = s; setSaveState(s); };
179
- const toggleSplit = () => {
180
- setSplit((v) => {
181
- try { localStorage.setItem('pe-split', v ? '0' : '1'); } catch { /* private mode */ }
182
- return !v;
183
- });
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 */ }
184
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;
185
242
 
186
243
  const editor = useEditor({
187
244
  extensions: [
@@ -193,11 +250,11 @@ export default function PostEditor({ kind = 'post', post, page, initialHTML, uns
193
250
  CodeBlock,
194
251
  Highlight,
195
252
  Placeholder.configure({
196
- // Only the empty document gets the invitation; empty paragraphs in an
197
- // existing doc get the floating quick-insert menu instead (the two
198
- // 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).
199
255
  placeholder: ({ editor: ed, node }) =>
200
- 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',
201
258
  }),
202
259
  CharacterCount,
203
260
  TableKit.configure({ table: { resizable: false } }),
@@ -211,6 +268,7 @@ export default function PostEditor({ kind = 'post', post, page, initialHTML, uns
211
268
  ToggleCard,
212
269
  TocCard,
213
270
  CalendarCard,
271
+ EventCard,
214
272
  InfoCards,
215
273
  FileCard,
216
274
  BookmarkCard,
@@ -265,6 +323,16 @@ export default function PostEditor({ kind = 'post', post, page, initialHTML, uns
265
323
  },
266
324
  },
267
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
+ },
268
336
  immediatelyRender: false,
269
337
  });
270
338
  const editorRef = useRef(editor);
@@ -278,7 +346,51 @@ export default function PostEditor({ kind = 'post', post, page, initialHTML, uns
278
346
  setSave('saving');
279
347
  const bodyJson = JSON.stringify(ed.getJSON());
280
348
  const bodyHtml = ed.getHTML();
281
- 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') {
282
394
  const r = await actions.pages.save({
283
395
  id: docId,
284
396
  title: m.title,
@@ -345,6 +457,7 @@ export default function PostEditor({ kind = 'post', post, page, initialHTML, uns
345
457
  }, [docId, kind]);
346
458
 
347
459
  const queueSave = useCallback(() => {
460
+ editedRef.current = true;
348
461
  setSave('dirty');
349
462
  if (saveTimer.current) clearTimeout(saveTimer.current);
350
463
  saveTimer.current = setTimeout(() => save(), 1200);
@@ -379,14 +492,18 @@ export default function PostEditor({ kind = 'post', post, page, initialHTML, uns
379
492
  showToast({ text: `Snippet “${r.data.snippet.name}” saved — insert it with /` });
380
493
  }, []);
381
494
 
382
- // Author + tag-registry options for the settings sidebar, loaded on first open.
383
- const sidebarLoaded = useRef(false);
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.
384
497
  useEffect(() => {
385
- if (kind !== 'post' || !sidebarOpen || sidebarLoaded.current) return;
386
- sidebarLoaded.current = true;
498
+ if (kind !== 'post') return;
387
499
  actions.users.list({}).then((r) => {
388
500
  if (!r.error) setAuthors(r.data.users.map((u) => ({ id: u.id, name: u.name })));
389
501
  });
502
+ }, [kind]);
503
+ const tagsLoaded = useRef(false);
504
+ useEffect(() => {
505
+ if (!postLike || !sidebarOpen || tagsLoaded.current) return;
506
+ tagsLoaded.current = true;
390
507
  actions.tags.list({}).then((r) => {
391
508
  if (!r.error) setTagSuggestions(r.data.tags.map((t) => t.name));
392
509
  });
@@ -432,8 +549,11 @@ export default function PostEditor({ kind = 'post', post, page, initialHTML, uns
432
549
  if ((e.metaKey || e.ctrlKey) && e.key === 'k') { e.preventDefault(); openLinkEdit(); }
433
550
  if ((e.metaKey || e.ctrlKey) && e.key === '/') { e.preventDefault(); setShortcutsOpen((v) => !v); }
434
551
  };
552
+ // closing the tab / reloading: browsers only allow their own prompt
435
553
  const onLeave = (e: BeforeUnloadEvent) => {
436
- 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();
437
557
  };
438
558
  window.addEventListener('keydown', onKey);
439
559
  window.addEventListener('beforeunload', onLeave);
@@ -458,6 +578,7 @@ export default function PostEditor({ kind = 'post', post, page, initialHTML, uns
458
578
  const toastTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
459
579
 
460
580
  function publicPath(m: Meta) {
581
+ if (kind === 'event') return eventUrl(m.slug);
461
582
  return kind === 'post' ? collectionUrl(m.collection, m.slug) : m.isHome ? localeHomeUrl(m.locale) : `/${m.slug}`;
462
583
  }
463
584
 
@@ -478,7 +599,7 @@ export default function PostEditor({ kind = 'post', post, page, initialHTML, uns
478
599
  if (saveStateRef.current === 'error') return;
479
600
  if (patch.status === 'published') setLivePath(publicPath(next));
480
601
  const messages: Partial<Record<Status, { text: string; href?: string }>> = {
481
- 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) },
482
603
  scheduled: { text: `Scheduled for ${next.scheduledAt ? new Date(next.scheduledAt).toLocaleString() : 'later'}` },
483
604
  draft: { text: 'Back to private draft' },
484
605
  archived: { text: 'Archived — hidden from the site' },
@@ -495,12 +616,15 @@ export default function PostEditor({ kind = 'post', post, page, initialHTML, uns
495
616
  if (saveStateRef.current === 'error') return;
496
617
  const r = kind === 'page'
497
618
  ? await actions.pages.applyUpdate({ id: docId })
619
+ : kind === 'event'
620
+ ? await actions.events.applyUpdate({ id: docId })
498
621
  : await actions.posts.applyUpdate({ id: docId });
499
622
  if (r.error) {
500
623
  setSave('error');
501
624
  return;
502
625
  }
503
626
  setPending(false);
627
+ editedRef.current = false;
504
628
  const href = publicPath(metaRef.current);
505
629
  setLivePath(href);
506
630
  setPreviewTick((t) => t + 1);
@@ -509,6 +633,13 @@ export default function PostEditor({ kind = 'post', post, page, initialHTML, uns
509
633
 
510
634
  async function discardChanges() {
511
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> {
512
643
  setPublishOpen(false);
513
644
  // cancel any queued autosave so it can't re-stage the edits after the
514
645
  // discard, and clear the dirty flag so the reload isn't blocked
@@ -516,22 +647,135 @@ export default function PostEditor({ kind = 'post', post, page, initialHTML, uns
516
647
  setSave('saved');
517
648
  const r = kind === 'page'
518
649
  ? await actions.pages.discardChanges({ id: docId })
650
+ : kind === 'event'
651
+ ? await actions.events.discardChanges({ id: docId })
519
652
  : await actions.posts.discardChanges({ id: docId });
520
653
  if (r.error) {
521
654
  setSave('error');
522
- return;
655
+ return false;
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);
683
+ }
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();
523
707
  }
524
- location.reload(); // rehydrate the editor from the live version
708
+ setLeaveBusy('');
709
+ if (saveStateRef.current === 'error') return; // stay: the dialog shows the failure
710
+ goAway(to);
525
711
  }
526
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
+
527
739
  const words = editor?.storage.characterCount?.words() ?? 0;
528
740
  const saveLabel =
529
741
  saveState === 'saved' ? (pending ? 'Saved — not live yet' : 'Saved') :
530
742
  saveState === 'saving' ? 'Saving…' :
531
- 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';
532
744
 
533
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
+ };
534
777
  const liveHref = livePath || publicPath(meta);
778
+ const primaryAuthor = authors.find((a) => a.id === meta.authorIds[0])?.name ?? '';
535
779
 
536
780
  return (
537
781
  <div className={`pe-shell ${sidebarOpen ? 'has-sidebar' : ''} ${split ? 'has-split' : ''}`}>
@@ -544,23 +788,27 @@ export default function PostEditor({ kind = 'post', post, page, initialHTML, uns
544
788
  )}
545
789
  <header className="pe-topbar">
546
790
  <div className="pe-topbar-left">
547
- <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>
548
792
  <span className={`pe-savestate ${saveState}`}>
549
- {STATUS_LABEL[meta.status]}{pending && <em className="pe-edited-dot"> · edited</em>} — {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>
550
795
  </span>
551
796
  </div>
552
- <div className="pe-topbar-right">
553
- <button
554
- type="button"
555
- className={`pe-btn ghost sm ${split ? 'is-active' : ''}`}
556
- title="Side-by-side preview"
557
- onClick={toggleSplit}
558
- >
559
- <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8">
560
- <rect x="3" y="4" width="18" height="16" rx="2" /><path d="M12 4v16" />
561
- </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
801
+ </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
562
809
  </button>
563
- <button type="button" className="pe-btn ghost sm" onClick={() => setPreviewOpen(true)}>Preview</button>
810
+ </div>
811
+ <div className="pe-topbar-right">
564
812
  <div className="pe-publish-wrap">
565
813
  <button type="button" className="pe-btn primary sm" onClick={() => setPublishOpen((v) => !v)}>
566
814
  {publishLabel} ▾
@@ -580,7 +828,7 @@ export default function PostEditor({ kind = 'post', post, page, initialHTML, uns
580
828
  >
581
829
  Publish now
582
830
  </button>
583
- {kind === 'post' && (
831
+ {postLike && (
584
832
  <div className="pe-pop-schedule">
585
833
  <span className="pe-pop-label">Or schedule for later</span>
586
834
  <div className="pe-pop-schedrow">
@@ -690,21 +938,23 @@ export default function PostEditor({ kind = 'post', post, page, initialHTML, uns
690
938
  </div>
691
939
  <button
692
940
  type="button"
693
- 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' : ''}`}
694
942
  title={kind === 'page' ? 'Page settings' : `${col.singular} settings`}
943
+ aria-label={kind === 'page' ? 'Page settings' : `${col.singular} settings`}
695
944
  onClick={() => setSidebarOpen((v) => !v)}
696
945
  >
697
- <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8">
698
- <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" />
699
948
  </svg>
700
949
  </button>
701
950
  </div>
702
951
  </header>
703
952
 
704
953
  <div className="pe-body">
954
+ <section className="pe-editor-pane">
705
955
  <main className="pe-main">
706
956
  <div className="pe-column">
707
- {kind === 'post' && (meta.cover ? (
957
+ {postLike && (meta.cover ? (
708
958
  <figure className="pe-feature">
709
959
  <img src={meta.cover} alt={meta.coverAlt} />
710
960
  <div className="pe-feature-tools">
@@ -713,21 +963,32 @@ export default function PostEditor({ kind = 'post', post, page, initialHTML, uns
713
963
  </div>
714
964
  <figcaption className="pe-feature-label">Feature image</figcaption>
715
965
  </figure>
716
- ) : (
717
- <button type="button" className="pe-add-feature" onClick={() => setPicker('feature')}>
718
- <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6">
719
- <rect x="3" y="5" width="18" height="14" rx="2" />
720
- <circle cx="8.5" cy="10" r="1.5" />
721
- <path d="M21 15.5 16 11l-6.5 6.5M3 17l4-4 3 3" />
722
- </svg>
723
- <span>Add feature image</span>
724
- <em>Shown at the top of the {noun} and in link previews</em>
725
- </button>
726
- ))}
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
+ )}
727
984
 
728
985
  <textarea
729
986
  className="pe-title"
730
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
+ }}
731
992
  placeholder={kind === 'page' ? 'Page title' : `${col.singular} title`}
732
993
  value={meta.title}
733
994
  onChange={(e) => {
@@ -740,6 +1001,60 @@ export default function PostEditor({ kind = 'post', post, page, initialHTML, uns
740
1001
  }}
741
1002
  />
742
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
+
743
1058
  {editor && (
744
1059
  <BubbleMenu editor={editor} className="pe-bubble" options={{ placement: 'top' }}>
745
1060
  <button type="button" className={editor.isActive('bold') ? 'is-active' : ''} onClick={() => editor.chain().focus().toggleBold().run()}><b>B</b></button>
@@ -800,10 +1115,10 @@ export default function PostEditor({ kind = 'post', post, page, initialHTML, uns
800
1115
  <FloatingMenu
801
1116
  editor={editor}
802
1117
  className="pe-floating"
803
- options={{ placement: 'left' }}
1118
+ options={{ placement: 'left', offset: 14 }}
804
1119
  shouldShow={({ editor: ed, view, state }) => {
805
1120
  // empty paragraph in a non-empty doc — the empty doc shows the
806
- // placeholder instead, so the two never overlap
1121
+ // long placeholder instead
807
1122
  const { $anchor, empty } = state.selection;
808
1123
  return (
809
1124
  view.hasFocus() && empty && !ed.isEmpty && $anchor.depth === 1 &&
@@ -811,24 +1126,15 @@ export default function PostEditor({ kind = 'post', post, page, initialHTML, uns
811
1126
  );
812
1127
  }}
813
1128
  >
814
- {([
815
- ['Image', () => { pickerRangeRef.current = null; setPicker('editor'); }],
816
- ['Gallery', () => editor.chain().focus().insertContent({ type: 'galleryCard' }).run()],
817
- ['Embed', () => editor.chain().focus().insertContent({ type: 'embedCard' }).run()],
818
- ['Bookmark', () => editor.chain().focus().insertContent({ type: 'bookmarkCard' }).run()],
819
- ['File', () => editor.chain().focus().insertContent({ type: 'fileCard' }).run()],
820
- ['Button', () => editor.chain().focus().insertContent({ type: 'buttonCard' }).run()],
821
- ['Callout', () => editor.chain().focus().insertContent({ type: 'callout' }).run()],
822
- ['Toggle', () => editor.chain().focus().insertContent({ type: 'toggleCard', content: [{ type: 'paragraph' }] }).run()],
823
- ['Calendar', () => editor.chain().focus().insertContent({ type: 'calendarCard' }).run()],
824
- ['Info cards', () => editor.chain().focus().insertContent({ type: 'infoCards' }).run()],
825
- ['Table', () => editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run()],
826
- ['HTML', () => editor.chain().focus().insertContent({ type: 'htmlCard' }).run()],
827
- ['— Divider', () => editor.chain().focus().setHorizontalRule().run()],
828
- ] as [string, () => void][]).map(([label, run]) => (
829
- <button key={label} type="button" onMouseDown={(e) => { e.preventDefault(); run(); }}>{label}</button>
830
- ))}
831
- <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>
832
1138
  </FloatingMenu>
833
1139
  )}
834
1140
 
@@ -881,10 +1187,6 @@ export default function PostEditor({ kind = 'post', post, page, initialHTML, uns
881
1187
  )}
882
1188
 
883
1189
  <EditorContent editor={editor} />
884
- <div className="pe-wordcount">
885
- {words.toLocaleString()} words{words > 0 && ` · ${Math.max(1, Math.round(words / 220))} min read`}
886
- <button type="button" className="pe-shortcuts-btn" title="Keyboard shortcuts (⌘/)" onClick={() => setShortcutsOpen(true)}>⌘/</button>
887
- </div>
888
1190
 
889
1191
  {shortcutsOpen && (
890
1192
  <div className="pe-shortcuts-overlay" onClick={() => setShortcutsOpen(false)}>
@@ -930,196 +1232,263 @@ export default function PostEditor({ kind = 'post', post, page, initialHTML, uns
930
1232
  )}
931
1233
  </div>
932
1234
  </main>
1235
+ {editor && <FormatDock editor={editor} words={words} onLink={openLinkEdit} onShortcuts={() => setShortcutsOpen(true)} />}
1236
+ </section>
933
1237
 
934
- {split && (
935
- <aside className="pe-splitpane">
936
- <div className="pe-split-bar">
937
- <span className="pe-split-label">Live preview</span>
938
- <div className="pe-split-actions">
939
- <button type="button" className="pe-btn ghost sm" title="Refresh" onClick={() => setPreviewTick((t) => t + 1)}>↻</button>
940
- <a className="pe-btn ghost sm" href={previewPath} target="_blank" rel="noopener" title="Open in tab">↗</a>
941
- <button type="button" className="pe-btn ghost sm" title="Close preview" onClick={toggleSplit}>✕</button>
942
- </div>
943
- </div>
944
- <iframe key={previewTick} src={previewPath} title="Live preview" />
945
- </aside>
946
- )}
1238
+ {split && <LivePane src={previewPath} path={liveHref} tick={previewTick} focusText={focusText} />}
947
1239
  </div>
948
1240
 
949
1241
  {sidebarOpen && (
950
- <aside className="pe-sidebar">
1242
+ <aside className="pe-sidebar" aria-label={kind === 'page' ? 'Page settings' : `${col.singular} settings`}>
951
1243
  <div className="pe-sidebar-head">
952
1244
  <span>{kind === 'page' ? 'Page settings' : `${col.singular} settings`}</span>
953
- <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>
954
1248
  </div>
955
1249
 
956
- <label className="pe-field">
957
- <span>{kind === 'page' ? 'Page URL' : `${col.singular} URL`}</span>
958
- <div className="pe-slug">
959
- <span className="pe-slug-prefix">{kind === 'page' ? '/' : `${col.base}/`}</span>
960
- <input type="text" value={meta.slug} onChange={(e) => setMeta({ slug: e.target.value })} />
961
- </div>
962
- </label>
963
-
964
- {kind === 'post' && Object.keys(CONTENT.collections).length > 1 && (
1250
+ <section className="pe-sb-section">
965
1251
  <label className="pe-field">
966
- <span>Collection</span>
967
- <select value={meta.collection} onChange={(e) => setMeta({ collection: e.target.value })}>
968
- {Object.entries(CONTENT.collections).map(([k, c]) => (
969
- <option key={k} value={k}>{c.plural}</option>
970
- ))}
971
- </select>
972
- {meta.status === 'published' && (
973
- <em className="hint">Moving a published {noun} leaves a redirect at its old URL.</em>
974
- )}
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>
975
1257
  </label>
976
- )}
977
1258
 
978
- {kind === 'page' && I18N.enabled && (
979
- <label className="pe-field">
980
- <span>Language</span>
981
- <select
982
- value={meta.locale || I18N.defaultLocale}
983
- onChange={(e) => setMeta({ locale: e.target.value })}
984
- >
985
- {I18N.locales.map((l) => (
986
- <option key={l} value={l}>{l.toUpperCase()}{l === I18N.defaultLocale ? ' — default' : ''}</option>
987
- ))}
988
- </select>
989
- </label>
990
- )}
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
+ )}
991
1280
 
992
- {kind === 'page' && (
993
- <label className="pe-field pe-check">
994
- <input
995
- type="checkbox"
996
- checked={meta.isHome}
997
- onChange={(e) => setMeta({ isHome: e.target.checked })}
998
- />
999
- {I18N.enabled ? (
1000
- <span>Serve this page at <code>{localeHomeUrl(meta.locale)}</code> ({(meta.locale || I18N.defaultLocale).toUpperCase()} home page)</span>
1001
- ) : (
1002
- <span>Serve this page at <code>/</code> (home page)</span>
1003
- )}
1004
- </label>
1005
- )}
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
+ )}
1006
1287
 
1007
- {templates.length > 0 && (
1008
- <label className="pe-field">
1009
- <span>Template <em className="pe-check-hint">from the active theme</em></span>
1010
- <select value={meta.template} onChange={(e) => setMeta({ template: e.target.value })}>
1011
- <option value="">Default</option>
1012
- {templates.map((t) => (
1013
- <option key={t} value={t}>{t}</option>
1014
- ))}
1015
- </select>
1016
- </label>
1017
- )}
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
+ )}
1018
1316
 
1019
- {kind === 'post' && (
1020
- <>
1317
+ {kind === 'post' && Object.keys(CONTENT.collections).length > 1 && (
1021
1318
  <label className="pe-field">
1022
- <span>Publish date</span>
1023
- <input
1024
- type="datetime-local"
1025
- value={tsToLocal(meta.publishedAt)}
1026
- onChange={(e) => setMeta({ publishedAt: localToTs(e.target.value) })}
1027
- />
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
+ )}
1028
1328
  </label>
1329
+ )}
1029
1330
 
1331
+ {kind === 'page' && I18N.enabled && (
1030
1332
  <label className="pe-field">
1031
- <span>Tags</span>
1032
- <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>
1033
1339
  </label>
1340
+ )}
1034
1341
 
1035
- <label className="pe-field pe-check">
1036
- <input
1037
- type="checkbox"
1038
- checked={meta.featured}
1039
- onChange={(e) => setMeta({ featured: e.target.checked })}
1040
- />
1041
- <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>
1351
+ </label>
1352
+ )}
1353
+
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 })} />
1042
1358
  </label>
1359
+ )}
1043
1360
 
1044
- {authors.length > 0 && (
1045
- <div className="pe-field">
1046
- <span>Authors <em className="pe-check-hint">first is the primary byline</em></span>
1047
- <div className="pe-authors">
1048
- {authors.map((a) => {
1049
- const checked = meta.authorIds.includes(a.id);
1050
- return (
1051
- <label key={a.id} className="pe-check pe-author-check">
1052
- <input
1053
- type="checkbox"
1054
- checked={checked}
1055
- onChange={(e) =>
1056
- setMeta({
1057
- authorIds: e.target.checked
1058
- ? [...meta.authorIds, a.id]
1059
- : meta.authorIds.filter((id) => id !== a.id),
1060
- })
1061
- }
1062
- />
1063
- <span>{a.name}{checked && meta.authorIds[0] === a.id && meta.authorIds.length > 1 ? ' · primary' : ''}</span>
1064
- </label>
1065
- );
1066
- })}
1067
- </div>
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>}
1068
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>
1069
1400
  )}
1070
-
1071
1401
  <label className="pe-field">
1072
- <span>Excerpt <em>{meta.excerpt.length}/300</em></span>
1402
+ <span>Excerpt <em>{meta.excerpt.length} / 300</em></span>
1073
1403
  <textarea
1074
1404
  rows={3}
1075
1405
  maxLength={300}
1076
- placeholder="A short teaser shown on the blog index and in link previews"
1406
+ placeholder="A short teaser for post cards and link previews"
1077
1407
  value={meta.excerpt}
1078
1408
  onChange={(e) => setMeta({ excerpt: e.target.value })}
1079
1409
  />
1080
1410
  </label>
1081
-
1082
- <div className="pe-field">
1083
- <span>Feature image alt</span>
1084
- <input
1085
- type="text"
1086
- placeholder="Describe the feature image"
1087
- value={meta.coverAlt}
1088
- onChange={(e) => setMeta({ coverAlt: e.target.value })}
1089
- />
1090
- </div>
1091
- </>
1411
+ </section>
1092
1412
  )}
1093
1413
 
1094
- <details className="pe-seo">
1095
- <summary>Search engine optimization</summary>
1096
- <label className="pe-field">
1097
- <span>Meta title <em>{(meta.seoTitle || meta.title).length}/60</em></span>
1098
- <input type="text" placeholder={meta.title} value={meta.seoTitle} onChange={(e) => setMeta({ seoTitle: e.target.value })} />
1099
- </label>
1100
- <label className="pe-field">
1101
- <span>Meta description <em>{(meta.seoDesc || meta.excerpt).length}/160</em></span>
1102
- <textarea rows={3} placeholder={meta.excerpt} value={meta.seoDesc} onChange={(e) => setMeta({ seoDesc: e.target.value })} />
1103
- </label>
1104
- </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>
1105
1423
 
1106
- <div className="pe-danger">
1424
+ <section className="pe-sb-section pe-sb-danger">
1107
1425
  <button
1108
1426
  type="button"
1109
1427
  className="pe-btn danger sm"
1110
1428
  onClick={async () => {
1111
1429
  if (!window.confirm(`Delete this ${noun} permanently? This cannot be undone.`)) return;
1112
1430
  if (kind === 'page') await actions.pages.delete({ id: docId });
1431
+ else if (kind === 'event') await actions.events.delete({ id: docId });
1113
1432
  else await actions.posts.delete({ id: docId });
1433
+ leavingRef.current = true;
1114
1434
  location.href = listHref;
1115
1435
  }}
1116
1436
  >
1117
1437
  Delete {noun}
1118
1438
  </button>
1119
- </div>
1439
+ </section>
1120
1440
  </aside>
1121
1441
  )}
1122
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
+
1123
1492
  <ImagePicker
1124
1493
  open={picker !== 'closed'}
1125
1494
  unsplashEnabled={unsplashEnabled}
@@ -1130,8 +1499,21 @@ export default function PostEditor({ kind = 'post', post, page, initialHTML, uns
1130
1499
  open={previewOpen}
1131
1500
  url={previewPath}
1132
1501
  shareUrl={sharePath}
1502
+ meta={previewMeta}
1503
+ site={site}
1504
+ isPost={postLike}
1505
+ startView={previewView}
1506
+ onChange={setMeta}
1507
+ onPickCover={() => setPicker('feature')}
1133
1508
  onClose={() => setPreviewOpen(false)}
1134
1509
  />
1510
+ {editor && (
1511
+ <MobileToolbar
1512
+ editor={editor}
1513
+ onLink={openLinkEdit}
1514
+ onImage={() => setPicker('editor')}
1515
+ />
1516
+ )}
1135
1517
  </div>
1136
1518
  );
1137
1519
  }