@acms-subir/core 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cli.mjs +75 -0
- package/integration.mjs +286 -0
- package/package.json +89 -0
- package/src/actions/index.ts +502 -0
- package/src/components/DesignManager.tsx +542 -0
- package/src/components/LinksManager.tsx +694 -0
- package/src/components/Modal.astro +65 -0
- package/src/components/NavManager.tsx +168 -0
- package/src/components/WidgetRenderer.astro +84 -0
- package/src/components/builder/PageBuilder.tsx +575 -0
- package/src/components/editor/ImagePicker.tsx +215 -0
- package/src/components/editor/PostEditor.tsx +1011 -0
- package/src/components/editor/PreviewOverlay.tsx +77 -0
- package/src/components/editor/extensions/calendar.tsx +230 -0
- package/src/components/editor/extensions/cards.tsx +488 -0
- package/src/components/editor/extensions/codeBlock.tsx +70 -0
- package/src/components/editor/extensions/infoCards.tsx +270 -0
- package/src/components/editor/extensions/mediaCards.tsx +376 -0
- package/src/components/editor/extensions/postList.tsx +130 -0
- package/src/components/editor/extensions/slash.tsx +250 -0
- package/src/components/editor/extensions/tableTyping.ts +63 -0
- package/src/components/editor/imaging.ts +146 -0
- package/src/components/media/CropModal.tsx +229 -0
- package/src/components/media/MediaLibrary.tsx +295 -0
- package/src/layouts/Admin.astro +119 -0
- package/src/layouts/Site.astro +218 -0
- package/src/lib/auth.ts +94 -0
- package/src/lib/backup/unzip.ts +38 -0
- package/src/lib/backup/zip.ts +105 -0
- package/src/lib/blocksToHtml.ts +68 -0
- package/src/lib/brand.ts +26 -0
- package/src/lib/calendar/ics.ts +402 -0
- package/src/lib/calendar/render.ts +275 -0
- package/src/lib/calendar/src.ts +68 -0
- package/src/lib/content.ts +53 -0
- package/src/lib/dates.ts +34 -0
- package/src/lib/dynamicBlocks.ts +115 -0
- package/src/lib/env.ts +6 -0
- package/src/lib/highlightCode.ts +44 -0
- package/src/lib/i18n.ts +40 -0
- package/src/lib/markdown.ts +7 -0
- package/src/lib/media/store.ts +95 -0
- package/src/lib/net.ts +51 -0
- package/src/lib/preview.ts +28 -0
- package/src/lib/repo/d1.ts +44 -0
- package/src/lib/repo/index.ts +23 -0
- package/src/lib/repo/migrations.ts +231 -0
- package/src/lib/repo/search.ts +14 -0
- package/src/lib/repo/sql.ts +721 -0
- package/src/lib/repo/sqlite.ts +43 -0
- package/src/lib/repo/types.ts +132 -0
- package/src/lib/schema.ts +286 -0
- package/src/lib/seo.ts +37 -0
- package/src/lib/theme.ts +206 -0
- package/src/lib/widgets.ts +108 -0
- package/src/middleware.ts +109 -0
- package/src/pages/[...slug].astro +43 -0
- package/src/pages/admin/c/[collection]/[id].astro +56 -0
- package/src/pages/admin/c/[collection]/index.astro +163 -0
- package/src/pages/admin/design.astro +42 -0
- package/src/pages/admin/index.astro +145 -0
- package/src/pages/admin/links.astro +21 -0
- package/src/pages/admin/login.astro +83 -0
- package/src/pages/admin/logout.ts +7 -0
- package/src/pages/admin/media.astro +13 -0
- package/src/pages/admin/navigation.astro +17 -0
- package/src/pages/admin/pages/[id].astro +49 -0
- package/src/pages/admin/pages/index.astro +100 -0
- package/src/pages/admin/posts/[id].astro +6 -0
- package/src/pages/admin/posts/index.astro +7 -0
- package/src/pages/admin/settings.astro +512 -0
- package/src/pages/admin/tags.astro +101 -0
- package/src/pages/admin/users.astro +149 -0
- package/src/pages/api/backup.ts +63 -0
- package/src/pages/api/drafts.ts +106 -0
- package/src/pages/api/media/upload.ts +89 -0
- package/src/pages/api/restore.ts +102 -0
- package/src/pages/author/[slug].astro +66 -0
- package/src/pages/blog/[slug].astro +240 -0
- package/src/pages/blog/index.astro +51 -0
- package/src/pages/blog/tag/[slug].astro +83 -0
- package/src/pages/embed/[pageId]/[blockId].ts +45 -0
- package/src/pages/go/[id].ts +10 -0
- package/src/pages/index.astro +52 -0
- package/src/pages/links.astro +110 -0
- package/src/pages/llms.txt.ts +47 -0
- package/src/pages/media/[...key].ts +18 -0
- package/src/pages/preview/[type]/[id].astro +52 -0
- package/src/pages/robots.txt.ts +28 -0
- package/src/pages/rss.xml.ts +58 -0
- package/src/pages/search.astro +83 -0
- package/src/pages/sitemap.xml.ts +55 -0
- package/src/styles/admin.css +652 -0
- package/src/styles/builder.css +121 -0
- package/src/styles/cards.css +176 -0
- package/src/styles/editor.css +596 -0
- package/src/styles/site.css +427 -0
- package/src/themes/editorial/templates/home.astro +127 -0
- package/src/themes/editorial/templates/post-minimal.astro +50 -0
- package/src/themes/editorial/theme.css +108 -0
- package/src/themes/editorial/theme.json +20 -0
- package/src/themes/field/theme.json +17 -0
- package/src/themes/ink/theme.json +17 -0
- package/src/themes/night/theme.css +7 -0
- package/src/themes/night/theme.json +17 -0
- package/src/virtual.d.ts +25 -0
- package/src/worker.ts +28 -0
|
@@ -0,0 +1,1011 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { useEditor, EditorContent } from '@tiptap/react';
|
|
3
|
+
import { BubbleMenu, FloatingMenu } from '@tiptap/react/menus';
|
|
4
|
+
import type { Range } from '@tiptap/react';
|
|
5
|
+
import StarterKit from '@tiptap/starter-kit';
|
|
6
|
+
import { TableKit } from '@tiptap/extension-table';
|
|
7
|
+
import { Placeholder, CharacterCount } from '@tiptap/extensions';
|
|
8
|
+
import { actions } from 'astro:actions';
|
|
9
|
+
import type { Page, Post, Status } from '../../lib/schema';
|
|
10
|
+
import { ImageCard, EmbedCard, ButtonCard, Callout, HtmlCard, detectEmbed } from './extensions/cards';
|
|
11
|
+
import { FileCard, GalleryCard, BookmarkCard } from './extensions/mediaCards';
|
|
12
|
+
import { CodeBlock } from './extensions/codeBlock';
|
|
13
|
+
import { PostListCard } from './extensions/postList';
|
|
14
|
+
import { CalendarCard } from './extensions/calendar';
|
|
15
|
+
import { InfoCards } from './extensions/infoCards';
|
|
16
|
+
import { SlashMenu } from './extensions/slash';
|
|
17
|
+
import { TableTyping } from './extensions/tableTyping';
|
|
18
|
+
import { ImagePicker, type PickedImage } from './ImagePicker';
|
|
19
|
+
import { PreviewOverlay } from './PreviewOverlay';
|
|
20
|
+
import { renderMarkdown } from '../../lib/markdown';
|
|
21
|
+
import { CONTENT, collectionUrl } from '../../lib/content';
|
|
22
|
+
import { I18N, localeHomeUrl } from '../../lib/i18n';
|
|
23
|
+
|
|
24
|
+
// The Ghost-style writing surface, shared by posts AND pages: distraction-free
|
|
25
|
+
// column, cards via "/", autosave, a collapsible settings sidebar, and an
|
|
26
|
+
// optional side-by-side live preview. `kind` switches the save action and
|
|
27
|
+
// which settings fields exist — everything else is identical.
|
|
28
|
+
|
|
29
|
+
type SaveState = 'saved' | 'dirty' | 'saving' | 'error';
|
|
30
|
+
|
|
31
|
+
interface Props {
|
|
32
|
+
kind?: 'post' | 'page';
|
|
33
|
+
post?: Post;
|
|
34
|
+
page?: Page;
|
|
35
|
+
initialHTML: string;
|
|
36
|
+
unsplashEnabled: boolean;
|
|
37
|
+
previewPath: string;
|
|
38
|
+
sharePath: string;
|
|
39
|
+
// theme-provided template names for this document kind; empty = no picker
|
|
40
|
+
templates?: string[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface Meta {
|
|
44
|
+
title: string;
|
|
45
|
+
slug: string;
|
|
46
|
+
collection: string;
|
|
47
|
+
tags: string[];
|
|
48
|
+
excerpt: string;
|
|
49
|
+
cover: string;
|
|
50
|
+
coverAlt: string;
|
|
51
|
+
seoTitle: string;
|
|
52
|
+
seoDesc: string;
|
|
53
|
+
status: Status;
|
|
54
|
+
featured: boolean;
|
|
55
|
+
template: string;
|
|
56
|
+
isHome: boolean;
|
|
57
|
+
// pages only; '' = the site's default locale
|
|
58
|
+
locale: string;
|
|
59
|
+
authorId: string | null;
|
|
60
|
+
authorIds: string[];
|
|
61
|
+
publishedAt: number | null;
|
|
62
|
+
scheduledAt: number | null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
interface AuthorOption {
|
|
66
|
+
id: string;
|
|
67
|
+
name: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const tsToLocal = (ts: number | null) => {
|
|
71
|
+
if (!ts) return '';
|
|
72
|
+
const d = new Date(ts);
|
|
73
|
+
const pad = (n: number) => String(n).padStart(2, '0');
|
|
74
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
75
|
+
};
|
|
76
|
+
const localToTs = (v: string) => (v ? new Date(v).getTime() : null);
|
|
77
|
+
|
|
78
|
+
const STATUS_LABEL: Record<Status, string> = {
|
|
79
|
+
draft: 'Draft', scheduled: 'Scheduled', published: 'Published', archived: 'Archived',
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
// Pasted plain text that reads as markdown gets converted to rich content.
|
|
83
|
+
// Only structural/inline syntax counts — ordinary prose must pass through.
|
|
84
|
+
function looksLikeMarkdown(text: string): boolean {
|
|
85
|
+
return (
|
|
86
|
+
/^#{1,6}\s+\S/m.test(text) || // headings
|
|
87
|
+
/^\s{0,3}(?:[-*+]|\d+\.)\s+\S/m.test(text) || // lists
|
|
88
|
+
/^\s{0,3}>\s?\S/m.test(text) || // blockquotes
|
|
89
|
+
/^```/m.test(text) || // fenced code
|
|
90
|
+
/^\s{0,3}(?:-{3,}|\*{3,})\s*$/m.test(text) || // hr
|
|
91
|
+
/^\s*\|.+\|\s*$/m.test(text) || // tables
|
|
92
|
+
/\*\*[^*\n]+\*\*|__[^_\n]+__/.test(text) || // bold
|
|
93
|
+
/!?\[[^\]\n]+\]\([^)\n]+\)/.test(text) // links / images
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Table rows pasted back out of a rendered doc arrive one-per-paragraph with
|
|
98
|
+
// blank lines between them — not a valid markdown table. Rejoin consecutive
|
|
99
|
+
// pipe rows so marked sees a single table block.
|
|
100
|
+
function joinBrokenTableRows(text: string): string {
|
|
101
|
+
const isRow = (l: string) => /^\s*\|.*\|\s*$/.test(l);
|
|
102
|
+
const lines = text.split('\n');
|
|
103
|
+
const out: string[] = [];
|
|
104
|
+
for (let i = 0; i < lines.length; i++) {
|
|
105
|
+
if (lines[i].trim() === '' && out.length && isRow(out[out.length - 1])) {
|
|
106
|
+
let j = i;
|
|
107
|
+
while (j < lines.length && lines[j].trim() === '') j++;
|
|
108
|
+
if (j < lines.length && isRow(lines[j])) { i = j - 1; continue; }
|
|
109
|
+
}
|
|
110
|
+
out.push(lines[i]);
|
|
111
|
+
}
|
|
112
|
+
return out.join('\n');
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export default function PostEditor({ kind = 'post', post, page, initialHTML, unsplashEnabled, previewPath, sharePath, templates = [] }: Props) {
|
|
116
|
+
const doc = (kind === 'page' ? page : post)!;
|
|
117
|
+
const docId = doc.id;
|
|
118
|
+
|
|
119
|
+
const [meta, setMetaState] = useState<Meta>(() =>
|
|
120
|
+
kind === 'page'
|
|
121
|
+
? {
|
|
122
|
+
title: page!.title, slug: page!.slug, collection: 'posts', tags: [], excerpt: '', cover: '', coverAlt: '',
|
|
123
|
+
seoTitle: page!.seoTitle, seoDesc: page!.seoDesc, status: page!.status,
|
|
124
|
+
featured: false, isHome: page!.isHome, locale: page!.locale, authorId: null, authorIds: [], template: page!.template, publishedAt: null, scheduledAt: null,
|
|
125
|
+
}
|
|
126
|
+
: {
|
|
127
|
+
title: post!.title, slug: post!.slug, collection: post!.collection, tags: post!.tags, excerpt: post!.excerpt,
|
|
128
|
+
cover: post!.cover, coverAlt: post!.coverAlt, seoTitle: post!.seoTitle, seoDesc: post!.seoDesc,
|
|
129
|
+
status: post!.status, featured: post!.featured, isHome: false, locale: '', authorId: post!.authorId,
|
|
130
|
+
authorIds: post!.authorIds.length ? post!.authorIds : post!.authorId ? [post!.authorId] : [],
|
|
131
|
+
template: post!.template,
|
|
132
|
+
publishedAt: post!.publishedAt, scheduledAt: post!.scheduledAt,
|
|
133
|
+
}
|
|
134
|
+
);
|
|
135
|
+
// labels + URL base follow the post's collection (config key on the row)
|
|
136
|
+
const col = kind === 'page' ? CONTENT.posts : (CONTENT.collections[meta.collection] ?? CONTENT.posts);
|
|
137
|
+
const listHref = kind === 'page' ? '/admin/pages' : `/admin/c/${meta.collection}`;
|
|
138
|
+
const noun = kind === 'page' ? 'page' : col.singular.toLowerCase();
|
|
139
|
+
|
|
140
|
+
const [authors, setAuthors] = useState<AuthorOption[]>([]);
|
|
141
|
+
const [tagSuggestions, setTagSuggestions] = useState<string[]>([]);
|
|
142
|
+
const metaRef = useRef(meta);
|
|
143
|
+
const [saveState, setSaveState] = useState<SaveState>('saved');
|
|
144
|
+
const saveStateRef = useRef<SaveState>('saved');
|
|
145
|
+
const [sidebarOpen, setSidebarOpen] = useState(false);
|
|
146
|
+
const [publishOpen, setPublishOpen] = useState(false);
|
|
147
|
+
const [previewOpen, setPreviewOpen] = useState(false);
|
|
148
|
+
const [split, setSplit] = useState(() => {
|
|
149
|
+
try { return localStorage.getItem('pe-split') === '1'; } catch { return false; }
|
|
150
|
+
});
|
|
151
|
+
const [previewTick, setPreviewTick] = useState(0);
|
|
152
|
+
const [picker, setPicker] = useState<'closed' | 'editor' | 'feature'>('closed');
|
|
153
|
+
const pickerRangeRef = useRef<Range | null>(null);
|
|
154
|
+
const [scheduleDraft, setScheduleDraft] = useState('');
|
|
155
|
+
const [linkEdit, setLinkEdit] = useState(false);
|
|
156
|
+
const [linkDraft, setLinkDraft] = useState('');
|
|
157
|
+
const [shortcutsOpen, setShortcutsOpen] = useState(false);
|
|
158
|
+
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
159
|
+
|
|
160
|
+
const setSave = (s: SaveState) => { saveStateRef.current = s; setSaveState(s); };
|
|
161
|
+
const toggleSplit = () => {
|
|
162
|
+
setSplit((v) => {
|
|
163
|
+
try { localStorage.setItem('pe-split', v ? '0' : '1'); } catch { /* private mode */ }
|
|
164
|
+
return !v;
|
|
165
|
+
});
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
const editor = useEditor({
|
|
169
|
+
extensions: [
|
|
170
|
+
StarterKit.configure({
|
|
171
|
+
heading: { levels: [2, 3] },
|
|
172
|
+
link: { openOnClick: false },
|
|
173
|
+
codeBlock: false,
|
|
174
|
+
}),
|
|
175
|
+
CodeBlock,
|
|
176
|
+
Placeholder.configure({
|
|
177
|
+
// Only the empty document gets the invitation; empty paragraphs in an
|
|
178
|
+
// existing doc get the floating quick-insert menu instead (the two
|
|
179
|
+
// rendered on top of each other otherwise).
|
|
180
|
+
placeholder: ({ editor: ed, node }) =>
|
|
181
|
+
ed.isEmpty && node.type.name === 'paragraph' ? `Begin writing your ${noun} — type “/” for cards, paste markdown…` : '',
|
|
182
|
+
}),
|
|
183
|
+
CharacterCount,
|
|
184
|
+
TableKit.configure({ table: { resizable: false } }),
|
|
185
|
+
TableTyping,
|
|
186
|
+
ImageCard,
|
|
187
|
+
EmbedCard,
|
|
188
|
+
ButtonCard,
|
|
189
|
+
Callout,
|
|
190
|
+
HtmlCard,
|
|
191
|
+
PostListCard,
|
|
192
|
+
CalendarCard,
|
|
193
|
+
InfoCards,
|
|
194
|
+
FileCard,
|
|
195
|
+
BookmarkCard,
|
|
196
|
+
GalleryCard.configure({ unsplashEnabled }),
|
|
197
|
+
SlashMenu.configure({
|
|
198
|
+
context: {
|
|
199
|
+
openImagePicker: (range) => {
|
|
200
|
+
pickerRangeRef.current = range;
|
|
201
|
+
setPicker('editor');
|
|
202
|
+
},
|
|
203
|
+
},
|
|
204
|
+
}),
|
|
205
|
+
],
|
|
206
|
+
content: initialHTML,
|
|
207
|
+
editorProps: {
|
|
208
|
+
attributes: { class: 'pe-prose' },
|
|
209
|
+
handlePaste: (view, event) => {
|
|
210
|
+
// bare embeddable URL on an empty paragraph → embed card
|
|
211
|
+
const text = event.clipboardData?.getData('text/plain')?.trim() ?? '';
|
|
212
|
+
const { $from, empty } = view.state.selection;
|
|
213
|
+
if (text && empty && $from.parent.type.name === 'paragraph' && $from.parent.content.size === 0 && !text.includes(' ')) {
|
|
214
|
+
const info = detectEmbed(text);
|
|
215
|
+
if (info && info.provider !== 'iframe') {
|
|
216
|
+
editorRef.current
|
|
217
|
+
?.chain()
|
|
218
|
+
.insertContent({ type: 'embedCard', attrs: { url: text, embedSrc: info.embedSrc, provider: info.provider } })
|
|
219
|
+
.run();
|
|
220
|
+
return true;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
// plain-text markdown → rich content; headings clamp to the editor's
|
|
224
|
+
// h2/h3 schema. Rich clipboards keep ProseMirror's own HTML handling,
|
|
225
|
+
// EXCEPT when the plain text carries a markdown table — copied
|
|
226
|
+
// markdown source usually has an HTML flavor alongside it (code
|
|
227
|
+
// editors, chat apps, this editor's own output) that would otherwise
|
|
228
|
+
// keep the pipe rows literal.
|
|
229
|
+
const html = event.clipboardData?.getData('text/html') ?? '';
|
|
230
|
+
const joined = joinBrokenTableRows(text);
|
|
231
|
+
const hasMarkdownTable = /^\s*\|.+\|\s*\n\s*\|(\s*:?-+:?\s*\|)+\s*$/m.test(joined);
|
|
232
|
+
if (text && looksLikeMarkdown(text) && (!html || hasMarkdownTable)) {
|
|
233
|
+
const rich = renderMarkdown(joined)
|
|
234
|
+
.replace(/<(\/?)h1\b[^>]*>/g, '<$1h2>')
|
|
235
|
+
.replace(/<(\/?)h[4-6]\b[^>]*>/g, '<$1h3>');
|
|
236
|
+
editorRef.current?.chain().insertContent(rich).run();
|
|
237
|
+
return true;
|
|
238
|
+
}
|
|
239
|
+
return false;
|
|
240
|
+
},
|
|
241
|
+
},
|
|
242
|
+
onUpdate: () => queueSave(),
|
|
243
|
+
immediatelyRender: false,
|
|
244
|
+
});
|
|
245
|
+
const editorRef = useRef(editor);
|
|
246
|
+
editorRef.current = editor;
|
|
247
|
+
|
|
248
|
+
const save = useCallback(async (overrides: Partial<Meta> = {}) => {
|
|
249
|
+
const ed = editorRef.current;
|
|
250
|
+
if (!ed) return;
|
|
251
|
+
if (saveTimer.current) { clearTimeout(saveTimer.current); saveTimer.current = null; }
|
|
252
|
+
const m = { ...metaRef.current, ...overrides };
|
|
253
|
+
setSave('saving');
|
|
254
|
+
const bodyJson = JSON.stringify(ed.getJSON());
|
|
255
|
+
const bodyHtml = ed.getHTML();
|
|
256
|
+
if (kind === 'page') {
|
|
257
|
+
const r = await actions.pages.save({
|
|
258
|
+
id: docId,
|
|
259
|
+
title: m.title,
|
|
260
|
+
slug: m.slug,
|
|
261
|
+
bodyJson,
|
|
262
|
+
bodyHtml,
|
|
263
|
+
seoTitle: m.seoTitle,
|
|
264
|
+
seoDesc: m.seoDesc,
|
|
265
|
+
template: m.template,
|
|
266
|
+
status: m.status,
|
|
267
|
+
isHome: m.isHome,
|
|
268
|
+
locale: m.locale,
|
|
269
|
+
});
|
|
270
|
+
if (r.error) {
|
|
271
|
+
setSave('error');
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
// server normalizes the locale (default stores as '')
|
|
275
|
+
const p = r.data.page;
|
|
276
|
+
setMetaState((prev) => {
|
|
277
|
+
const next = { ...prev, slug: p.slug, isHome: p.isHome, locale: p.locale };
|
|
278
|
+
metaRef.current = next;
|
|
279
|
+
return next;
|
|
280
|
+
});
|
|
281
|
+
} else {
|
|
282
|
+
const r = await actions.posts.save({
|
|
283
|
+
id: docId,
|
|
284
|
+
title: m.title,
|
|
285
|
+
slug: m.slug,
|
|
286
|
+
collection: m.collection,
|
|
287
|
+
bodyJson,
|
|
288
|
+
bodyHtml,
|
|
289
|
+
excerpt: m.excerpt,
|
|
290
|
+
tags: m.tags,
|
|
291
|
+
cover: m.cover,
|
|
292
|
+
coverAlt: m.coverAlt,
|
|
293
|
+
seoTitle: m.seoTitle,
|
|
294
|
+
seoDesc: m.seoDesc,
|
|
295
|
+
status: m.status,
|
|
296
|
+
featured: m.featured,
|
|
297
|
+
authorIds: m.authorIds,
|
|
298
|
+
template: m.template,
|
|
299
|
+
publishedAt: m.publishedAt,
|
|
300
|
+
scheduledAt: m.scheduledAt,
|
|
301
|
+
});
|
|
302
|
+
if (r.error) {
|
|
303
|
+
setSave('error');
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
// server may have re-slugified / auto-set publish date
|
|
307
|
+
const p = r.data.post;
|
|
308
|
+
setMetaState((prev) => {
|
|
309
|
+
const next = { ...prev, slug: p.slug, publishedAt: p.publishedAt, collection: p.collection };
|
|
310
|
+
metaRef.current = next;
|
|
311
|
+
return next;
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
setPreviewTick((t) => t + 1);
|
|
315
|
+
setSave(saveStateRef.current === 'saving' ? 'saved' : saveStateRef.current);
|
|
316
|
+
}, [docId, kind]);
|
|
317
|
+
|
|
318
|
+
const queueSave = useCallback(() => {
|
|
319
|
+
setSave('dirty');
|
|
320
|
+
if (saveTimer.current) clearTimeout(saveTimer.current);
|
|
321
|
+
saveTimer.current = setTimeout(() => save(), 1200);
|
|
322
|
+
}, [save]);
|
|
323
|
+
|
|
324
|
+
const setMeta = useCallback((patch: Partial<Meta>) => {
|
|
325
|
+
setMetaState((prev) => {
|
|
326
|
+
const next = { ...prev, ...patch };
|
|
327
|
+
metaRef.current = next;
|
|
328
|
+
return next;
|
|
329
|
+
});
|
|
330
|
+
queueSave();
|
|
331
|
+
}, [queueSave]);
|
|
332
|
+
|
|
333
|
+
// Author + tag-registry options for the settings sidebar, loaded on first open.
|
|
334
|
+
const sidebarLoaded = useRef(false);
|
|
335
|
+
useEffect(() => {
|
|
336
|
+
if (kind !== 'post' || !sidebarOpen || sidebarLoaded.current) return;
|
|
337
|
+
sidebarLoaded.current = true;
|
|
338
|
+
actions.users.list({}).then((r) => {
|
|
339
|
+
if (!r.error) setAuthors(r.data.users.map((u) => ({ id: u.id, name: u.name })));
|
|
340
|
+
});
|
|
341
|
+
actions.tags.list({}).then((r) => {
|
|
342
|
+
if (!r.error) setTagSuggestions(r.data.tags.map((t) => t.name));
|
|
343
|
+
});
|
|
344
|
+
}, [sidebarOpen, kind]);
|
|
345
|
+
|
|
346
|
+
// opens the link editor seeded from the current selection/link
|
|
347
|
+
const openLinkEdit = useCallback(() => {
|
|
348
|
+
const ed = editorRef.current;
|
|
349
|
+
if (!ed) return;
|
|
350
|
+
const { empty } = ed.state.selection;
|
|
351
|
+
if (empty && !ed.isActive('link')) return; // nothing to link
|
|
352
|
+
setLinkDraft((ed.getAttributes('link').href as string | undefined) ?? 'https://');
|
|
353
|
+
setLinkEdit(true);
|
|
354
|
+
}, []);
|
|
355
|
+
|
|
356
|
+
// Cmd+S flush, Cmd+K link, Cmd+/ shortcut sheet + unsaved-changes guard
|
|
357
|
+
useEffect(() => {
|
|
358
|
+
const onKey = (e: KeyboardEvent) => {
|
|
359
|
+
if ((e.metaKey || e.ctrlKey) && e.key === 's') { e.preventDefault(); save(); }
|
|
360
|
+
if ((e.metaKey || e.ctrlKey) && e.key === 'k') { e.preventDefault(); openLinkEdit(); }
|
|
361
|
+
if ((e.metaKey || e.ctrlKey) && e.key === '/') { e.preventDefault(); setShortcutsOpen((v) => !v); }
|
|
362
|
+
};
|
|
363
|
+
const onLeave = (e: BeforeUnloadEvent) => {
|
|
364
|
+
if (saveStateRef.current === 'dirty' || saveStateRef.current === 'saving') e.preventDefault();
|
|
365
|
+
};
|
|
366
|
+
window.addEventListener('keydown', onKey);
|
|
367
|
+
window.addEventListener('beforeunload', onLeave);
|
|
368
|
+
return () => { window.removeEventListener('keydown', onKey); window.removeEventListener('beforeunload', onLeave); };
|
|
369
|
+
}, [save, openLinkEdit]);
|
|
370
|
+
|
|
371
|
+
function insertPicked(img: PickedImage) {
|
|
372
|
+
const ed = editorRef.current;
|
|
373
|
+
if (picker === 'editor' && ed) {
|
|
374
|
+
const chain = pickerRangeRef.current
|
|
375
|
+
? ed.chain().focus().insertContentAt(pickerRangeRef.current.from, { type: 'imageCard', attrs: img })
|
|
376
|
+
: ed.chain().focus().insertContent({ type: 'imageCard', attrs: img });
|
|
377
|
+
chain.run();
|
|
378
|
+
queueSave();
|
|
379
|
+
} else if (picker === 'feature') {
|
|
380
|
+
setMeta({ cover: img.src, coverAlt: img.alt });
|
|
381
|
+
}
|
|
382
|
+
setPicker('closed');
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const [toast, setToast] = useState<{ text: string; href?: string } | null>(null);
|
|
386
|
+
const toastTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
387
|
+
|
|
388
|
+
function publicPath(m: Meta) {
|
|
389
|
+
return kind === 'post' ? collectionUrl(m.collection, m.slug) : m.isHome ? localeHomeUrl(m.locale) : `/${m.slug}`;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
async function transition(patch: Partial<Meta>) {
|
|
393
|
+
setPublishOpen(false);
|
|
394
|
+
const next = { ...metaRef.current, ...patch };
|
|
395
|
+
metaRef.current = next;
|
|
396
|
+
setMetaState(next);
|
|
397
|
+
await save(patch);
|
|
398
|
+
if (saveStateRef.current === 'error') return;
|
|
399
|
+
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) },
|
|
401
|
+
scheduled: { text: `Scheduled for ${next.scheduledAt ? new Date(next.scheduledAt).toLocaleString() : 'later'}` },
|
|
402
|
+
draft: { text: 'Back to private draft' },
|
|
403
|
+
archived: { text: 'Archived — hidden from the site' },
|
|
404
|
+
};
|
|
405
|
+
const msg = patch.status ? messages[patch.status] : undefined;
|
|
406
|
+
if (msg) {
|
|
407
|
+
setToast(msg);
|
|
408
|
+
if (toastTimer.current) clearTimeout(toastTimer.current);
|
|
409
|
+
toastTimer.current = setTimeout(() => setToast(null), 4500);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const words = editor?.storage.characterCount?.words() ?? 0;
|
|
414
|
+
const saveLabel =
|
|
415
|
+
saveState === 'saved' ? 'Saved' :
|
|
416
|
+
saveState === 'saving' ? 'Saving…' :
|
|
417
|
+
saveState === 'dirty' ? 'Unsaved changes' : 'Save failed — retrying on next change';
|
|
418
|
+
|
|
419
|
+
const publishLabel = meta.status === 'published' ? 'Update' : 'Publish';
|
|
420
|
+
|
|
421
|
+
return (
|
|
422
|
+
<div className={`pe-shell ${sidebarOpen ? 'has-sidebar' : ''} ${split ? 'has-split' : ''}`}>
|
|
423
|
+
{toast && (
|
|
424
|
+
<div className="pe-toast" role="status">
|
|
425
|
+
<span className="pe-toast-check">✓</span>
|
|
426
|
+
{toast.text}
|
|
427
|
+
{toast.href && <a href={toast.href} target="_blank" rel="noopener">View ↗</a>}
|
|
428
|
+
</div>
|
|
429
|
+
)}
|
|
430
|
+
<header className="pe-topbar">
|
|
431
|
+
<div className="pe-topbar-left">
|
|
432
|
+
<a className="pe-btn ghost sm" href={listHref}>{kind === 'page' ? '‹ Pages' : '‹ Posts'}</a>
|
|
433
|
+
<span className={`pe-savestate ${saveState}`}>
|
|
434
|
+
{STATUS_LABEL[meta.status]} — {saveLabel}
|
|
435
|
+
</span>
|
|
436
|
+
</div>
|
|
437
|
+
<div className="pe-topbar-right">
|
|
438
|
+
<button
|
|
439
|
+
type="button"
|
|
440
|
+
className={`pe-btn ghost sm ${split ? 'is-active' : ''}`}
|
|
441
|
+
title="Side-by-side preview"
|
|
442
|
+
onClick={toggleSplit}
|
|
443
|
+
>
|
|
444
|
+
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8">
|
|
445
|
+
<rect x="3" y="4" width="18" height="16" rx="2" /><path d="M12 4v16" />
|
|
446
|
+
</svg>
|
|
447
|
+
</button>
|
|
448
|
+
<button type="button" className="pe-btn ghost sm" onClick={() => setPreviewOpen(true)}>Preview</button>
|
|
449
|
+
<div className="pe-publish-wrap">
|
|
450
|
+
<button type="button" className="pe-btn primary sm" onClick={() => setPublishOpen((v) => !v)}>
|
|
451
|
+
{publishLabel} ▾
|
|
452
|
+
</button>
|
|
453
|
+
{publishOpen && (
|
|
454
|
+
<div className="pe-popover">
|
|
455
|
+
{meta.status === 'draft' && (
|
|
456
|
+
<>
|
|
457
|
+
<div className="pe-pop-head">
|
|
458
|
+
<b>Ready to publish?</b>
|
|
459
|
+
<span>This {noun} is a private draft — publishing makes it visible to everyone.</span>
|
|
460
|
+
</div>
|
|
461
|
+
<button
|
|
462
|
+
type="button"
|
|
463
|
+
className="pe-btn primary pe-pop-cta"
|
|
464
|
+
onClick={() => transition({ status: 'published', scheduledAt: null })}
|
|
465
|
+
>
|
|
466
|
+
Publish now
|
|
467
|
+
</button>
|
|
468
|
+
{kind === 'post' && (
|
|
469
|
+
<div className="pe-pop-schedule">
|
|
470
|
+
<span className="pe-pop-label">Or schedule for later</span>
|
|
471
|
+
<div className="pe-pop-schedrow">
|
|
472
|
+
<input
|
|
473
|
+
type="datetime-local"
|
|
474
|
+
value={scheduleDraft}
|
|
475
|
+
min={tsToLocal(Date.now())}
|
|
476
|
+
onChange={(e) => setScheduleDraft(e.target.value)}
|
|
477
|
+
/>
|
|
478
|
+
<button
|
|
479
|
+
type="button"
|
|
480
|
+
className="pe-btn sm"
|
|
481
|
+
disabled={!scheduleDraft || (localToTs(scheduleDraft) ?? 0) <= Date.now()}
|
|
482
|
+
onClick={() => transition({ status: 'scheduled', scheduledAt: localToTs(scheduleDraft) })}
|
|
483
|
+
>
|
|
484
|
+
Schedule
|
|
485
|
+
</button>
|
|
486
|
+
</div>
|
|
487
|
+
{!scheduleDraft && <span className="pe-pop-hint">Pick a date and time first</span>}
|
|
488
|
+
</div>
|
|
489
|
+
)}
|
|
490
|
+
</>
|
|
491
|
+
)}
|
|
492
|
+
{meta.status === 'scheduled' && (
|
|
493
|
+
<>
|
|
494
|
+
<div className="pe-pop-head">
|
|
495
|
+
<b>Scheduled</b>
|
|
496
|
+
<span>Goes live {meta.scheduledAt ? new Date(meta.scheduledAt).toLocaleString() : ''}.</span>
|
|
497
|
+
</div>
|
|
498
|
+
<button
|
|
499
|
+
type="button"
|
|
500
|
+
className="pe-btn primary pe-pop-cta"
|
|
501
|
+
onClick={() => transition({ status: 'published', scheduledAt: null })}
|
|
502
|
+
>
|
|
503
|
+
Publish now instead
|
|
504
|
+
</button>
|
|
505
|
+
<button type="button" className="pe-pop-item" onClick={() => transition({ status: 'draft', scheduledAt: null })}>
|
|
506
|
+
<b>Unschedule</b><span>Back to a private draft</span>
|
|
507
|
+
</button>
|
|
508
|
+
</>
|
|
509
|
+
)}
|
|
510
|
+
{meta.status === 'published' && (
|
|
511
|
+
<>
|
|
512
|
+
<div className="pe-pop-head">
|
|
513
|
+
<b>Published</b>
|
|
514
|
+
<span>Live on the site — edits save and go live automatically.</span>
|
|
515
|
+
</div>
|
|
516
|
+
<a className="pe-btn pe-pop-cta" href={publicPath(meta)} target="_blank" rel="noopener">
|
|
517
|
+
View on site ↗
|
|
518
|
+
</a>
|
|
519
|
+
<div className="pe-pop-sep" />
|
|
520
|
+
<button type="button" className="pe-pop-item" onClick={() => transition({ status: 'draft' })}>
|
|
521
|
+
<b>Unpublish</b><span>Revert to a private draft</span>
|
|
522
|
+
</button>
|
|
523
|
+
<button type="button" className="pe-pop-item" onClick={() => transition({ status: 'archived' })}>
|
|
524
|
+
<b>Archive</b><span>Hide from the site, keep forever</span>
|
|
525
|
+
</button>
|
|
526
|
+
</>
|
|
527
|
+
)}
|
|
528
|
+
{meta.status === 'archived' && (
|
|
529
|
+
<>
|
|
530
|
+
<div className="pe-pop-head">
|
|
531
|
+
<b>Archived</b>
|
|
532
|
+
<span>Hidden from the site, kept forever.</span>
|
|
533
|
+
</div>
|
|
534
|
+
<button
|
|
535
|
+
type="button"
|
|
536
|
+
className="pe-btn primary pe-pop-cta"
|
|
537
|
+
onClick={() => transition({ status: 'published' })}
|
|
538
|
+
>
|
|
539
|
+
Publish again
|
|
540
|
+
</button>
|
|
541
|
+
<button type="button" className="pe-pop-item" onClick={() => transition({ status: 'draft' })}>
|
|
542
|
+
<b>Restore as draft</b><span>Back into the workflow</span>
|
|
543
|
+
</button>
|
|
544
|
+
</>
|
|
545
|
+
)}
|
|
546
|
+
</div>
|
|
547
|
+
)}
|
|
548
|
+
</div>
|
|
549
|
+
<button
|
|
550
|
+
type="button"
|
|
551
|
+
className={`pe-btn ghost sm pe-sidebar-toggle ${sidebarOpen ? 'is-active' : ''}`}
|
|
552
|
+
title={kind === 'page' ? 'Page settings' : `${col.singular} settings`}
|
|
553
|
+
onClick={() => setSidebarOpen((v) => !v)}
|
|
554
|
+
>
|
|
555
|
+
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8">
|
|
556
|
+
<rect x="3" y="4" width="18" height="16" rx="2" /><path d="M15 4v16" />
|
|
557
|
+
</svg>
|
|
558
|
+
</button>
|
|
559
|
+
</div>
|
|
560
|
+
</header>
|
|
561
|
+
|
|
562
|
+
<div className="pe-body">
|
|
563
|
+
<main className="pe-main">
|
|
564
|
+
<div className="pe-column">
|
|
565
|
+
{kind === 'post' && (meta.cover ? (
|
|
566
|
+
<figure className="pe-feature">
|
|
567
|
+
<img src={meta.cover} alt={meta.coverAlt} />
|
|
568
|
+
<div className="pe-feature-tools">
|
|
569
|
+
<button type="button" className="pe-btn sm" onClick={() => setPicker('feature')}>Replace</button>
|
|
570
|
+
<button type="button" className="pe-btn sm" onClick={() => setMeta({ cover: '', coverAlt: '' })}>Remove</button>
|
|
571
|
+
</div>
|
|
572
|
+
<figcaption className="pe-feature-label">Feature image</figcaption>
|
|
573
|
+
</figure>
|
|
574
|
+
) : (
|
|
575
|
+
<button type="button" className="pe-add-feature" onClick={() => setPicker('feature')}>
|
|
576
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6">
|
|
577
|
+
<rect x="3" y="5" width="18" height="14" rx="2" />
|
|
578
|
+
<circle cx="8.5" cy="10" r="1.5" />
|
|
579
|
+
<path d="M21 15.5 16 11l-6.5 6.5M3 17l4-4 3 3" />
|
|
580
|
+
</svg>
|
|
581
|
+
<span>Add feature image</span>
|
|
582
|
+
<em>Shown at the top of the {noun} and in link previews</em>
|
|
583
|
+
</button>
|
|
584
|
+
))}
|
|
585
|
+
|
|
586
|
+
<textarea
|
|
587
|
+
className="pe-title"
|
|
588
|
+
rows={1}
|
|
589
|
+
placeholder={kind === 'page' ? 'Page title' : `${col.singular} title`}
|
|
590
|
+
value={meta.title}
|
|
591
|
+
onChange={(e) => {
|
|
592
|
+
e.target.style.height = 'auto';
|
|
593
|
+
e.target.style.height = `${e.target.scrollHeight}px`;
|
|
594
|
+
setMeta({ title: e.target.value });
|
|
595
|
+
}}
|
|
596
|
+
onKeyDown={(e) => {
|
|
597
|
+
if (e.key === 'Enter') { e.preventDefault(); editor?.commands.focus('start'); }
|
|
598
|
+
}}
|
|
599
|
+
/>
|
|
600
|
+
|
|
601
|
+
{editor && (
|
|
602
|
+
<BubbleMenu editor={editor} className="pe-bubble" options={{ placement: 'top' }}>
|
|
603
|
+
<button type="button" className={editor.isActive('bold') ? 'is-active' : ''} onClick={() => editor.chain().focus().toggleBold().run()}><b>B</b></button>
|
|
604
|
+
<button type="button" className={editor.isActive('italic') ? 'is-active' : ''} onClick={() => editor.chain().focus().toggleItalic().run()}><i>I</i></button>
|
|
605
|
+
<button type="button" className={editor.isActive('strike') ? 'is-active' : ''} onClick={() => editor.chain().focus().toggleStrike().run()}><s>S</s></button>
|
|
606
|
+
<button type="button" className={editor.isActive('code') ? 'is-active' : ''} onClick={() => editor.chain().focus().toggleCode().run()}>{'<>'}</button>
|
|
607
|
+
<span className="pe-bubble-sep" />
|
|
608
|
+
<button type="button" className={editor.isActive('heading', { level: 2 }) ? 'is-active' : ''} onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}>H2</button>
|
|
609
|
+
<button type="button" className={editor.isActive('heading', { level: 3 }) ? 'is-active' : ''} onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}>H3</button>
|
|
610
|
+
<button type="button" className={editor.isActive('blockquote') ? 'is-active' : ''} onClick={() => editor.chain().focus().toggleBlockquote().run()}>❝</button>
|
|
611
|
+
<span className="pe-bubble-sep" />
|
|
612
|
+
<button
|
|
613
|
+
type="button"
|
|
614
|
+
className={editor.isActive('link') ? 'is-active' : ''}
|
|
615
|
+
title="Link (⌘K)"
|
|
616
|
+
onClick={openLinkEdit}
|
|
617
|
+
>
|
|
618
|
+
Link
|
|
619
|
+
</button>
|
|
620
|
+
</BubbleMenu>
|
|
621
|
+
)}
|
|
622
|
+
|
|
623
|
+
{editor && (
|
|
624
|
+
<BubbleMenu
|
|
625
|
+
editor={editor}
|
|
626
|
+
className="pe-bubble pe-tablebar"
|
|
627
|
+
options={{ placement: 'bottom' }}
|
|
628
|
+
shouldShow={({ editor: ed }) => ed.isActive('table')}
|
|
629
|
+
>
|
|
630
|
+
<button type="button" title="Add column" onClick={() => editor.chain().focus().addColumnAfter().run()}>+ Col</button>
|
|
631
|
+
<button type="button" title="Add row" onClick={() => editor.chain().focus().addRowAfter().run()}>+ Row</button>
|
|
632
|
+
<span className="pe-bubble-sep" />
|
|
633
|
+
<button type="button" title="Delete column" onClick={() => editor.chain().focus().deleteColumn().run()}>− Col</button>
|
|
634
|
+
<button type="button" title="Delete row" onClick={() => editor.chain().focus().deleteRow().run()}>− Row</button>
|
|
635
|
+
<span className="pe-bubble-sep" />
|
|
636
|
+
<button type="button" title="Toggle header row" onClick={() => editor.chain().focus().toggleHeaderRow().run()}>Header</button>
|
|
637
|
+
<button type="button" title="Delete table" onClick={() => editor.chain().focus().deleteTable().run()}>✕</button>
|
|
638
|
+
</BubbleMenu>
|
|
639
|
+
)}
|
|
640
|
+
|
|
641
|
+
{editor && (
|
|
642
|
+
<FloatingMenu
|
|
643
|
+
editor={editor}
|
|
644
|
+
className="pe-floating"
|
|
645
|
+
options={{ placement: 'left' }}
|
|
646
|
+
shouldShow={({ editor: ed, view, state }) => {
|
|
647
|
+
// empty paragraph in a non-empty doc — the empty doc shows the
|
|
648
|
+
// placeholder instead, so the two never overlap
|
|
649
|
+
const { $anchor, empty } = state.selection;
|
|
650
|
+
return (
|
|
651
|
+
view.hasFocus() && empty && !ed.isEmpty && $anchor.depth === 1 &&
|
|
652
|
+
$anchor.parent.type.name === 'paragraph' && $anchor.parent.content.size === 0
|
|
653
|
+
);
|
|
654
|
+
}}
|
|
655
|
+
>
|
|
656
|
+
{([
|
|
657
|
+
['Image', () => { pickerRangeRef.current = null; setPicker('editor'); }],
|
|
658
|
+
['Gallery', () => editor.chain().focus().insertContent({ type: 'galleryCard' }).run()],
|
|
659
|
+
['Embed', () => editor.chain().focus().insertContent({ type: 'embedCard' }).run()],
|
|
660
|
+
['Bookmark', () => editor.chain().focus().insertContent({ type: 'bookmarkCard' }).run()],
|
|
661
|
+
['File', () => editor.chain().focus().insertContent({ type: 'fileCard' }).run()],
|
|
662
|
+
['Button', () => editor.chain().focus().insertContent({ type: 'buttonCard' }).run()],
|
|
663
|
+
['Callout', () => editor.chain().focus().insertContent({ type: 'callout' }).run()],
|
|
664
|
+
['Calendar', () => editor.chain().focus().insertContent({ type: 'calendarCard' }).run()],
|
|
665
|
+
['Info cards', () => editor.chain().focus().insertContent({ type: 'infoCards' }).run()],
|
|
666
|
+
['Table', () => editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run()],
|
|
667
|
+
['HTML', () => editor.chain().focus().insertContent({ type: 'htmlCard' }).run()],
|
|
668
|
+
['— Divider', () => editor.chain().focus().setHorizontalRule().run()],
|
|
669
|
+
] as [string, () => void][]).map(([label, run]) => (
|
|
670
|
+
<button key={label} type="button" onMouseDown={(e) => { e.preventDefault(); run(); }}>{label}</button>
|
|
671
|
+
))}
|
|
672
|
+
<span className="pe-floating-hint">or type /</span>
|
|
673
|
+
</FloatingMenu>
|
|
674
|
+
)}
|
|
675
|
+
|
|
676
|
+
{linkEdit && editor && (
|
|
677
|
+
<div className="pe-linkedit" role="dialog" aria-label="Edit link">
|
|
678
|
+
<input
|
|
679
|
+
autoFocus
|
|
680
|
+
type="url"
|
|
681
|
+
placeholder="https://… (empty removes the link)"
|
|
682
|
+
value={linkDraft}
|
|
683
|
+
onChange={(e) => setLinkDraft(e.target.value)}
|
|
684
|
+
onKeyDown={(e) => {
|
|
685
|
+
if (e.key === 'Enter') {
|
|
686
|
+
e.preventDefault();
|
|
687
|
+
const url = linkDraft.trim();
|
|
688
|
+
if (url === '' || url === 'https://') editor.chain().focus().extendMarkRange('link').unsetLink().run();
|
|
689
|
+
else editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run();
|
|
690
|
+
setLinkEdit(false);
|
|
691
|
+
}
|
|
692
|
+
if (e.key === 'Escape') { e.preventDefault(); setLinkEdit(false); editor.commands.focus(); }
|
|
693
|
+
}}
|
|
694
|
+
/>
|
|
695
|
+
<button
|
|
696
|
+
type="button"
|
|
697
|
+
className="pe-btn primary sm"
|
|
698
|
+
onClick={() => {
|
|
699
|
+
const url = linkDraft.trim();
|
|
700
|
+
if (url === '' || url === 'https://') editor.chain().focus().extendMarkRange('link').unsetLink().run();
|
|
701
|
+
else editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run();
|
|
702
|
+
setLinkEdit(false);
|
|
703
|
+
}}
|
|
704
|
+
>
|
|
705
|
+
Apply
|
|
706
|
+
</button>
|
|
707
|
+
<button type="button" className="pe-btn ghost sm" onClick={() => { setLinkEdit(false); editor.commands.focus(); }}>✕</button>
|
|
708
|
+
</div>
|
|
709
|
+
)}
|
|
710
|
+
|
|
711
|
+
<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
|
+
|
|
717
|
+
{shortcutsOpen && (
|
|
718
|
+
<div className="pe-shortcuts-overlay" onClick={() => setShortcutsOpen(false)}>
|
|
719
|
+
<div className="pe-shortcuts" role="dialog" aria-label="Keyboard shortcuts" onClick={(e) => e.stopPropagation()}>
|
|
720
|
+
<div className="pe-shortcuts-head">
|
|
721
|
+
<span>Keyboard shortcuts</span>
|
|
722
|
+
<button type="button" className="pe-btn ghost sm" onClick={() => setShortcutsOpen(false)}>✕</button>
|
|
723
|
+
</div>
|
|
724
|
+
{([
|
|
725
|
+
['Writing', [
|
|
726
|
+
['⌘B / ⌘I', 'Bold / italic'],
|
|
727
|
+
['⌘⇧X', 'Strikethrough'],
|
|
728
|
+
['⌘E', 'Inline code'],
|
|
729
|
+
['⌘K', 'Add or edit a link'],
|
|
730
|
+
['/', 'Insert a card (image, gallery, embed…)'],
|
|
731
|
+
]],
|
|
732
|
+
['Markdown as you type', [
|
|
733
|
+
['# · ## · ###', 'Headings'],
|
|
734
|
+
['- or 1.', 'Bullet / numbered list'],
|
|
735
|
+
['> text', 'Quote'],
|
|
736
|
+
['``` ', 'Code block'],
|
|
737
|
+
['| a | b |', 'Table (press Enter after the divider row)'],
|
|
738
|
+
['Paste markdown', 'Converts to rich content automatically'],
|
|
739
|
+
]],
|
|
740
|
+
['Document', [
|
|
741
|
+
['⌘S', 'Save now'],
|
|
742
|
+
['⌘/', 'This cheat-sheet'],
|
|
743
|
+
]],
|
|
744
|
+
] as [string, [string, string][]][]).map(([group, rows]) => (
|
|
745
|
+
<div key={group} className="pe-shortcuts-group">
|
|
746
|
+
<div className="pe-shortcuts-title">{group}</div>
|
|
747
|
+
{rows.map(([keys, what]) => (
|
|
748
|
+
<div key={keys} className="pe-shortcuts-row">
|
|
749
|
+
<kbd>{keys}</kbd>
|
|
750
|
+
<span>{what}</span>
|
|
751
|
+
</div>
|
|
752
|
+
))}
|
|
753
|
+
</div>
|
|
754
|
+
))}
|
|
755
|
+
</div>
|
|
756
|
+
</div>
|
|
757
|
+
)}
|
|
758
|
+
</div>
|
|
759
|
+
</main>
|
|
760
|
+
|
|
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
|
+
)}
|
|
774
|
+
</div>
|
|
775
|
+
|
|
776
|
+
{sidebarOpen && (
|
|
777
|
+
<aside className="pe-sidebar">
|
|
778
|
+
<div className="pe-sidebar-head">
|
|
779
|
+
<span>{kind === 'page' ? 'Page settings' : `${col.singular} settings`}</span>
|
|
780
|
+
<button type="button" className="pe-btn ghost sm" onClick={() => setSidebarOpen(false)}>✕</button>
|
|
781
|
+
</div>
|
|
782
|
+
|
|
783
|
+
<label className="pe-field">
|
|
784
|
+
<span>{kind === 'page' ? 'Page URL' : `${col.singular} URL`}</span>
|
|
785
|
+
<div className="pe-slug">
|
|
786
|
+
<span className="pe-slug-prefix">{kind === 'page' ? '/' : `${col.base}/`}</span>
|
|
787
|
+
<input type="text" value={meta.slug} onChange={(e) => setMeta({ slug: e.target.value })} />
|
|
788
|
+
</div>
|
|
789
|
+
</label>
|
|
790
|
+
|
|
791
|
+
{kind === 'post' && Object.keys(CONTENT.collections).length > 1 && (
|
|
792
|
+
<label className="pe-field">
|
|
793
|
+
<span>Collection</span>
|
|
794
|
+
<select value={meta.collection} onChange={(e) => setMeta({ collection: e.target.value })}>
|
|
795
|
+
{Object.entries(CONTENT.collections).map(([k, c]) => (
|
|
796
|
+
<option key={k} value={k}>{c.plural}</option>
|
|
797
|
+
))}
|
|
798
|
+
</select>
|
|
799
|
+
{meta.status === 'published' && (
|
|
800
|
+
<em className="hint">Moving a published {noun} leaves a redirect at its old URL.</em>
|
|
801
|
+
)}
|
|
802
|
+
</label>
|
|
803
|
+
)}
|
|
804
|
+
|
|
805
|
+
{kind === 'page' && I18N.enabled && (
|
|
806
|
+
<label className="pe-field">
|
|
807
|
+
<span>Language</span>
|
|
808
|
+
<select
|
|
809
|
+
value={meta.locale || I18N.defaultLocale}
|
|
810
|
+
onChange={(e) => setMeta({ locale: e.target.value })}
|
|
811
|
+
>
|
|
812
|
+
{I18N.locales.map((l) => (
|
|
813
|
+
<option key={l} value={l}>{l.toUpperCase()}{l === I18N.defaultLocale ? ' — default' : ''}</option>
|
|
814
|
+
))}
|
|
815
|
+
</select>
|
|
816
|
+
</label>
|
|
817
|
+
)}
|
|
818
|
+
|
|
819
|
+
{kind === 'page' && (
|
|
820
|
+
<label className="pe-field pe-check">
|
|
821
|
+
<input
|
|
822
|
+
type="checkbox"
|
|
823
|
+
checked={meta.isHome}
|
|
824
|
+
onChange={(e) => setMeta({ isHome: e.target.checked })}
|
|
825
|
+
/>
|
|
826
|
+
{I18N.enabled ? (
|
|
827
|
+
<span>Serve this page at <code>{localeHomeUrl(meta.locale)}</code> ({(meta.locale || I18N.defaultLocale).toUpperCase()} home page)</span>
|
|
828
|
+
) : (
|
|
829
|
+
<span>Serve this page at <code>/</code> (home page)</span>
|
|
830
|
+
)}
|
|
831
|
+
</label>
|
|
832
|
+
)}
|
|
833
|
+
|
|
834
|
+
{templates.length > 0 && (
|
|
835
|
+
<label className="pe-field">
|
|
836
|
+
<span>Template <em className="pe-check-hint">from the active theme</em></span>
|
|
837
|
+
<select value={meta.template} onChange={(e) => setMeta({ template: e.target.value })}>
|
|
838
|
+
<option value="">Default</option>
|
|
839
|
+
{templates.map((t) => (
|
|
840
|
+
<option key={t} value={t}>{t}</option>
|
|
841
|
+
))}
|
|
842
|
+
</select>
|
|
843
|
+
</label>
|
|
844
|
+
)}
|
|
845
|
+
|
|
846
|
+
{kind === 'post' && (
|
|
847
|
+
<>
|
|
848
|
+
<label className="pe-field">
|
|
849
|
+
<span>Publish date</span>
|
|
850
|
+
<input
|
|
851
|
+
type="datetime-local"
|
|
852
|
+
value={tsToLocal(meta.publishedAt)}
|
|
853
|
+
onChange={(e) => setMeta({ publishedAt: localToTs(e.target.value) })}
|
|
854
|
+
/>
|
|
855
|
+
</label>
|
|
856
|
+
|
|
857
|
+
<label className="pe-field">
|
|
858
|
+
<span>Tags</span>
|
|
859
|
+
<TagInput tags={meta.tags} suggestions={tagSuggestions} onChange={(tags) => setMeta({ tags })} />
|
|
860
|
+
</label>
|
|
861
|
+
|
|
862
|
+
<label className="pe-field pe-check">
|
|
863
|
+
<input
|
|
864
|
+
type="checkbox"
|
|
865
|
+
checked={meta.featured}
|
|
866
|
+
onChange={(e) => setMeta({ featured: e.target.checked })}
|
|
867
|
+
/>
|
|
868
|
+
<span>Feature this post <em className="pe-check-hint">highlighted on the homepage and in featured lists</em></span>
|
|
869
|
+
</label>
|
|
870
|
+
|
|
871
|
+
{authors.length > 0 && (
|
|
872
|
+
<div className="pe-field">
|
|
873
|
+
<span>Authors <em className="pe-check-hint">first is the primary byline</em></span>
|
|
874
|
+
<div className="pe-authors">
|
|
875
|
+
{authors.map((a) => {
|
|
876
|
+
const checked = meta.authorIds.includes(a.id);
|
|
877
|
+
return (
|
|
878
|
+
<label key={a.id} className="pe-check pe-author-check">
|
|
879
|
+
<input
|
|
880
|
+
type="checkbox"
|
|
881
|
+
checked={checked}
|
|
882
|
+
onChange={(e) =>
|
|
883
|
+
setMeta({
|
|
884
|
+
authorIds: e.target.checked
|
|
885
|
+
? [...meta.authorIds, a.id]
|
|
886
|
+
: meta.authorIds.filter((id) => id !== a.id),
|
|
887
|
+
})
|
|
888
|
+
}
|
|
889
|
+
/>
|
|
890
|
+
<span>{a.name}{checked && meta.authorIds[0] === a.id && meta.authorIds.length > 1 ? ' · primary' : ''}</span>
|
|
891
|
+
</label>
|
|
892
|
+
);
|
|
893
|
+
})}
|
|
894
|
+
</div>
|
|
895
|
+
</div>
|
|
896
|
+
)}
|
|
897
|
+
|
|
898
|
+
<label className="pe-field">
|
|
899
|
+
<span>Excerpt <em>{meta.excerpt.length}/300</em></span>
|
|
900
|
+
<textarea
|
|
901
|
+
rows={3}
|
|
902
|
+
maxLength={300}
|
|
903
|
+
placeholder="A short teaser shown on the blog index and in link previews"
|
|
904
|
+
value={meta.excerpt}
|
|
905
|
+
onChange={(e) => setMeta({ excerpt: e.target.value })}
|
|
906
|
+
/>
|
|
907
|
+
</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
|
+
</>
|
|
919
|
+
)}
|
|
920
|
+
|
|
921
|
+
<details className="pe-seo">
|
|
922
|
+
<summary>Search engine optimization</summary>
|
|
923
|
+
<label className="pe-field">
|
|
924
|
+
<span>Meta title <em>{(meta.seoTitle || meta.title).length}/60</em></span>
|
|
925
|
+
<input type="text" placeholder={meta.title} value={meta.seoTitle} onChange={(e) => setMeta({ seoTitle: e.target.value })} />
|
|
926
|
+
</label>
|
|
927
|
+
<label className="pe-field">
|
|
928
|
+
<span>Meta description <em>{(meta.seoDesc || meta.excerpt).length}/160</em></span>
|
|
929
|
+
<textarea rows={3} placeholder={meta.excerpt} value={meta.seoDesc} onChange={(e) => setMeta({ seoDesc: e.target.value })} />
|
|
930
|
+
</label>
|
|
931
|
+
</details>
|
|
932
|
+
|
|
933
|
+
<div className="pe-danger">
|
|
934
|
+
<button
|
|
935
|
+
type="button"
|
|
936
|
+
className="pe-btn danger sm"
|
|
937
|
+
onClick={async () => {
|
|
938
|
+
if (!window.confirm(`Delete this ${noun} permanently? This cannot be undone.`)) return;
|
|
939
|
+
if (kind === 'page') await actions.pages.delete({ id: docId });
|
|
940
|
+
else await actions.posts.delete({ id: docId });
|
|
941
|
+
location.href = listHref;
|
|
942
|
+
}}
|
|
943
|
+
>
|
|
944
|
+
Delete {noun}
|
|
945
|
+
</button>
|
|
946
|
+
</div>
|
|
947
|
+
</aside>
|
|
948
|
+
)}
|
|
949
|
+
|
|
950
|
+
<ImagePicker
|
|
951
|
+
open={picker !== 'closed'}
|
|
952
|
+
unsplashEnabled={unsplashEnabled}
|
|
953
|
+
onClose={() => setPicker('closed')}
|
|
954
|
+
onSelect={insertPicked}
|
|
955
|
+
/>
|
|
956
|
+
<PreviewOverlay
|
|
957
|
+
open={previewOpen}
|
|
958
|
+
url={previewPath}
|
|
959
|
+
shareUrl={sharePath}
|
|
960
|
+
onClose={() => setPreviewOpen(false)}
|
|
961
|
+
/>
|
|
962
|
+
</div>
|
|
963
|
+
);
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
// ---- tag chips ------------------------------------------------------------
|
|
967
|
+
|
|
968
|
+
function TagInput({ tags, suggestions = [], onChange }: { tags: string[]; suggestions?: string[]; onChange: (tags: string[]) => void }) {
|
|
969
|
+
const [draft, setDraft] = useState('');
|
|
970
|
+
const commit = (value?: string) => {
|
|
971
|
+
const t = (value ?? draft).trim().replace(/,+$/, '');
|
|
972
|
+
if (t && !tags.some((x) => x.toLowerCase() === t.toLowerCase())) onChange([...tags, t]);
|
|
973
|
+
setDraft('');
|
|
974
|
+
};
|
|
975
|
+
const matches = draft
|
|
976
|
+
? suggestions.filter(
|
|
977
|
+
(s) => s.toLowerCase().includes(draft.toLowerCase()) && !tags.some((t) => t.toLowerCase() === s.toLowerCase())
|
|
978
|
+
).slice(0, 6)
|
|
979
|
+
: [];
|
|
980
|
+
return (
|
|
981
|
+
<div className="pe-tags-wrap">
|
|
982
|
+
<div className="pe-tags">
|
|
983
|
+
{tags.map((t) => (
|
|
984
|
+
<span key={t} className="pe-tag">
|
|
985
|
+
{t}
|
|
986
|
+
<button type="button" aria-label={`Remove ${t}`} onClick={() => onChange(tags.filter((x) => x !== t))}>✕</button>
|
|
987
|
+
</span>
|
|
988
|
+
))}
|
|
989
|
+
<input
|
|
990
|
+
type="text"
|
|
991
|
+
placeholder={tags.length ? '' : 'Add a tag…'}
|
|
992
|
+
value={draft}
|
|
993
|
+
onChange={(e) => setDraft(e.target.value)}
|
|
994
|
+
onBlur={() => setTimeout(() => commit(), 150)}
|
|
995
|
+
onKeyDown={(e) => {
|
|
996
|
+
if (e.key === 'Enter' || e.key === ',') { e.preventDefault(); commit(matches[0] && matches[0].toLowerCase() === draft.toLowerCase() ? matches[0] : undefined); }
|
|
997
|
+
if (e.key === 'Backspace' && !draft && tags.length) onChange(tags.slice(0, -1));
|
|
998
|
+
if (e.key === 'Escape') setDraft('');
|
|
999
|
+
}}
|
|
1000
|
+
/>
|
|
1001
|
+
</div>
|
|
1002
|
+
{matches.length > 0 && (
|
|
1003
|
+
<div className="pe-tag-suggest">
|
|
1004
|
+
{matches.map((s) => (
|
|
1005
|
+
<button key={s} type="button" onMouseDown={(e) => { e.preventDefault(); commit(s); }}>{s}</button>
|
|
1006
|
+
))}
|
|
1007
|
+
</div>
|
|
1008
|
+
)}
|
|
1009
|
+
</div>
|
|
1010
|
+
);
|
|
1011
|
+
}
|