This commit is contained in:
2026-03-20 21:17:26 +01:00
parent 1a62fcb81d
commit 29c3ff8572
229 changed files with 13147 additions and 2577 deletions

View File

@@ -2,12 +2,18 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { EditorContent, Extension, useEditor } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight';
import Image from '@tiptap/extension-image';
import Link from '@tiptap/extension-link';
import Placeholder from '@tiptap/extension-placeholder';
import Underline from '@tiptap/extension-underline';
import Suggestion from '@tiptap/suggestion';
import { Node, mergeAttributes } from '@tiptap/core';
import { common, createLowlight } from 'lowlight';
import tippy from 'tippy.js';
import { buildBotFingerprint } from '../../lib/security/botFingerprint';
import TurnstileField from '../security/TurnstileField';
import Select from '../ui/Select';
type StoryType = {
slug: string;
@@ -62,6 +68,28 @@ type Props = {
csrfToken: string;
};
const EMPTY_DOC = {
type: 'doc',
content: [{ type: 'paragraph' }],
};
const lowlight = createLowlight(common);
const CODE_BLOCK_LANGUAGES = [
{ value: 'bash', label: 'Bash / Shell' },
{ value: 'plaintext', label: 'Plain text' },
{ value: 'php', label: 'PHP' },
{ value: 'javascript', label: 'JavaScript' },
{ value: 'typescript', label: 'TypeScript' },
{ value: 'json', label: 'JSON' },
{ value: 'html', label: 'HTML' },
{ value: 'css', label: 'CSS' },
{ value: 'sql', label: 'SQL' },
{ value: 'xml', label: 'XML / SVG' },
{ value: 'yaml', label: 'YAML' },
{ value: 'markdown', label: 'Markdown' },
];
const ArtworkBlock = Node.create({
name: 'artworkEmbed',
group: 'block',
@@ -230,11 +258,14 @@ const DownloadAssetBlock = Node.create({
function createSlashCommandExtension(insert: {
image: () => void;
uploadImage: () => void;
artwork: () => void;
code: () => void;
quote: () => void;
divider: () => void;
gallery: () => void;
video: () => void;
download: () => void;
}) {
return Extension.create({
name: 'slashCommands',
@@ -246,22 +277,28 @@ function createSlashCommandExtension(insert: {
startOfLine: true,
items: ({ query }: { query: string }) => {
const all = [
{ title: 'Upload Image', key: 'uploadImage' },
{ title: 'Image', key: 'image' },
{ title: 'Artwork', key: 'artwork' },
{ title: 'Code', key: 'code' },
{ title: 'Quote', key: 'quote' },
{ title: 'Divider', key: 'divider' },
{ title: 'Gallery', key: 'gallery' },
{ title: 'Video', key: 'video' },
{ title: 'Download', key: 'download' },
];
return all.filter((item) => item.key.startsWith(query.toLowerCase()));
},
command: ({ props }: { editor: any; props: { key: string } }) => {
if (props.key === 'uploadImage') insert.uploadImage();
if (props.key === 'image') insert.image();
if (props.key === 'artwork') insert.artwork();
if (props.key === 'code') insert.code();
if (props.key === 'quote') insert.quote();
if (props.key === 'divider') insert.divider();
if (props.key === 'gallery') insert.gallery();
if (props.key === 'video') insert.video();
if (props.key === 'download') insert.download();
},
render: () => {
let popup: any;
@@ -359,22 +396,37 @@ function createSlashCommandExtension(insert: {
});
}
async function requestJson<T>(url: string, method: string, body: unknown, csrfToken: string): Promise<T> {
async function botHeaders(extra: Record<string, string> = {}, captcha: { token?: string } = {}) {
const fingerprint = await buildBotFingerprint();
return {
...extra,
'X-Bot-Fingerprint': fingerprint,
...(captcha?.token ? { 'X-Captcha-Token': captcha.token } : {}),
};
}
async function requestJson<T>(url: string, method: string, body: unknown, csrfToken: string, captcha: { token?: string } = {}): Promise<T> {
const response = await fetch(url, {
method,
headers: {
headers: await botHeaders({
'Content-Type': 'application/json',
'X-CSRF-TOKEN': csrfToken,
'X-Requested-With': 'XMLHttpRequest',
},
}, captcha),
body: JSON.stringify(body),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(`Request failed: ${response.status}`);
const error = new Error((payload as any)?.message || `Request failed: ${response.status}`) as Error & { status?: number; payload?: unknown };
error.status = response.status;
error.payload = payload;
throw error;
}
return response.json() as Promise<T>;
return payload as T;
}
export default function StoryEditor({ mode, initialStory, storyTypes, endpoints, csrfToken }: Props) {
@@ -398,7 +450,26 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
const [showLivePreview, setShowLivePreview] = useState(false);
const [livePreviewHtml, setLivePreviewHtml] = useState('');
const [inlineToolbar, setInlineToolbar] = useState({ visible: false, top: 0, left: 0 });
const [fieldErrors, setFieldErrors] = useState<Record<string, string[]>>({});
const [generalError, setGeneralError] = useState('');
const [wordCount, setWordCount] = useState(0);
const [readMinutes, setReadMinutes] = useState(1);
const [codeBlockLanguage, setCodeBlockLanguage] = useState('bash');
const [isSubmitting, setIsSubmitting] = useState(false);
const [captchaState, setCaptchaState] = useState({
required: false,
token: '',
message: '',
nonce: 0,
provider: 'turnstile',
siteKey: '',
inputName: 'cf-turnstile-response',
scriptUrl: '',
});
const lastSavedRef = useRef('');
const editorRef = useRef<any>(null);
const bodyImageInputRef = useRef<HTMLInputElement | null>(null);
const coverImageInputRef = useRef<HTMLInputElement | null>(null);
const emitSaveEvent = useCallback((kind: 'autosave' | 'manual', id?: number) => {
window.dispatchEvent(new CustomEvent('story-editor:saved', {
@@ -410,6 +481,58 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
}));
}, []);
const resetCaptchaState = useCallback(() => {
setCaptchaState((prev) => ({
...prev,
required: false,
token: '',
message: '',
nonce: prev.nonce + 1,
}));
}, []);
const captureCaptchaRequirement = useCallback((payload: any = {}) => {
const requiresCaptcha = !!(payload?.requires_captcha || payload?.requiresCaptcha);
if (!requiresCaptcha) {
return false;
}
const nextCaptcha = payload?.captcha || {};
const message = payload?.errors?.captcha?.[0] || payload?.message || 'Complete the captcha challenge to continue.';
setCaptchaState((prev) => ({
required: true,
token: '',
message,
nonce: prev.nonce + 1,
provider: nextCaptcha.provider || payload?.captcha_provider || prev.provider || 'turnstile',
siteKey: nextCaptcha.siteKey || payload?.captcha_site_key || prev.siteKey || '',
inputName: nextCaptcha.inputName || payload?.captcha_input || prev.inputName || 'cf-turnstile-response',
scriptUrl: nextCaptcha.scriptUrl || payload?.captcha_script_url || prev.scriptUrl || '',
}));
return true;
}, []);
const applyFailure = useCallback((error: any, fallback: string) => {
const payload = error?.payload || {};
const nextErrors = payload?.errors && typeof payload.errors === 'object' ? payload.errors : {};
setFieldErrors(nextErrors);
const requiresCaptcha = captureCaptchaRequirement(payload);
const message = nextErrors?.captcha?.[0]
|| nextErrors?.title?.[0]
|| nextErrors?.content?.[0]
|| payload?.message
|| fallback;
setGeneralError(message);
setSaveStatus(requiresCaptcha ? 'Captcha required' : message);
}, [captureCaptchaRequirement]);
const clearFeedback = useCallback(() => {
setGeneralError('');
setFieldErrors({});
}, []);
const openLinkPrompt = useCallback((editor: any) => {
const prev = editor.getAttributes('link').href;
const url = window.prompt('Link URL', prev || 'https://');
@@ -439,66 +562,111 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
const response = await fetch(endpoints.uploadImage, {
method: 'POST',
headers: {
headers: await botHeaders({
'X-CSRF-TOKEN': csrfToken,
'X-Requested-With': 'XMLHttpRequest',
},
}, captchaState),
body: formData,
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
setFieldErrors(payload?.errors && typeof payload.errors === 'object' ? payload.errors : {});
captureCaptchaRequirement(payload);
setGeneralError(payload?.errors?.captcha?.[0] || payload?.message || 'Image upload failed');
return null;
}
const data = await response.json();
clearFeedback();
if (captchaState.required && captchaState.token) {
resetCaptchaState();
}
const data = payload;
return data.medium_url || data.original_url || data.thumbnail_url || null;
}, [endpoints.uploadImage, csrfToken]);
}, [captchaState, captureCaptchaRequirement, clearFeedback, endpoints.uploadImage, csrfToken, resetCaptchaState]);
const applyCodeBlockLanguage = useCallback((language: string) => {
const nextLanguage = (language || 'plaintext').trim() || 'plaintext';
setCodeBlockLanguage(nextLanguage);
const currentEditor = editorRef.current;
if (!currentEditor || !currentEditor.isActive('codeBlock')) {
return;
}
currentEditor.chain().focus().updateAttributes('codeBlock', { language: nextLanguage }).run();
}, []);
const toggleCodeBlockWithLanguage = useCallback(() => {
const currentEditor = editorRef.current;
if (!currentEditor) return;
if (currentEditor.isActive('codeBlock')) {
currentEditor.chain().focus().toggleCodeBlock().run();
return;
}
currentEditor.chain().focus().setCodeBlock({ language: codeBlockLanguage }).run();
}, [codeBlockLanguage]);
const insertActions = useMemo(() => ({
image: () => {
const currentEditor = editorRef.current;
const url = window.prompt('Image URL', 'https://');
if (!url || !editor) return;
editor.chain().focus().setImage({ src: url }).run();
if (!url || !currentEditor) return;
currentEditor.chain().focus().setImage({ src: url }).run();
},
uploadImage: () => bodyImageInputRef.current?.click(),
artwork: () => setArtworkModalOpen(true),
code: () => {
if (!editor) return;
editor.chain().focus().toggleCodeBlock().run();
toggleCodeBlockWithLanguage();
},
quote: () => {
if (!editor) return;
editor.chain().focus().toggleBlockquote().run();
const currentEditor = editorRef.current;
if (!currentEditor) return;
currentEditor.chain().focus().toggleBlockquote().run();
},
divider: () => {
if (!editor) return;
editor.chain().focus().setHorizontalRule().run();
const currentEditor = editorRef.current;
if (!currentEditor) return;
currentEditor.chain().focus().setHorizontalRule().run();
},
gallery: () => {
if (!editor) return;
const currentEditor = editorRef.current;
if (!currentEditor) return;
const raw = window.prompt('Gallery image URLs (comma separated)', '');
const images = (raw || '').split(',').map((value) => value.trim()).filter(Boolean);
editor.chain().focus().insertContent({ type: 'galleryBlock', attrs: { images } }).run();
currentEditor.chain().focus().insertContent({ type: 'galleryBlock', attrs: { images } }).run();
},
video: () => {
if (!editor) return;
const currentEditor = editorRef.current;
if (!currentEditor) return;
const src = window.prompt('Video embed URL (YouTube/Vimeo)', 'https://www.youtube.com/embed/');
if (!src) return;
editor.chain().focus().insertContent({ type: 'videoEmbed', attrs: { src, title: 'Embedded video' } }).run();
currentEditor.chain().focus().insertContent({ type: 'videoEmbed', attrs: { src, title: 'Embedded video' } }).run();
},
download: () => {
if (!editor) return;
const currentEditor = editorRef.current;
if (!currentEditor) return;
const url = window.prompt('Download URL', 'https://');
if (!url) return;
const label = window.prompt('Button label', 'Download asset') || 'Download asset';
editor.chain().focus().insertContent({ type: 'downloadAsset', attrs: { url, label } }).run();
currentEditor.chain().focus().insertContent({ type: 'downloadAsset', attrs: { url, label } }).run();
},
}), []);
}), [toggleCodeBlockWithLanguage]);
const editor = useEditor({
extensions: [
StarterKit.configure({
codeBlock: false,
heading: { levels: [1, 2, 3] },
}),
CodeBlockLowlight.configure({
lowlight,
}),
Underline,
Image,
Link.configure({
openOnClick: false,
@@ -517,10 +685,10 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
DownloadAssetBlock,
createSlashCommandExtension(insertActions),
],
content: initialStory.content || { type: 'doc', content: [{ type: 'paragraph' }] },
content: initialStory.content || EMPTY_DOC,
editorProps: {
attributes: {
class: 'tiptap prose prose-invert max-w-none min-h-[26rem] rounded-xl border border-gray-700 bg-gray-900/80 px-6 py-5 text-gray-200 focus:outline-none',
class: 'tiptap prose prose-invert prose-headings:tracking-tight prose-p:text-[1.04rem] prose-p:leading-8 prose-p:text-stone-200 prose-strong:text-white prose-a:text-sky-300 prose-blockquote:border-l-sky-400 prose-blockquote:text-stone-300 prose-code:text-sky-200 max-w-none min-h-[32rem] bg-transparent px-0 py-0 text-stone-200 focus:outline-none',
},
handleDrop: (_view, event) => {
const file = event.dataTransfer?.files?.[0];
@@ -559,11 +727,17 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
},
});
editorRef.current = editor;
useEffect(() => {
if (!editor) return;
const updatePreview = () => {
setLivePreviewHtml(editor.getHTML());
const text = editor.getText().replace(/\s+/g, ' ').trim();
const words = text === '' ? 0 : text.split(' ').length;
setWordCount(words);
setReadMinutes(Math.max(1, Math.ceil(words / 200)));
};
updatePreview();
@@ -574,6 +748,30 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
};
}, [editor]);
useEffect(() => {
if (!editor) return;
const syncCodeBlockLanguage = () => {
if (!editor.isActive('codeBlock')) {
return;
}
const nextLanguage = String(editor.getAttributes('codeBlock').language || '').trim();
if (nextLanguage !== '') {
setCodeBlockLanguage(nextLanguage);
}
};
syncCodeBlockLanguage();
editor.on('selectionUpdate', syncCodeBlockLanguage);
editor.on('update', syncCodeBlockLanguage);
return () => {
editor.off('selectionUpdate', syncCodeBlockLanguage);
editor.off('update', syncCodeBlockLanguage);
};
}, [editor]);
useEffect(() => {
if (!artworkModalOpen) return;
void fetchArtworks(artworkQuery);
@@ -620,13 +818,17 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
og_image: ogImage || coverImage,
status,
scheduled_for: scheduledFor || null,
content: editor?.getJSON() || { type: 'doc', content: [{ type: 'paragraph' }] },
content: editor?.getJSON() || EMPTY_DOC,
}), [storyId, title, excerpt, coverImage, storyType, tagsCsv, metaTitle, metaDescription, canonicalUrl, ogImage, status, scheduledFor, editor]);
useEffect(() => {
if (!editor) return;
const timer = window.setInterval(async () => {
if (isSubmitting) {
return;
}
const body = payload();
const snapshot = JSON.stringify(body);
if (snapshot === lastSavedRef.current) {
@@ -634,21 +836,37 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
}
try {
clearFeedback();
setSaveStatus('Saving...');
const data = await requestJson<{ story_id?: number; message?: string }>(endpoints.autosave, 'POST', body, csrfToken);
const data = await requestJson<{ story_id?: number; message?: string; edit_url?: string }>(
endpoints.autosave,
'POST',
captchaState.required && captchaState.inputName ? {
...body,
[captchaState.inputName]: captchaState.token || '',
} : body,
csrfToken,
captchaState,
);
if (data.story_id && !storyId) {
setStoryId(data.story_id);
}
if (data.edit_url && window.location.pathname.endsWith('/create')) {
window.history.replaceState({}, '', data.edit_url);
}
lastSavedRef.current = snapshot;
setSaveStatus(data.message || 'Saved just now');
if (captchaState.required && captchaState.token) {
resetCaptchaState();
}
emitSaveEvent('autosave', data.story_id || storyId);
} catch {
setSaveStatus('Autosave failed');
} catch (error) {
applyFailure(error, 'Autosave failed');
}
}, 10000);
return () => window.clearInterval(timer);
}, [editor, payload, endpoints.autosave, csrfToken, storyId, emitSaveEvent]);
}, [applyFailure, captchaState, clearFeedback, csrfToken, editor, emitSaveEvent, endpoints.autosave, isSubmitting, payload, resetCaptchaState, storyId]);
const persistStory = async (submitAction: 'save_draft' | 'submit_review' | 'publish_now' | 'schedule_publish') => {
const body = {
@@ -659,20 +877,81 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
};
try {
clearFeedback();
setIsSubmitting(true);
setSaveStatus('Saving...');
const endpoint = storyId ? endpoints.update : endpoints.create;
const method = storyId ? 'PUT' : 'POST';
const data = await requestJson<{ story_id: number; message?: string }>(endpoint, method, body, csrfToken);
const data = await requestJson<{ story_id: number; message?: string; status?: string; edit_url?: string; public_url?: string }>(endpoint, method, captchaState.required && captchaState.inputName ? {
...body,
[captchaState.inputName]: captchaState.token || '',
} : body, csrfToken, captchaState);
if (data.story_id) {
setStoryId(data.story_id);
}
if (data.edit_url && window.location.pathname.endsWith('/create')) {
window.history.replaceState({}, '', data.edit_url);
}
lastSavedRef.current = JSON.stringify(payload());
setSaveStatus(data.message || 'Saved just now');
if (captchaState.required && captchaState.token) {
resetCaptchaState();
}
emitSaveEvent('manual', data.story_id || storyId);
} catch {
setSaveStatus('Save failed');
if (submitAction === 'publish_now' && data.public_url) {
window.location.assign(data.public_url);
return;
}
} catch (error) {
applyFailure(error, submitAction === 'publish_now' ? 'Publish failed' : 'Save failed');
} finally {
setIsSubmitting(false);
}
};
const handleBodyImagePicked = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
event.target.value = '';
if (!file) return;
setSaveStatus('Uploading image...');
const uploaded = await uploadImageFile(file);
if (!uploaded || !editor) {
setSaveStatus('Image upload failed');
return;
}
editor.chain().focus().setImage({ src: uploaded }).run();
setSaveStatus('Image uploaded');
};
const handleCoverImagePicked = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
event.target.value = '';
if (!file) return;
setSaveStatus('Uploading cover...');
const uploaded = await uploadImageFile(file);
if (!uploaded) {
setSaveStatus('Cover upload failed');
return;
}
setCoverImage(uploaded);
setSaveStatus('Cover uploaded');
};
const readinessChecks = useMemo(() => ([
{ label: 'Title', ok: title.trim().length > 0, hint: 'Give the story a clear headline.' },
{ label: 'Body', ok: wordCount >= 50, hint: 'Aim for at least 50 words before publishing.' },
{ label: 'Story type', ok: storyType.trim().length > 0, hint: 'Choose the format that fits the post.' },
]), [storyType, title, wordCount]);
const titleError = fieldErrors?.title?.[0] || '';
const contentError = fieldErrors?.content?.[0] || '';
const excerptError = fieldErrors?.excerpt?.[0] || '';
const tagsError = fieldErrors?.tags_csv?.[0] || '';
const insertArtwork = (item: Artwork) => {
if (!editor) return;
editor.chain().focus().insertContent({
@@ -689,86 +968,260 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
return (
<div className="space-y-6">
<div className="rounded-xl border border-gray-700 bg-gray-800/60 p-4 shadow-lg">
<div className="space-y-4">
<input
value={title}
onChange={(event) => setTitle(event.target.value)}
placeholder="Title"
className="w-full rounded-xl border border-gray-700 bg-gray-900 px-4 py-3 text-2xl font-semibold text-gray-100"
/>
<div className="sticky top-16 z-30 overflow-hidden rounded-[1.5rem] border border-white/10 bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.18),_transparent_28%),linear-gradient(135deg,rgba(12,18,28,0.96),rgba(10,14,22,0.92))] p-4 shadow-[0_20px_70px_rgba(3,7,18,0.26)] backdrop-blur-xl">
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-2 text-xs uppercase tracking-[0.24em] text-white/45">
<span className="rounded-full border border-white/10 bg-white/[0.06] px-2.5 py-1 text-[11px] font-semibold text-white/70">{mode === 'create' ? 'New story' : 'Editing draft'}</span>
<span>{wordCount.toLocaleString()} words</span>
<span>{readMinutes} min read</span>
<span>{saveStatus}</span>
</div>
<p className="max-w-2xl text-sm text-white/62">Write in the main column, keep the sidebar for story settings, and only surface captcha when protection actually asks for it.</p>
</div>
<div className="grid gap-3 md:grid-cols-2">
<input value={excerpt} onChange={(event) => setExcerpt(event.target.value)} placeholder="Excerpt" className="rounded-xl border border-gray-700 bg-gray-900 px-3 py-2 text-sm text-gray-200" />
<select value={storyType} onChange={(event) => setStoryType(event.target.value)} className="rounded-xl border border-gray-700 bg-gray-900 px-3 py-2 text-sm text-gray-200">
{storyTypes.map((type) => (
<option key={type.slug} value={type.slug}>{type.name}</option>
))}
</select>
<input value={tagsCsv} onChange={(event) => setTagsCsv(event.target.value)} placeholder="Tags (comma separated)" className="rounded-xl border border-gray-700 bg-gray-900 px-3 py-2 text-sm text-gray-200" />
<select value={status} onChange={(event) => setStatus(event.target.value)} className="rounded-xl border border-gray-700 bg-gray-900 px-3 py-2 text-sm text-gray-200">
<option value="draft">Draft</option>
<option value="pending_review">Pending Review</option>
<option value="published">Published</option>
<option value="scheduled">Scheduled</option>
<option value="archived">Archived</option>
</select>
<input value={coverImage} onChange={(event) => setCoverImage(event.target.value)} placeholder="Cover image URL" className="rounded-xl border border-gray-700 bg-gray-900 px-3 py-2 text-sm text-gray-200" />
<input type="datetime-local" value={scheduledFor} onChange={(event) => setScheduledFor(event.target.value)} className="rounded-xl border border-gray-700 bg-gray-900 px-3 py-2 text-sm text-gray-200" />
<input value={metaTitle} onChange={(event) => setMetaTitle(event.target.value)} placeholder="Meta title" className="rounded-xl border border-gray-700 bg-gray-900 px-3 py-2 text-sm text-gray-200" />
<input value={metaDescription} onChange={(event) => setMetaDescription(event.target.value)} placeholder="Meta description" className="rounded-xl border border-gray-700 bg-gray-900 px-3 py-2 text-sm text-gray-200" />
<input value={canonicalUrl} onChange={(event) => setCanonicalUrl(event.target.value)} placeholder="Canonical URL" className="rounded-xl border border-gray-700 bg-gray-900 px-3 py-2 text-sm text-gray-200" />
<input value={ogImage} onChange={(event) => setOgImage(event.target.value)} placeholder="OG image URL" className="rounded-xl border border-gray-700 bg-gray-900 px-3 py-2 text-sm text-gray-200" />
<div className="flex flex-wrap items-center gap-2">
<button type="button" onClick={() => setShowInsertMenu((current) => !current)} className="rounded-xl border border-white/10 bg-white/[0.05] px-3 py-2 text-sm text-white/78 transition hover:bg-white/[0.09]">Insert block</button>
<button type="button" onClick={() => setShowLivePreview((current) => !current)} className="rounded-xl border border-white/10 bg-white/[0.05] px-3 py-2 text-sm text-white/78 transition hover:bg-white/[0.09]">{showLivePreview ? 'Hide preview' : 'Preview'}</button>
<button type="button" onClick={() => persistStory('save_draft')} disabled={isSubmitting} className="rounded-xl border border-white/10 bg-white/[0.05] px-3 py-2 text-sm text-white transition hover:bg-white/[0.09] disabled:opacity-60">Save draft</button>
<button type="button" onClick={() => persistStory('submit_review')} disabled={isSubmitting} className="rounded-xl border border-amber-400/30 bg-amber-400/12 px-3 py-2 text-sm text-amber-100 transition hover:bg-amber-400/20 disabled:opacity-60">Submit review</button>
<button type="button" onClick={() => persistStory('publish_now')} disabled={isSubmitting} className="rounded-xl border border-emerald-400/30 bg-emerald-400/14 px-3 py-2 text-sm font-medium text-emerald-100 transition hover:bg-emerald-400/22 disabled:opacity-60">Publish now</button>
</div>
</div>
</div>
<div className="relative rounded-xl border border-gray-700 bg-gray-800/60 p-4 shadow-lg">
<div className="mb-3 flex flex-wrap items-center gap-2">
<button type="button" onClick={() => setShowInsertMenu((current) => !current)} className="rounded-lg border border-gray-600 px-3 py-1 text-xs text-gray-200">+ Insert</button>
<button type="button" onClick={() => setShowLivePreview((current) => !current)} className="rounded-lg border border-gray-600 px-3 py-1 text-xs text-gray-200">{showLivePreview ? 'Hide Preview' : 'Live Preview'}</button>
<button type="button" onClick={() => persistStory('save_draft')} className="rounded-lg border border-gray-600 bg-gray-700/40 px-3 py-1 text-xs text-gray-200">Save Draft</button>
<button type="button" onClick={() => persistStory('submit_review')} className="rounded-lg border border-amber-500/40 bg-amber-500/10 px-3 py-1 text-xs text-amber-200">Submit for Review</button>
<button type="button" onClick={() => persistStory('publish_now')} className="rounded-lg border border-emerald-500/40 bg-emerald-500/10 px-3 py-1 text-xs text-emerald-200">Publish Now</button>
<button type="button" onClick={() => persistStory('schedule_publish')} className="rounded-lg border border-sky-500/40 bg-sky-500/10 px-3 py-1 text-xs text-sky-200">Schedule Publish</button>
<span className="ml-auto text-xs text-emerald-300">{saveStatus}</span>
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_22rem]">
<div className="space-y-6">
<section className="overflow-hidden rounded-[2rem] border border-white/10 bg-[radial-gradient(circle_at_top_left,_rgba(251,191,36,0.12),_transparent_26%),linear-gradient(180deg,rgba(16,22,33,0.96),rgba(9,12,19,0.92))] shadow-[0_22px_80px_rgba(4,8,20,0.24)]">
{coverImage ? (
<div className="relative h-56 overflow-hidden border-b border-white/10">
<img src={coverImage} alt="Story cover" className="h-full w-full object-cover" />
<div className="absolute inset-0 bg-gradient-to-t from-slate-950 via-slate-950/25 to-transparent" />
<div className="absolute bottom-4 left-4 rounded-full border border-white/15 bg-black/35 px-3 py-1 text-xs uppercase tracking-[0.24em] text-white/75">Cover preview</div>
</div>
) : null}
<div className="space-y-5 p-6 md:p-8">
<div className="flex flex-wrap items-center gap-3 text-xs uppercase tracking-[0.22em] text-white/42">
<span>{storyTypes.find((type) => type.slug === storyType)?.name || 'Story'}</span>
<span>{status.replace(/_/g, ' ')}</span>
{scheduledFor ? <span>Scheduled {scheduledFor}</span> : null}
</div>
<div>
<input
value={title}
onChange={(event) => setTitle(event.target.value)}
placeholder="Give the story a title worth opening"
className="w-full border-0 bg-transparent px-0 text-4xl font-semibold tracking-tight text-white placeholder:text-white/25 focus:outline-none md:text-5xl"
/>
{titleError ? <p className="mt-2 text-sm text-rose-300">{titleError}</p> : null}
</div>
<div>
<textarea
value={excerpt}
onChange={(event) => setExcerpt(event.target.value)}
placeholder="Write a short dek that explains why this story matters."
rows={3}
className="w-full resize-none border-0 bg-transparent px-0 text-base leading-7 text-white/70 placeholder:text-white/25 focus:outline-none"
/>
{excerptError ? <p className="mt-2 text-sm text-rose-300">{excerptError}</p> : null}
</div>
<div className="grid gap-3 sm:grid-cols-3">
<div className="rounded-2xl border border-white/10 bg-white/[0.04] p-4">
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-white/38">Words</p>
<p className="mt-2 text-2xl font-semibold text-white">{wordCount.toLocaleString()}</p>
</div>
<div className="rounded-2xl border border-white/10 bg-white/[0.04] p-4">
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-white/38">Reading time</p>
<p className="mt-2 text-2xl font-semibold text-white">{readMinutes} min</p>
</div>
<div className="rounded-2xl border border-white/10 bg-white/[0.04] p-4">
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-white/38">Status</p>
<p className="mt-2 text-2xl font-semibold capitalize text-white">{status.replace(/_/g, ' ')}</p>
</div>
</div>
</div>
</section>
<section className="relative overflow-hidden rounded-[2rem] border border-white/10 bg-[linear-gradient(180deg,rgba(16,19,28,0.98),rgba(8,10,17,0.96))] shadow-[0_24px_90px_rgba(4,8,20,0.28)]">
<div className="border-b border-white/10 px-5 py-4">
<div className="flex flex-wrap items-center gap-2">
{editor ? (
<>
<button type="button" className={`rounded-xl px-3 py-2 text-sm ${editor.isActive('bold') ? 'bg-sky-400/20 text-sky-100' : 'bg-white/[0.05] text-white/75'}`} onClick={() => editor.chain().focus().toggleBold().run()}>Bold</button>
<button type="button" className={`rounded-xl px-3 py-2 text-sm ${editor.isActive('italic') ? 'bg-sky-400/20 text-sky-100' : 'bg-white/[0.05] text-white/75'}`} onClick={() => editor.chain().focus().toggleItalic().run()}>Italic</button>
<button type="button" className={`rounded-xl px-3 py-2 text-sm ${editor.isActive('underline') ? 'bg-sky-400/20 text-sky-100' : 'bg-white/[0.05] text-white/75'}`} onClick={() => editor.chain().focus().toggleUnderline().run()}>Underline</button>
<button type="button" className={`rounded-xl px-3 py-2 text-sm ${editor.isActive('heading', { level: 2 }) ? 'bg-sky-400/20 text-sky-100' : 'bg-white/[0.05] text-white/75'}`} onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}>H2</button>
<button type="button" className={`rounded-xl px-3 py-2 text-sm ${editor.isActive('heading', { level: 3 }) ? 'bg-sky-400/20 text-sky-100' : 'bg-white/[0.05] text-white/75'}`} onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}>H3</button>
<button type="button" className={`rounded-xl px-3 py-2 text-sm ${editor.isActive('bulletList') ? 'bg-sky-400/20 text-sky-100' : 'bg-white/[0.05] text-white/75'}`} onClick={() => editor.chain().focus().toggleBulletList().run()}>Bullets</button>
<button type="button" className={`rounded-xl px-3 py-2 text-sm ${editor.isActive('orderedList') ? 'bg-sky-400/20 text-sky-100' : 'bg-white/[0.05] text-white/75'}`} onClick={() => editor.chain().focus().toggleOrderedList().run()}>Numbers</button>
<button type="button" className={`rounded-xl px-3 py-2 text-sm ${editor.isActive('blockquote') ? 'bg-sky-400/20 text-sky-100' : 'bg-white/[0.05] text-white/75'}`} onClick={() => editor.chain().focus().toggleBlockquote().run()}>Quote</button>
<button type="button" className={`rounded-xl px-3 py-2 text-sm ${editor.isActive('codeBlock') ? 'bg-sky-400/20 text-sky-100' : 'bg-white/[0.05] text-white/75'}`} onClick={toggleCodeBlockWithLanguage}>Code block</button>
<div className="inline-flex items-center gap-2 rounded-xl bg-white/[0.05] px-3 py-2 text-sm text-white/75">
<span className="text-white/50">Lang</span>
<div className="min-w-[10rem]">
<Select
value={codeBlockLanguage}
onChange={(event) => applyCodeBlockLanguage(event.target.value)}
options={CODE_BLOCK_LANGUAGES}
size="sm"
className="border-white/10 bg-slate-950/90 py-1 text-sm text-white hover:border-white/20"
/>
</div>
</div>
<button type="button" className="rounded-xl bg-white/[0.05] px-3 py-2 text-sm text-white/75" onClick={() => openLinkPrompt(editor)}>Link</button>
</>
) : null}
</div>
</div>
{showInsertMenu && (
<div className="border-b border-white/10 bg-white/[0.03] px-5 py-4">
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 xl:grid-cols-4">
<button type="button" className="rounded-xl border border-white/10 bg-white/[0.05] px-3 py-2 text-sm text-white/80" onClick={insertActions.uploadImage}>Upload image</button>
<button type="button" className="rounded-xl border border-white/10 bg-white/[0.05] px-3 py-2 text-sm text-white/80" onClick={insertActions.image}>Image URL</button>
<button type="button" className="rounded-xl border border-white/10 bg-white/[0.05] px-3 py-2 text-sm text-white/80" onClick={insertActions.artwork}>Embed artwork</button>
<button type="button" className="rounded-xl border border-white/10 bg-white/[0.05] px-3 py-2 text-sm text-white/80" onClick={insertActions.gallery}>Gallery</button>
<button type="button" className="rounded-xl border border-white/10 bg-white/[0.05] px-3 py-2 text-sm text-white/80" onClick={insertActions.video}>Video</button>
<button type="button" className="rounded-xl border border-white/10 bg-white/[0.05] px-3 py-2 text-sm text-white/80" onClick={insertActions.download}>Download</button>
<button type="button" className="rounded-xl border border-white/10 bg-white/[0.05] px-3 py-2 text-sm text-white/80" onClick={insertActions.quote}>Quote</button>
<button type="button" className="rounded-xl border border-white/10 bg-white/[0.05] px-3 py-2 text-sm text-white/80" onClick={insertActions.code}>Code block</button>
<div className="col-span-2 rounded-xl border border-white/10 bg-white/[0.05] px-3 py-2 sm:col-span-3 xl:col-span-2">
<div className="mb-2 text-sm text-white/45">Language</div>
<Select
value={codeBlockLanguage}
onChange={(event) => applyCodeBlockLanguage(event.target.value)}
options={CODE_BLOCK_LANGUAGES}
size="sm"
className="border-white/10 bg-slate-950/90 text-white hover:border-white/20"
/>
</div>
</div>
</div>
)}
{editor && inlineToolbar.visible && (
<div
className="fixed z-40 flex items-center gap-1 rounded-2xl border border-white/10 bg-slate-950/95 px-2 py-1 shadow-lg backdrop-blur"
style={{ top: `${inlineToolbar.top}px`, left: `${inlineToolbar.left}px` }}
>
<button type="button" className={`rounded px-2 py-1 text-xs ${editor.isActive('bold') ? 'bg-sky-500/30 text-sky-100' : 'text-gray-200'}`} onMouseDown={(event) => event.preventDefault()} onClick={() => editor.chain().focus().toggleBold().run()}>B</button>
<button type="button" className={`rounded px-2 py-1 text-xs ${editor.isActive('italic') ? 'bg-sky-500/30 text-sky-100' : 'text-gray-200'}`} onMouseDown={(event) => event.preventDefault()} onClick={() => editor.chain().focus().toggleItalic().run()}>I</button>
<button type="button" className={`rounded px-2 py-1 text-xs ${editor.isActive('underline') ? 'bg-sky-500/30 text-sky-100' : 'text-gray-200'}`} onMouseDown={(event) => event.preventDefault()} onClick={() => editor.chain().focus().toggleUnderline().run()}>U</button>
<button type="button" className={`rounded px-2 py-1 text-xs ${editor.isActive('code') ? 'bg-sky-500/30 text-sky-100' : 'text-gray-200'}`} onMouseDown={(event) => event.preventDefault()} onClick={() => editor.chain().focus().toggleCode().run()}>{'</>'}</button>
<button type="button" className="rounded px-2 py-1 text-xs text-gray-200" onMouseDown={(event) => event.preventDefault()} onClick={() => openLinkPrompt(editor)}>Link</button>
</div>
)}
<div className="px-6 py-8 md:px-10 md:py-10">
<EditorContent editor={editor} />
{contentError ? <p className="mt-4 text-sm text-rose-300">{contentError}</p> : null}
</div>
{showLivePreview && (
<div className="border-t border-white/10 bg-white/[0.02] px-6 py-6 md:px-10">
<div className="mb-3 text-xs font-semibold uppercase tracking-[0.22em] text-white/40">Live preview</div>
<div className="prose prose-invert max-w-none prose-pre:bg-slate-950 prose-p:text-stone-200" dangerouslySetInnerHTML={{ __html: livePreviewHtml }} />
</div>
)}
</section>
</div>
{showInsertMenu && (
<div className="mb-3 grid grid-cols-2 gap-2 rounded-xl border border-gray-700 bg-gray-900/90 p-2 sm:grid-cols-3">
<button type="button" className="rounded-md border border-gray-700 px-2 py-1 text-xs text-gray-200" onClick={insertActions.image}>Image</button>
<button type="button" className="rounded-md border border-gray-700 px-2 py-1 text-xs text-gray-200" onClick={insertActions.artwork}>Embed Artwork</button>
<button type="button" className="rounded-md border border-gray-700 px-2 py-1 text-xs text-gray-200" onClick={insertActions.code}>Code Block</button>
<button type="button" className="rounded-md border border-gray-700 px-2 py-1 text-xs text-gray-200" onClick={insertActions.quote}>Quote</button>
<button type="button" className="rounded-md border border-gray-700 px-2 py-1 text-xs text-gray-200" onClick={insertActions.divider}>Divider</button>
<button type="button" className="rounded-md border border-gray-700 px-2 py-1 text-xs text-gray-200" onClick={insertActions.gallery}>Gallery</button>
<button type="button" className="rounded-md border border-gray-700 px-2 py-1 text-xs text-gray-200" onClick={insertActions.video}>Video Embed</button>
<button type="button" className="rounded-md border border-gray-700 px-2 py-1 text-xs text-gray-200" onClick={insertActions.download}>Download Asset</button>
</div>
)}
<aside className="space-y-4 xl:sticky xl:top-24 self-start">
{(generalError || captchaState.required) && (
<section className="rounded-[1.5rem] border border-amber-400/20 bg-amber-500/10 p-5">
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-amber-100/70">Action needed</p>
<p className="mt-3 text-sm text-amber-50">{generalError || captchaState.message || 'Complete the captcha challenge to continue.'}</p>
{captchaState.required && captchaState.siteKey ? (
<div className="mt-4 rounded-2xl border border-white/10 bg-black/20 p-3">
<TurnstileField
key={`story-editor-captcha-${captchaState.nonce}`}
provider={captchaState.provider}
siteKey={captchaState.siteKey}
scriptUrl={captchaState.scriptUrl}
onToken={(token) => setCaptchaState((prev) => ({ ...prev, token }))}
className="min-h-16"
/>
</div>
) : null}
</section>
)}
{editor && inlineToolbar.visible && (
<div
className="fixed z-40 flex items-center gap-1 rounded-lg border border-gray-700 bg-gray-900 px-2 py-1 shadow-lg"
style={{ top: `${inlineToolbar.top}px`, left: `${inlineToolbar.left}px` }}
>
<button type="button" className={`rounded px-2 py-1 text-xs ${editor.isActive('bold') ? 'bg-sky-500/30 text-sky-100' : 'text-gray-200'}`} onMouseDown={(event) => event.preventDefault()} onClick={() => editor.chain().focus().toggleBold().run()}>B</button>
<button type="button" className={`rounded px-2 py-1 text-xs ${editor.isActive('italic') ? 'bg-sky-500/30 text-sky-100' : 'text-gray-200'}`} onMouseDown={(event) => event.preventDefault()} onClick={() => editor.chain().focus().toggleItalic().run()}>I</button>
<button type="button" className={`rounded px-2 py-1 text-xs ${editor.isActive('code') ? 'bg-sky-500/30 text-sky-100' : 'text-gray-200'}`} onMouseDown={(event) => event.preventDefault()} onClick={() => editor.chain().focus().toggleCode().run()}>{'</>'}</button>
<button type="button" className="rounded px-2 py-1 text-xs text-gray-200" onMouseDown={(event) => event.preventDefault()} onClick={() => openLinkPrompt(editor)}>Link</button>
<button type="button" className="rounded px-2 py-1 text-xs text-gray-200" onMouseDown={(event) => event.preventDefault()} onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}>H2</button>
<button type="button" className="rounded px-2 py-1 text-xs text-gray-200" onMouseDown={(event) => event.preventDefault()} onClick={() => editor.chain().focus().toggleBlockquote().run()}>Quote</button>
</div>
)}
<section className="rounded-[1.5rem] border border-white/10 bg-white/[0.03] p-5">
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-white/38">Publish checklist</p>
<div className="mt-4 space-y-3">
{readinessChecks.map((item) => (
<div key={item.label} className="rounded-2xl border border-white/8 bg-black/10 px-4 py-3">
<div className="flex items-center justify-between gap-3">
<span className="text-sm font-medium text-white">{item.label}</span>
<span className={`text-xs font-semibold uppercase tracking-[0.18em] ${item.ok ? 'text-emerald-300' : 'text-amber-200'}`}>{item.ok ? 'Ready' : 'Needs work'}</span>
</div>
<p className="mt-2 text-sm text-white/48">{item.hint}</p>
</div>
))}
</div>
</section>
<EditorContent editor={editor} />
<section className="rounded-[1.5rem] border border-white/10 bg-white/[0.03] p-5">
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-white/38">Story settings</p>
<div className="mt-4 space-y-4">
<div>
<label className="mb-2 block text-sm font-medium text-white/80">Story type</label>
<select value={storyType} onChange={(event) => setStoryType(event.target.value)} className="w-full rounded-xl border border-white/10 bg-slate-950/80 px-3 py-2.5 text-sm text-white">
{storyTypes.map((type) => (
<option key={type.slug} value={type.slug}>{type.name}</option>
))}
</select>
</div>
{showLivePreview && (
<div className="mt-4 rounded-xl border border-gray-700 bg-gray-900/60 p-4">
<div className="mb-2 text-xs font-semibold uppercase tracking-wide text-gray-400">Live Preview</div>
<div className="prose prose-invert max-w-none prose-pre:bg-gray-900" dangerouslySetInnerHTML={{ __html: livePreviewHtml }} />
</div>
)}
<div>
<label className="mb-2 block text-sm font-medium text-white/80">Tags</label>
<input value={tagsCsv} onChange={(event) => setTagsCsv(event.target.value)} placeholder="art direction, process, workflow" className="w-full rounded-xl border border-white/10 bg-slate-950/80 px-3 py-2.5 text-sm text-white placeholder:text-white/25" />
{tagsError ? <p className="mt-2 text-sm text-rose-300">{tagsError}</p> : <p className="mt-2 text-xs text-white/40">Comma-separated. New tags are created automatically.</p>}
</div>
<div>
<label className="mb-2 block text-sm font-medium text-white/80">Workflow status</label>
<select value={status} onChange={(event) => setStatus(event.target.value)} className="w-full rounded-xl border border-white/10 bg-slate-950/80 px-3 py-2.5 text-sm text-white">
<option value="draft">Draft</option>
<option value="pending_review">Pending Review</option>
<option value="published">Published</option>
<option value="scheduled">Scheduled</option>
<option value="archived">Archived</option>
</select>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-white/80">Schedule publish</label>
<input type="datetime-local" value={scheduledFor} onChange={(event) => setScheduledFor(event.target.value)} className="w-full rounded-xl border border-white/10 bg-slate-950/80 px-3 py-2.5 text-sm text-white" />
</div>
</div>
</section>
<section className="rounded-[1.5rem] border border-white/10 bg-white/[0.03] p-5">
<div className="flex items-center justify-between gap-3">
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-white/38">Cover</p>
<button type="button" onClick={() => coverImageInputRef.current?.click()} className="rounded-xl border border-white/10 bg-white/[0.05] px-3 py-2 text-xs text-white/78">Upload</button>
</div>
<div className="mt-4 space-y-3">
<input value={coverImage} onChange={(event) => setCoverImage(event.target.value)} placeholder="https://..." className="w-full rounded-xl border border-white/10 bg-slate-950/80 px-3 py-2.5 text-sm text-white placeholder:text-white/25" />
{coverImage ? <img src={coverImage} alt="Cover preview" className="h-40 w-full rounded-2xl object-cover" /> : <div className="rounded-2xl border border-dashed border-white/10 px-4 py-8 text-center text-sm text-white/38">Add a cover image to give the story more presence in feeds.</div>}
</div>
</section>
<section className="rounded-[1.5rem] border border-white/10 bg-white/[0.03] p-5">
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-white/38">SEO & social</p>
<div className="mt-4 space-y-3">
<input value={metaTitle} onChange={(event) => setMetaTitle(event.target.value)} placeholder="Meta title" className="w-full rounded-xl border border-white/10 bg-slate-950/80 px-3 py-2.5 text-sm text-white placeholder:text-white/25" />
<textarea value={metaDescription} onChange={(event) => setMetaDescription(event.target.value)} rows={3} placeholder="Meta description" className="w-full resize-none rounded-xl border border-white/10 bg-slate-950/80 px-3 py-2.5 text-sm text-white placeholder:text-white/25" />
<input value={canonicalUrl} onChange={(event) => setCanonicalUrl(event.target.value)} placeholder="Canonical URL" className="w-full rounded-xl border border-white/10 bg-slate-950/80 px-3 py-2.5 text-sm text-white placeholder:text-white/25" />
<input value={ogImage} onChange={(event) => setOgImage(event.target.value)} placeholder="OG image URL" className="w-full rounded-xl border border-white/10 bg-slate-950/80 px-3 py-2.5 text-sm text-white placeholder:text-white/25" />
</div>
</section>
</aside>
</div>
<div className="flex flex-wrap gap-3">
@@ -791,6 +1244,9 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
)}
</div>
<input ref={bodyImageInputRef} type="file" accept="image/*" className="hidden" onChange={handleBodyImagePicked} />
<input ref={coverImageInputRef} type="file" accept="image/*" className="hidden" onChange={handleCoverImagePicked} />
{artworkModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4">
<div className="w-full max-w-3xl rounded-xl border border-gray-700 bg-gray-900 p-4 shadow-lg">