@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.
- package/CHANGELOG.md +106 -0
- package/cli/common.mjs +35 -0
- package/cli/legacy.mjs +317 -0
- package/cli/main.mjs +66 -0
- package/cli/palette.mjs +4 -0
- package/cli/preview.mjs +50 -0
- package/cli/remote.mjs +55 -0
- package/cli/themes.mjs +127 -0
- package/cli.mjs +2 -315
- package/deploy/Caddyfile +8 -0
- package/deploy/Dockerfile +34 -0
- package/deploy/compose.https.yml +21 -0
- package/deploy/compose.yml +46 -0
- package/docs/theme-authoring.md +103 -0
- package/email-runner.mjs +32 -0
- package/integration.mjs +193 -2
- package/ops-check.mjs +27 -0
- package/package.json +35 -12
- package/src/actions/index.ts +744 -38
- package/src/components/AiCleaner.tsx +291 -0
- package/src/components/AiSettings.tsx +575 -0
- package/src/components/CommandPalette.tsx +200 -0
- package/src/components/ContentList.astro +246 -0
- package/src/components/DesignAccess.tsx +182 -0
- package/src/components/DesignManager.tsx +455 -47
- package/src/components/EmailSettings.tsx +521 -0
- package/src/components/EventPage.astro +178 -0
- package/src/components/LegalPage.astro +27 -0
- package/src/components/LinksManager.tsx +216 -97
- package/src/components/NavManager.tsx +83 -38
- package/src/components/ThemeScript.astro +50 -0
- package/src/components/ThemeSections.astro +57 -0
- package/src/components/ThemeSectionsEditor.tsx +513 -0
- package/src/components/booking/BookingFlow.astro +150 -0
- package/src/components/booking/HoursEditor.astro +63 -0
- package/src/components/builder/PageBuilder.tsx +10 -2
- package/src/components/editor/EventPanel.tsx +401 -0
- package/src/components/editor/FormatDock.tsx +81 -0
- package/src/components/editor/LivePane.tsx +122 -0
- package/src/components/editor/MobileToolbar.tsx +93 -0
- package/src/components/editor/PostEditor.tsx +826 -271
- package/src/components/editor/PreviewOverlay.tsx +242 -43
- package/src/components/editor/extensions/calendar.tsx +51 -7
- package/src/components/editor/extensions/cards.tsx +17 -1
- package/src/components/editor/extensions/codeBlock.tsx +1 -1
- package/src/components/editor/extensions/events.tsx +153 -0
- package/src/components/editor/extensions/infoCards.tsx +16 -1
- package/src/components/editor/extensions/mediaCards.tsx +36 -2
- package/src/components/editor/extensions/slash.tsx +77 -9
- package/src/components/editor/extensions/toc.tsx +89 -0
- package/src/components/editor/extensions/toggle.tsx +70 -0
- package/src/components/site/Pager.astro +24 -0
- package/src/components/site/PostCard.astro +64 -0
- package/src/components/site/TagLabel.astro +11 -0
- package/src/layouts/Admin.astro +289 -49
- package/src/layouts/Site.astro +331 -57
- package/src/lib/agent-api.ts +13 -0
- package/src/lib/agent-discovery.ts +40 -0
- package/src/lib/agent.ts +114 -0
- package/src/lib/ai-config.ts +58 -0
- package/src/lib/ai-schema.ts +22 -0
- package/src/lib/ai.ts +90 -0
- package/src/lib/analytics.ts +121 -0
- package/src/lib/api-keys.ts +65 -0
- package/src/lib/api.ts +52 -0
- package/src/lib/auth.ts +25 -5
- package/src/lib/backup/content.ts +36 -0
- package/src/lib/backup/unzip.ts +12 -1
- package/src/lib/backup/zip.ts +1 -1
- package/src/lib/booking/agent.ts +108 -0
- package/src/lib/booking/availability.ts +221 -0
- package/src/lib/booking/emails.ts +115 -0
- package/src/lib/booking/forms.ts +77 -0
- package/src/lib/booking/service.ts +277 -0
- package/src/lib/booking/view.ts +54 -0
- package/src/lib/calendar/agent-schema.ts +48 -0
- package/src/lib/calendar/agent.ts +136 -0
- package/src/lib/calendar/api.ts +81 -0
- package/src/lib/calendar/cards.ts +102 -0
- package/src/lib/calendar/config.ts +52 -0
- package/src/lib/calendar/data.ts +130 -0
- package/src/lib/calendar/events.ts +261 -0
- package/src/lib/calendar/feeds.ts +43 -0
- package/src/lib/calendar/hooks.ts +38 -0
- package/src/lib/calendar/http.ts +30 -0
- package/src/lib/calendar/ics-write.ts +120 -0
- package/src/lib/calendar/ics.ts +15 -5
- package/src/lib/calendar/import.ts +203 -0
- package/src/lib/calendar/render.ts +120 -37
- package/src/lib/calendar/rrule.ts +149 -0
- package/src/lib/calendar/seo.ts +48 -0
- package/src/lib/design-service.ts +58 -0
- package/src/lib/designDiff.ts +145 -0
- package/src/lib/designPreview.ts +112 -0
- package/src/lib/documents/cleanup.ts +31 -0
- package/src/lib/documents/extract.ts +154 -0
- package/src/lib/documents/vendor.d.ts +4 -0
- package/src/lib/drafts.ts +131 -0
- package/src/lib/dynamicBlocks.ts +79 -7
- package/src/lib/email/accounts.ts +55 -0
- package/src/lib/email/config.ts +86 -0
- package/src/lib/email/migration.ts +24 -0
- package/src/lib/email/providers.ts +89 -0
- package/src/lib/email/service.ts +78 -0
- package/src/lib/email/store.ts +167 -0
- package/src/lib/email/templates.ts +18 -0
- package/src/lib/email/types.ts +32 -0
- package/src/lib/email/webhooks.ts +87 -0
- package/src/lib/email.ts +15 -0
- package/src/lib/env.ts +1 -1
- package/src/lib/legal/index.ts +25 -0
- package/src/lib/mobile/bookings.ts +73 -0
- package/src/lib/mobile/calendar.ts +85 -0
- package/src/lib/mobile/content.ts +85 -0
- package/src/lib/mobile/http.ts +63 -0
- package/src/lib/mobile/route.ts +20 -0
- package/src/lib/ops.ts +8 -0
- package/src/lib/preview.ts +21 -1
- package/src/lib/repo/migrations.ts +254 -0
- package/src/lib/repo/sql.ts +956 -46
- package/src/lib/repo/types.ts +140 -3
- package/src/lib/scheduler.ts +25 -0
- package/src/lib/schema.ts +362 -0
- package/src/lib/search-i18n.ts +3 -0
- package/src/lib/socialIcons.ts +32 -0
- package/src/lib/tagColor.ts +11 -0
- package/src/lib/theme.ts +59 -40
- package/src/lib/themeTokens.ts +174 -0
- package/src/lib/umami.ts +108 -0
- package/src/lib/webhooks.ts +139 -0
- package/src/middleware.ts +36 -3
- package/src/pages/404.astro +23 -0
- package/src/pages/[...slug].astro +10 -5
- package/src/pages/account/[action].astro +53 -0
- package/src/pages/admin/ai.astro +12 -0
- package/src/pages/admin/c/[collection]/[id].astro +14 -4
- package/src/pages/admin/c/[collection]/index.astro +127 -91
- package/src/pages/admin/calendar/bookings.astro +400 -0
- package/src/pages/admin/calendar/calendars.astro +265 -0
- package/src/pages/admin/calendar/events/[id].astro +56 -0
- package/src/pages/admin/calendar/events/index.astro +408 -0
- package/src/pages/admin/calendar/index.ts +4 -0
- package/src/pages/admin/calendar/integrations.astro +221 -0
- package/src/pages/admin/calendar/resources/[id].astro +180 -0
- package/src/pages/admin/calendar/resources/index.astro +106 -0
- package/src/pages/admin/calendar/resources/link.astro +196 -0
- package/src/pages/admin/design.astro +16 -4
- package/src/pages/admin/email.astro +13 -0
- package/src/pages/admin/index.astro +237 -87
- package/src/pages/admin/links.astro +3 -3
- package/src/pages/admin/login.astro +14 -10
- package/src/pages/admin/navigation.astro +8 -2
- package/src/pages/admin/pages/[id].astro +14 -3
- package/src/pages/admin/pages/index.astro +41 -47
- package/src/pages/admin/settings.astro +475 -276
- package/src/pages/admin/tags.astro +4 -4
- package/src/pages/admin/users.astro +38 -28
- package/src/pages/api/agent/docs.ts +33 -0
- package/src/pages/api/agent/index.ts +21 -0
- package/src/pages/api/ai/clean.ts +13 -0
- package/src/pages/api/ai/draft.ts +14 -0
- package/src/pages/api/ai/settings.ts +53 -0
- package/src/pages/api/ai/test.ts +12 -0
- package/src/pages/api/backup.ts +8 -21
- package/src/pages/api/calendar/availability.ts +35 -0
- package/src/pages/api/calendar/bookings/[id].ts +49 -0
- package/src/pages/api/calendar/bookings/index.ts +73 -0
- package/src/pages/api/calendar/calendars.ts +35 -0
- package/src/pages/api/calendar/docs.ts +64 -0
- package/src/pages/api/calendar/events/[id].ts +63 -0
- package/src/pages/api/calendar/events/index.ts +112 -0
- package/src/pages/api/calendar/resources.ts +34 -0
- package/src/pages/api/design/capabilities.ts +10 -0
- package/src/pages/api/design/index.ts +58 -0
- package/src/pages/api/design/keys.ts +24 -0
- package/src/pages/api/design/media.ts +32 -0
- package/src/pages/api/email/run.ts +11 -0
- package/src/pages/api/email/settings.ts +61 -0
- package/src/pages/api/email/webhooks/[provider].ts +4 -0
- package/src/pages/api/mcp.ts +59 -0
- package/src/pages/api/media/upload.ts +2 -2
- package/src/pages/api/ops/status.ts +39 -0
- package/src/pages/api/restore.ts +27 -37
- package/src/pages/api/search.ts +18 -4
- package/src/pages/api/v1/availability.ts +7 -0
- package/src/pages/api/v1/bookings.ts +17 -0
- package/src/pages/api/v1/calendars.ts +7 -0
- package/src/pages/api/v1/content/[id].ts +7 -0
- package/src/pages/api/v1/content/index.ts +7 -0
- package/src/pages/api/v1/events.ts +7 -0
- package/src/pages/api/v1/resources.ts +7 -0
- package/src/pages/author/[slug].astro +43 -35
- package/src/pages/blog/[slug].astro +140 -66
- package/src/pages/blog/index.astro +95 -30
- package/src/pages/blog/tag/[slug].astro +45 -40
- package/src/pages/book/[resource]/[link].astro +70 -0
- package/src/pages/book/[resource]/index.astro +63 -0
- package/src/pages/book/[resource].ics.ts +38 -0
- package/src/pages/book/index.astro +47 -0
- package/src/pages/book/manage/[token].astro +108 -0
- package/src/pages/calendar/[slug].ics.ts +27 -0
- package/src/pages/calendar/feed.ics.ts +15 -0
- package/src/pages/calendar/index.astro +191 -0
- package/src/pages/embed/book/[resource]/[link].astro +33 -0
- package/src/pages/embed/calendar/[slug].astro +43 -0
- package/src/pages/events/[slug].astro +74 -0
- package/src/pages/events/[slug].ics.ts +25 -0
- package/src/pages/index.astro +9 -3
- package/src/pages/legal/privacy.astro +4 -0
- package/src/pages/legal/terms.astro +4 -0
- package/src/pages/links.astro +33 -7
- package/src/pages/llms.txt.ts +4 -0
- package/src/pages/preview/[type]/[id].astro +34 -8
- package/src/pages/search.astro +40 -20
- package/src/pages/sitemap.xml.ts +16 -1
- package/src/styles/admin.css +928 -105
- package/src/styles/cards.css +101 -60
- package/src/styles/editor.css +532 -102
- package/src/styles/email-settings.css +117 -0
- package/src/styles/sections.css +12 -0
- package/src/styles/site.css +938 -278
- package/src/styles/theme-editor.css +135 -0
- package/src/theme-sdk/contract.d.mts +619 -0
- package/src/theme-sdk/contract.mjs +80 -0
- package/src/theme-sdk/index.ts +15 -0
- package/src/theme-sdk/tokens.d.mts +148 -0
- package/src/theme-sdk/tokens.mjs +52 -0
- package/src/themes/editorial/templates/home.astro +4 -4
- package/src/themes/editorial/templates/post-minimal.astro +2 -2
- package/src/themes/editorial/theme.css +22 -37
- package/src/themes/editorial/theme.json +23 -3
- package/src/themes/field/theme.json +26 -9
- package/src/themes/ink/theme.json +30 -12
- package/src/themes/night/theme.css +4 -7
- package/src/themes/night/theme.json +27 -10
- package/src/virtual.d.ts +14 -0
- 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
|
-
|
|
116
|
-
|
|
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 === '
|
|
137
|
-
|
|
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
|
-
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
//
|
|
178
|
-
//
|
|
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
|
-
|
|
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;
|
|
224
|
-
//
|
|
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 === '
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
334
|
-
|
|
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'
|
|
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 (
|
|
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
|
-
|
|
408
|
-
|
|
409
|
-
|
|
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}
|
|
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
|
-
|
|
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-
|
|
438
|
-
<button
|
|
439
|
-
|
|
440
|
-
|
|
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-
|
|
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
|
-
{
|
|
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>
|
|
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=
|
|
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>
|
|
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
|
-
|
|
517
|
-
|
|
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="
|
|
556
|
-
<
|
|
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
|
-
{
|
|
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
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
<
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
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
|
|
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
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
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
|
-
<
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
e.preventDefault();
|
|
687
|
-
|
|
688
|
-
if (
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
>
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
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
|
-
['
|
|
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)}
|
|
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
|
-
<
|
|
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>
|
|
794
|
-
<
|
|
795
|
-
{
|
|
796
|
-
|
|
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
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
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
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
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
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
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
|
-
|
|
847
|
-
<>
|
|
1317
|
+
{kind === 'post' && Object.keys(CONTENT.collections).length > 1 && (
|
|
848
1318
|
<label className="pe-field">
|
|
849
|
-
<span>
|
|
850
|
-
<
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
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>
|
|
859
|
-
<
|
|
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
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
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
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
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 & 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
|
|
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
|
-
<
|
|
922
|
-
<
|
|
923
|
-
<
|
|
924
|
-
<
|
|
925
|
-
<
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
</label>
|
|
931
|
-
</details>
|
|
1414
|
+
<section className="pe-sb-section">
|
|
1415
|
+
<h3 className="pe-sb-title">Search & 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 & social preview →</em>
|
|
1421
|
+
</button>
|
|
1422
|
+
</section>
|
|
932
1423
|
|
|
933
|
-
<
|
|
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
|
-
</
|
|
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
|
}
|