@acms-subir/core 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +78 -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 -369
- 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 +182 -2
- package/ops-check.mjs +27 -0
- package/package.json +27 -5
- package/src/actions/index.ts +575 -47
- 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 +418 -46
- 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 +114 -57
- package/src/components/NavManager.tsx +42 -43
- 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 +605 -223
- package/src/components/editor/PreviewOverlay.tsx +242 -43
- package/src/components/editor/extensions/calendar.tsx +51 -7
- package/src/components/editor/extensions/events.tsx +153 -0
- package/src/components/editor/extensions/slash.tsx +7 -1
- 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 +324 -59
- 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/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 +110 -12
- 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 +60 -6
- package/src/lib/dynamicBlocks.ts +40 -8
- 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 +217 -0
- package/src/lib/repo/sql.ts +853 -60
- package/src/lib/repo/types.ts +122 -5
- package/src/lib/scheduler.ts +25 -0
- package/src/lib/schema.ts +309 -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 +8 -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 +4 -1
- package/src/pages/admin/c/[collection]/index.astro +127 -92
- 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 +10 -3
- package/src/pages/admin/email.astro +13 -0
- package/src/pages/admin/index.astro +232 -85
- 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 +4 -1
- package/src/pages/admin/pages/index.astro +41 -48
- package/src/pages/admin/settings.astro +418 -367
- 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 +137 -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 +6 -3
- package/src/pages/legal/privacy.astro +4 -0
- package/src/pages/legal/terms.astro +4 -0
- package/src/pages/links.astro +23 -3
- package/src/pages/llms.txt.ts +4 -0
- package/src/pages/preview/[type]/[id].astro +27 -6
- package/src/pages/search.astro +40 -20
- package/src/pages/sitemap.xml.ts +16 -1
- package/src/styles/admin.css +924 -105
- package/src/styles/cards.css +92 -74
- package/src/styles/editor.css +492 -114
- package/src/styles/email-settings.css +117 -0
- package/src/styles/sections.css +12 -0
- package/src/styles/site.css +938 -281
- 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,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
|
-
|
|
126
|
-
|
|
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 === '
|
|
150
|
-
|
|
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
|
-
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
//
|
|
197
|
-
//
|
|
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
|
-
|
|
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 === '
|
|
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
|
-
//
|
|
383
|
-
|
|
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'
|
|
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 (
|
|
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
|
-
|
|
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}
|
|
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
|
-
|
|
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-
|
|
553
|
-
<button
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
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
|
-
|
|
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
|
-
{
|
|
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="
|
|
698
|
-
<
|
|
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
|
-
{
|
|
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
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
<
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
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
|
|
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
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
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)}
|
|
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
|
-
<
|
|
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>
|
|
967
|
-
<
|
|
968
|
-
{
|
|
969
|
-
|
|
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
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
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
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
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
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
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
|
-
|
|
1020
|
-
<>
|
|
1317
|
+
{kind === 'post' && Object.keys(CONTENT.collections).length > 1 && (
|
|
1021
1318
|
<label className="pe-field">
|
|
1022
|
-
<span>
|
|
1023
|
-
<
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
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>
|
|
1032
|
-
<
|
|
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
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
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
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
<
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
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>}
|
|
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
|
|
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
|
-
<
|
|
1095
|
-
<
|
|
1096
|
-
<
|
|
1097
|
-
<
|
|
1098
|
-
<
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
</label>
|
|
1104
|
-
</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>
|
|
1105
1423
|
|
|
1106
|
-
<
|
|
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
|
-
</
|
|
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
|
}
|