// @ts-nocheck 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; name: string; }; type Artwork = { id: number; title: string; url: string; thumb: string | null; thumbs?: { xs?: string | null; sm?: string | null; md?: string | null; lg?: string | null; xl?: string | null; }; }; type StoryPayload = { id?: number; title: string; excerpt: string; cover_image: string; story_type: string; tags_csv: string; meta_title: string; meta_description: string; canonical_url: string; og_image: string; status: string; scheduled_for: string; content: Record; }; type Endpoints = { create: string; update: string; autosave: string; uploadImage: string; artworks: string; previewBase: string; analyticsBase: string; }; type Props = { mode: 'create' | 'edit'; initialStory: StoryPayload; storyTypes: StoryType[]; endpoints: Endpoints; 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', atom: true, addAttributes() { return { artworkId: { default: null }, title: { default: '' }, url: { default: '' }, thumb: { default: '' }, }; }, parseHTML() { return [{ tag: 'figure[data-artwork-embed]' }]; }, renderHTML({ HTMLAttributes }) { return [ 'figure', mergeAttributes(HTMLAttributes, { 'data-artwork-embed': 'true', class: 'my-4 overflow-hidden rounded-xl border border-gray-700 bg-gray-800/70', }), [ 'a', { href: HTMLAttributes.url || '#', class: 'block', rel: 'noopener noreferrer nofollow', target: '_blank', }, [ 'img', { src: HTMLAttributes.thumb || '', alt: HTMLAttributes.title || 'Artwork', class: 'h-48 w-full object-cover', loading: 'lazy', }, ], [ 'figcaption', { class: 'p-3 text-sm text-gray-200' }, `${HTMLAttributes.title || 'Artwork'} (#${HTMLAttributes.artworkId || 'n/a'})`, ], ], ]; }, }); const GalleryBlock = Node.create({ name: 'galleryBlock', group: 'block', atom: true, addAttributes() { return { images: { default: [] }, }; }, parseHTML() { return [{ tag: 'div[data-gallery-block]' }]; }, renderHTML({ HTMLAttributes }) { const images = Array.isArray(HTMLAttributes.images) ? HTMLAttributes.images : []; const children: Array = images.slice(0, 6).map((src: string) => [ 'img', { src, class: 'h-36 w-full rounded-lg object-cover', loading: 'lazy', alt: 'Gallery image' }, ]); if (children.length === 0) { children.push(['div', { class: 'rounded-lg border border-dashed border-gray-600 p-4 text-xs text-gray-400' }, 'Empty gallery block']); } return [ 'div', mergeAttributes(HTMLAttributes, { 'data-gallery-block': 'true', class: 'my-4 grid grid-cols-2 gap-3 rounded-xl border border-gray-700 bg-gray-800/50 p-3', }), ...children, ]; }, }); const VideoEmbedBlock = Node.create({ name: 'videoEmbed', group: 'block', atom: true, addAttributes() { return { src: { default: '' }, title: { default: 'Embedded video' }, }; }, parseHTML() { return [{ tag: 'figure[data-video-embed]' }]; }, renderHTML({ HTMLAttributes }) { return [ 'figure', mergeAttributes(HTMLAttributes, { 'data-video-embed': 'true', class: 'my-4 overflow-hidden rounded-xl border border-gray-700 bg-gray-800/60', }), [ 'iframe', { src: HTMLAttributes.src || '', title: HTMLAttributes.title || 'Embedded video', class: 'aspect-video w-full', allow: 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share', allowfullscreen: 'true', frameborder: '0', referrerpolicy: 'strict-origin-when-cross-origin', }, ], ]; }, }); const DownloadAssetBlock = Node.create({ name: 'downloadAsset', group: 'block', atom: true, addAttributes() { return { url: { default: '' }, label: { default: 'Download asset' }, }; }, parseHTML() { return [{ tag: 'div[data-download-asset]' }]; }, renderHTML({ HTMLAttributes }) { return [ 'div', mergeAttributes(HTMLAttributes, { 'data-download-asset': 'true', class: 'my-4 rounded-xl border border-gray-700 bg-gray-800/60 p-4', }), [ 'a', { href: HTMLAttributes.url || '#', class: 'inline-flex items-center rounded-lg border border-sky-500/40 bg-sky-500/10 px-3 py-2 text-sm text-sky-200', target: '_blank', rel: 'noopener noreferrer nofollow', download: 'true', }, HTMLAttributes.label || 'Download asset', ], ]; }, }); 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', addOptions() { return { suggestion: { char: '/', 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; let root: HTMLDivElement | null = null; let selected = 0; let items: Array<{ title: string; key: string }> = []; let command: ((item: { title: string; key: string }) => void) | null = null; const draw = () => { if (!root) return; root.innerHTML = items .map((item, index) => { const active = index === selected ? 'bg-sky-500/20 text-sky-200' : 'text-gray-200'; return ``; }) .join(''); root.querySelectorAll('button').forEach((button) => { button.addEventListener('mousedown', (event) => { event.preventDefault(); const idx = Number((event.currentTarget as HTMLButtonElement).dataset.index || 0); const choice = items[idx]; if (choice && command) command(choice); }); }); }; return { onStart: (props: any) => { items = props.items; command = props.command; selected = 0; root = document.createElement('div'); root.className = 'w-52 rounded-lg border border-gray-700 bg-gray-900 p-1 shadow-xl'; draw(); popup = tippy('body', { getReferenceClientRect: props.clientRect, appendTo: () => document.body, content: root, showOnCreate: true, interactive: true, trigger: 'manual', placement: 'bottom-start', }); }, onUpdate: (props: any) => { items = props.items; command = props.command; if (selected >= items.length) selected = 0; draw(); popup?.[0]?.setProps({ getReferenceClientRect: props.clientRect }); }, onKeyDown: (props: any) => { if (props.event.key === 'ArrowDown') { selected = (selected + 1) % Math.max(items.length, 1); draw(); return true; } if (props.event.key === 'ArrowUp') { selected = (selected + Math.max(items.length, 1) - 1) % Math.max(items.length, 1); draw(); return true; } if (props.event.key === 'Enter') { const choice = items[selected]; if (choice && command) command(choice); return true; } if (props.event.key === 'Escape') { popup?.[0]?.hide(); return true; } return false; }, onExit: () => { popup?.[0]?.destroy(); popup = null; root = null; }, }; }, }, }; }, addProseMirrorPlugins() { return [ Suggestion({ editor: this.editor, ...this.options.suggestion, }), ]; }, }); } async function botHeaders(extra: Record = {}, captcha: { token?: string } = {}) { const fingerprint = await buildBotFingerprint(); return { ...extra, 'X-Bot-Fingerprint': fingerprint, ...(captcha?.token ? { 'X-Captcha-Token': captcha.token } : {}), }; } async function requestJson(url: string, method: string, body: unknown, csrfToken: string, captcha: { token?: string } = {}): Promise { const response = await fetch(url, { method, 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) { 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 payload as T; } export default function StoryEditor({ mode, initialStory, storyTypes, endpoints, csrfToken }: Props) { const [storyId, setStoryId] = useState(initialStory.id); const [title, setTitle] = useState(initialStory.title || ''); const [excerpt, setExcerpt] = useState(initialStory.excerpt || ''); const [coverImage, setCoverImage] = useState(initialStory.cover_image || ''); const [storyType, setStoryType] = useState(initialStory.story_type || 'creator_story'); const [tagsCsv, setTagsCsv] = useState(initialStory.tags_csv || ''); const [metaTitle, setMetaTitle] = useState(initialStory.meta_title || ''); const [metaDescription, setMetaDescription] = useState(initialStory.meta_description || ''); const [canonicalUrl, setCanonicalUrl] = useState(initialStory.canonical_url || ''); const [ogImage, setOgImage] = useState(initialStory.og_image || ''); const [status, setStatus] = useState(initialStory.status || 'draft'); const [scheduledFor, setScheduledFor] = useState(initialStory.scheduled_for || ''); const [saveStatus, setSaveStatus] = useState('Autosave idle'); const [artworkModalOpen, setArtworkModalOpen] = useState(false); const [artworkResults, setArtworkResults] = useState([]); const [artworkQuery, setArtworkQuery] = useState(''); const [showInsertMenu, setShowInsertMenu] = useState(false); const [showLivePreview, setShowLivePreview] = useState(false); const [livePreviewHtml, setLivePreviewHtml] = useState(''); const [inlineToolbar, setInlineToolbar] = useState({ visible: false, top: 0, left: 0 }); const [fieldErrors, setFieldErrors] = useState>({}); 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(null); const bodyImageInputRef = useRef(null); const coverImageInputRef = useRef(null); const emitSaveEvent = useCallback((kind: 'autosave' | 'manual', id?: number) => { window.dispatchEvent(new CustomEvent('story-editor:saved', { detail: { kind, storyId: id, savedAt: new Date().toISOString(), }, })); }, []); 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://'); if (url === null) return; if (url.trim() === '') { editor.chain().focus().unsetLink().run(); return; } editor.chain().focus().setLink({ href: url.trim() }).run(); }, []); const fetchArtworks = useCallback(async (query: string) => { const q = encodeURIComponent(query); const response = await fetch(`${endpoints.artworks}?q=${q}`, { headers: { 'X-Requested-With': 'XMLHttpRequest', }, }); if (!response.ok) return; const data = await response.json(); setArtworkResults(Array.isArray(data.artworks) ? data.artworks : []); }, [endpoints.artworks]); const uploadImageFile = useCallback(async (file: File): Promise => { const formData = new FormData(); formData.append('image', file); const response = await fetch(endpoints.uploadImage, { method: 'POST', 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; } clearFeedback(); if (captchaState.required && captchaState.token) { resetCaptchaState(); } const data = payload; return data.medium_url || data.original_url || data.thumbnail_url || null; }, [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 || !currentEditor) return; currentEditor.chain().focus().setImage({ src: url }).run(); }, uploadImage: () => bodyImageInputRef.current?.click(), artwork: () => setArtworkModalOpen(true), code: () => { toggleCodeBlockWithLanguage(); }, quote: () => { const currentEditor = editorRef.current; if (!currentEditor) return; currentEditor.chain().focus().toggleBlockquote().run(); }, divider: () => { const currentEditor = editorRef.current; if (!currentEditor) return; currentEditor.chain().focus().setHorizontalRule().run(); }, gallery: () => { 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); currentEditor.chain().focus().insertContent({ type: 'galleryBlock', attrs: { images } }).run(); }, video: () => { const currentEditor = editorRef.current; if (!currentEditor) return; const src = window.prompt('Video embed URL (YouTube/Vimeo)', 'https://www.youtube.com/embed/'); if (!src) return; currentEditor.chain().focus().insertContent({ type: 'videoEmbed', attrs: { src, title: 'Embedded video' } }).run(); }, download: () => { 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'; 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, HTMLAttributes: { class: 'text-sky-300 underline', rel: 'noopener noreferrer nofollow', target: '_blank', }, }), Placeholder.configure({ placeholder: 'Start writing your story...', }), ArtworkBlock, GalleryBlock, VideoEmbedBlock, DownloadAssetBlock, createSlashCommandExtension(insertActions), ], content: initialStory.content || EMPTY_DOC, editorProps: { attributes: { 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]; if (!file || !file.type.startsWith('image/')) return false; void (async () => { setSaveStatus('Uploading image...'); const uploaded = await uploadImageFile(file); if (uploaded && editor) { editor.chain().focus().setImage({ src: uploaded }).run(); setSaveStatus('Image uploaded'); } else { setSaveStatus('Image upload failed'); } })(); return true; }, handlePaste: (_view, event) => { const file = event.clipboardData?.files?.[0]; if (!file || !file.type.startsWith('image/')) return false; void (async () => { setSaveStatus('Uploading image...'); const uploaded = await uploadImageFile(file); if (uploaded && editor) { editor.chain().focus().setImage({ src: uploaded }).run(); setSaveStatus('Image uploaded'); } else { setSaveStatus('Image upload failed'); } })(); return true; }, }, }); 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(); editor.on('update', updatePreview); return () => { editor.off('update', updatePreview); }; }, [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); }, [artworkModalOpen, artworkQuery, fetchArtworks]); useEffect(() => { if (!editor) return; const updateToolbar = () => { const { from, to } = editor.state.selection; if (from === to) { setInlineToolbar({ visible: false, top: 0, left: 0 }); return; } const start = editor.view.coordsAtPos(from); const end = editor.view.coordsAtPos(to); setInlineToolbar({ visible: true, top: Math.max(10, start.top + window.scrollY - 48), left: Math.max(10, (start.left + end.right) / 2 + window.scrollX - 120), }); }; editor.on('selectionUpdate', updateToolbar); editor.on('blur', () => setInlineToolbar({ visible: false, top: 0, left: 0 })); return () => { editor.off('selectionUpdate', updateToolbar); }; }, [editor]); const payload = useCallback(() => ({ story_id: storyId, title, excerpt, cover_image: coverImage, story_type: storyType, tags_csv: tagsCsv, tags: tagsCsv.split(',').map((tag) => tag.trim()).filter(Boolean), meta_title: metaTitle || title, meta_description: metaDescription || excerpt, canonical_url: canonicalUrl, og_image: ogImage || coverImage, status, scheduled_for: scheduledFor || null, 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) { return; } try { clearFeedback(); setSaveStatus('Saving...'); 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 (error) { applyFailure(error, 'Autosave failed'); } }, 10000); return () => window.clearInterval(timer); }, [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 = { ...payload(), submit_action: submitAction, status: submitAction === 'submit_review' ? 'pending_review' : submitAction === 'publish_now' ? 'published' : submitAction === 'schedule_publish' ? 'scheduled' : status, scheduled_for: submitAction === 'schedule_publish' ? scheduledFor : null, }; 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; 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); 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) => { 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) => { 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({ type: 'artworkEmbed', attrs: { artworkId: item.id, title: item.title, url: item.url, thumb: item.thumbs?.md || item.thumbs?.sm || item.thumb || '', }, }).run(); setArtworkModalOpen(false); }; return (
{mode === 'create' ? 'New story' : 'Editing draft'} {wordCount.toLocaleString()} words {readMinutes} min read {saveStatus}

Write in the main column, keep the sidebar for story settings, and only surface captcha when protection actually asks for it.

{coverImage ? (
Story cover
Cover preview
) : null}
{storyTypes.find((type) => type.slug === storyType)?.name || 'Story'} {status.replace(/_/g, ' ')} {scheduledFor ? Scheduled {scheduledFor} : null}
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 ?

{titleError}

: null}