// @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 DateTimePicker from '../ui/DateTimePicker'; import Modal from '../ui/Modal'; import NovaSelect from '../ui/NovaSelect'; 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; 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; }; type InsertDialogKind = 'image' | 'video' | 'download' | 'link' | null; 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 INSERT_DIALOG_CONTENT = { image: { title: 'Add image from URL', description: 'Paste a direct image URL to insert a full image block into the story body.', confirmLabel: 'Insert image', urlLabel: 'Image URL', urlPlaceholder: 'https://images.example.com/story-scene.jpg', urlHint: 'Use a direct image file URL when possible for the most reliable preview.', }, video: { title: 'Embed a video', description: 'Paste a YouTube or Vimeo link. Common watch and share URLs will be converted to embed URLs automatically.', confirmLabel: 'Embed video', urlLabel: 'Video URL', urlPlaceholder: 'https://www.youtube.com/watch?v=example', urlHint: 'You can paste a normal watch URL, share URL, or a direct embed URL.', }, download: { title: 'Add a download link', description: 'Create a downloadable asset button with a friendly label for readers.', confirmLabel: 'Add download', urlLabel: 'File URL', urlPlaceholder: 'https://cdn.example.com/files/asset.zip', urlHint: 'Point this at the exact file you want readers to download.', }, link: { title: 'Add link to selection', description: 'Attach a link to the currently selected text in your story.', confirmLabel: 'Save link', urlLabel: 'Link URL', urlPlaceholder: 'https://skinbase.org/help', urlHint: 'Paste any http or https URL. Leave it empty and use Remove link to clear an existing link.', }, }; const INSERT_DIALOG_INITIAL_STATE = { kind: null as InsertDialogKind, url: '', title: '', label: 'Download asset', error: '', }; function normalizeHttpUrl(rawValue: string): string | null { const trimmed = rawValue.trim(); if (trimmed === '') { return null; } const withProtocol = /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed) ? trimmed : `https://${trimmed.replace(/^\/+/, '')}`; try { const parsed = new URL(withProtocol); if (!['http:', 'https:'].includes(parsed.protocol)) { return null; } return parsed.toString(); } catch { return null; } } function normalizeVideoEmbedUrl(rawValue: string): string | null { const normalized = normalizeHttpUrl(rawValue); if (!normalized) { return null; } const parsed = new URL(normalized); const host = parsed.hostname.replace(/^www\./i, '').toLowerCase(); const path = parsed.pathname; if (host === 'youtu.be') { const videoId = path.replace(/^\//, '').split('/')[0]; return videoId ? `https://www.youtube.com/embed/${videoId}` : normalized; } if (host === 'youtube.com' || host === 'm.youtube.com') { if (path === '/watch') { const videoId = parsed.searchParams.get('v'); return videoId ? `https://www.youtube.com/embed/${videoId}` : normalized; } const pathMatch = path.match(/^\/(embed|shorts|live)\/([^/?#]+)/i); if (pathMatch?.[2]) { return `https://www.youtube.com/embed/${pathMatch[2]}`; } } if (host === 'vimeo.com') { const videoId = path.replace(/^\//, '').split('/')[0]; return videoId ? `https://player.vimeo.com/video/${videoId}` : normalized; } if (host === 'player.vimeo.com') { return normalized; } return normalized; } 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; part: () => 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: 'Add a new part', key: 'part' }, { 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 === 'part') insert.part(); 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 [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 [inlineToolbar, setInlineToolbar] = useState({ visible: false, top: 0, left: 0 }); const [fieldErrors, setFieldErrors] = useState>({}); const [generalError, setGeneralError] = useState(''); const [insertDialog, setInsertDialog] = useState(INSERT_DIALOG_INITIAL_STATE); const [wordCount, setWordCount] = useState(0); const [readMinutes, setReadMinutes] = useState(1); const [codeBlockLanguage, setCodeBlockLanguage] = useState('bash'); const [isSubmitting, setIsSubmitting] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false); const [focusMode, setFocusMode] = useState(false); const [plusMenuOpen, setPlusMenuOpen] = useState(false); const [plusButtonState, setPlusButtonState] = useState({ visible: false, top: 0, left: 0 }); const editorContainerRef = useRef(null); const insertSelectionRef = useRef<{ from: number; to: number } | null>(null); const titleInputRef = useRef(null); const excerptInputRef = useRef(null); 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 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 closeInsertDialog = useCallback(() => { insertSelectionRef.current = null; setInsertDialog(INSERT_DIALOG_INITIAL_STATE); }, []); const openInsertDialog = useCallback((kind: Exclude) => { const currentEditor = editorRef.current; if (!currentEditor) { return; } const { from, to } = currentEditor.state.selection; insertSelectionRef.current = { from, to }; setInsertDialog({ kind, url: '', title: kind === 'video' ? 'Embedded video' : '', label: 'Download asset', error: '', }); }, []); const openLinkDialog = useCallback(() => { const currentEditor = editorRef.current; if (!currentEditor) { return; } const { from, to } = currentEditor.state.selection; if (from === to) { return; } insertSelectionRef.current = { from, to }; setInsertDialog({ kind: 'link', url: currentEditor.getAttributes('link').href || '', title: '', label: 'Download asset', error: '', }); }, []); const removeSelectedLink = useCallback(() => { const currentEditor = editorRef.current; if (!currentEditor) { closeInsertDialog(); return; } const selection = insertSelectionRef.current; const chain = currentEditor.chain().focus(); if (selection) { chain.setTextSelection(selection).extendMarkRange('link'); } chain.unsetLink().run(); closeInsertDialog(); }, [closeInsertDialog]); const submitInsertDialog = useCallback((event?: React.FormEvent) => { event?.preventDefault(); if (!insertDialog.kind) { return; } const currentEditor = editorRef.current; if (!currentEditor) { closeInsertDialog(); return; } if (insertDialog.kind === 'link') { const selection = insertSelectionRef.current; const chain = currentEditor.chain().focus(); if (selection) { chain.setTextSelection(selection).extendMarkRange('link'); } const normalizedLink = normalizeHttpUrl(insertDialog.url); if (!normalizedLink) { setInsertDialog((previous) => ({ ...previous, error: 'Enter a valid http or https URL for the selected text.', })); return; } chain.setLink({ href: normalizedLink }).run(); closeInsertDialog(); return; } let normalizedUrl = normalizeHttpUrl(insertDialog.url); if (insertDialog.kind === 'video') { normalizedUrl = normalizeVideoEmbedUrl(insertDialog.url); } if (!normalizedUrl) { setInsertDialog((previous) => ({ ...previous, error: insertDialog.kind === 'video' ? 'Enter a valid YouTube, Vimeo, or direct embed URL.' : 'Enter a valid http or https URL.', })); return; } const selection = insertSelectionRef.current; const chain = currentEditor.chain().focus(); if (selection) { chain.setTextSelection(selection); } if (insertDialog.kind === 'image') { chain.setImage({ src: normalizedUrl }).run(); closeInsertDialog(); return; } if (insertDialog.kind === 'video') { chain.insertContent({ type: 'videoEmbed', attrs: { src: normalizedUrl, title: insertDialog.title.trim() || 'Embedded video', }, }).run(); closeInsertDialog(); return; } chain.insertContent({ type: 'downloadAsset', attrs: { url: normalizedUrl, label: insertDialog.label.trim() || 'Download asset', }, }).run(); closeInsertDialog(); }, [closeInsertDialog, insertDialog]); const insertActions = useMemo(() => ({ image: () => { openInsertDialog('image'); }, 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(); }, part: () => { 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: () => { openInsertDialog('video'); }, download: () => { openInsertDialog('download'); }, }), [openInsertDialog, toggleCodeBlockWithLanguage]); const editor = useEditor({ extensions: [ StarterKit.configure({ codeBlock: false, link: false, underline: 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), ], immediatelyRender: false, content: initialStory.content || EMPTY_DOC, editorProps: { attributes: { class: 'tiptap prose prose-xl prose-headings:text-white prose-headings:font-bold prose-headings:tracking-tight prose-p:text-white/90 prose-p:leading-[1.9] prose-li:leading-[1.9] prose-strong:text-white prose-a:text-sky-400 prose-a:underline prose-blockquote:border-l-sky-400/60 prose-blockquote:text-white/65 prose-blockquote:italic prose-code:text-sky-300 prose-pre:bg-black/30 prose-pre:text-sky-100 max-w-none min-h-[32rem] bg-transparent px-0 py-0 text-white/90 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 = () => { 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]); useEffect(() => { if (!editor) return; const hidePlusButton = () => { setPlusButtonState({ visible: false, top: 0, left: 0 }); setPlusMenuOpen(false); }; const updatePlusButton = () => { const { from, to } = editor.state.selection; if (from !== to || !editor.isFocused) { hidePlusButton(); return; } const container = editorContainerRef.current; if (!container) { hidePlusButton(); return; } const domAtPos = editor.view.domAtPos(from); const anchorNode = domAtPos.node instanceof Element ? domAtPos.node : domAtPos.node.parentElement; const blockElement = anchorNode?.closest('p, h1, h2, h3, blockquote, pre, li'); if (!blockElement || !container.contains(blockElement)) { hidePlusButton(); return; } const blockRect = blockElement.getBoundingClientRect(); const computedStyle = window.getComputedStyle(blockElement); const parsedLineHeight = Number.parseFloat(computedStyle.lineHeight); const lineHeight = Number.isFinite(parsedLineHeight) ? parsedLineHeight : 32; setPlusButtonState({ visible: true, top: blockRect.top + Math.max((lineHeight - 32) / 2, 0), left: Math.max(16, blockRect.left - 44), }); }; editor.on('selectionUpdate', updatePlusButton); editor.on('update', updatePlusButton); editor.on('focus', updatePlusButton); editor.on('blur', hidePlusButton); const frameId = window.requestAnimationFrame(updatePlusButton); window.addEventListener('scroll', updatePlusButton, true); window.addEventListener('resize', updatePlusButton); return () => { window.cancelAnimationFrame(frameId); window.removeEventListener('scroll', updatePlusButton, true); window.removeEventListener('resize', updatePlusButton); editor.off('selectionUpdate', updatePlusButton); editor.off('update', updatePlusButton); editor.off('focus', updatePlusButton); editor.off('blur', hidePlusButton); }; }, [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, og_image: ogImage || coverImage, status, scheduled_for: scheduledFor || null, content: editor?.getJSON() || EMPTY_DOC, }), [storyId, title, excerpt, coverImage, storyType, tagsCsv, metaTitle, metaDescription, 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 completedChecks = readinessChecks.filter((check) => check.ok).length; const progressPercent = Math.max(20, Math.round((completedChecks / Math.max(readinessChecks.length, 1)) * 100)); const topActions = [ { key: 'cover', label: coverImage ? 'Change cover' : 'Add cover', detail: coverImage ? 'Refresh the hero image.' : 'Give the story a visual anchor.', onClick: () => coverImageInputRef.current?.click(), tone: 'sky', }, { key: 'part', label: 'New part', detail: 'Drop in the three-dot chapter separator.', onClick: () => insertActions.part(), tone: 'violet', }, { key: 'settings', label: 'Story settings', detail: 'Manage SEO, workflow, and metadata.', onClick: () => setSettingsOpen(true), tone: 'slate', }, ]; const desktopInsertActions = [ { key: 'uploadImage', label: 'Upload photo', detail: 'Drop a full-width image into the body.' }, { key: 'artwork', label: 'Embed artwork', detail: 'Showcase one of your published pieces.' }, { key: 'video', label: 'Embed video', detail: 'Paste YouTube or Vimeo and let Nova normalize it.' }, { key: 'download', label: 'Download link', detail: 'Add a clear file CTA for readers.' }, { key: 'part', label: 'Add a new part', detail: 'Break long stories into readable chapters.' }, ] as Array<{ key: keyof typeof insertActions; label: string; detail: string }>; const quickLinks = storyId ? [ { key: 'preview', label: 'Preview story', href: `${endpoints.previewBase}/${storyId}/preview` }, { key: 'analytics', label: 'Story analytics', href: `${endpoints.analyticsBase}/${storyId}/analytics` }, ] : []; const storySuggestions = [ !coverImage ? { key: 'cover', label: 'Add a cover image', detail: 'A strong visual anchor makes the draft feel finished faster.', onClick: () => coverImageInputRef.current?.click(), tone: 'sky', } : null, excerpt.trim().length < 40 ? { key: 'excerpt', label: 'Sharpen the subtitle', detail: 'Give readers one sentence that sets the tone before the first paragraph.', onClick: () => excerptInputRef.current?.focus(), tone: 'violet', } : null, wordCount >= 220 ? { key: 'part', label: 'Split the next chapter', detail: 'This draft is long enough for a visual chapter break.', onClick: () => insertActions.part(), tone: 'emerald', } : null, tagsCsv.trim().length === '' ? { key: 'tags', label: 'Add discovery tags', detail: 'Open settings and add a few tags so the story is easier to surface later.', onClick: () => setSettingsOpen(true), tone: 'amber', } : null, ].filter(Boolean) as Array<{ key: string; label: string; detail: string; onClick: () => void; tone: string }>; const topActionToneClasses: Record = { sky: 'border-sky-300/18 bg-sky-400/10 text-sky-100 hover:border-sky-300/35 hover:bg-sky-400/15', violet: 'border-violet-300/18 bg-violet-400/10 text-violet-100 hover:border-violet-300/35 hover:bg-violet-400/15', slate: 'border-white/10 bg-white/[0.045] text-white/78 hover:border-white/20 hover:bg-white/[0.08]', }; const suggestionToneClasses: Record = { sky: 'border-sky-300/18 bg-sky-400/10 text-sky-100', violet: 'border-violet-300/18 bg-violet-400/10 text-violet-100', emerald: 'border-emerald-300/18 bg-emerald-400/10 text-emerald-100', amber: 'border-amber-300/18 bg-amber-400/10 text-amber-100', }; 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 (
{/* ── Nova top bar ─────────────────────────────────────────────────── */}
Stories {saveStatus}
{wordCount > 0 ? `${wordCount.toLocaleString()} words · ${readMinutes} min` : ''}
{!focusMode && (

Story Studio

Shape the narrative before readers ever see the first line.

Use the writing canvas for the draft itself, keep your metadata close, and drop in chapter breaks or rich media without leaving the flow.

{topActions.map((action) => ( ))}
)}
{desktopInsertActions.map((action) => ( ))} {quickLinks.map((link) => ( {link.label} ))}
{/* ── Writing canvas ───────────────────────────────────────────────── */}
{coverImage ? (
Story cover
) : null}
{/* Error / captcha banner */} {(generalError || captchaState.required) && (

{generalError || captchaState.message || 'Complete the captcha to continue.'}

{captchaState.required && captchaState.siteKey ? (
setCaptchaState((prev) => ({ ...prev, token }))} className="min-h-16" />
) : null}
)} {/* Cover image upload shortcut */} {!coverImage && ( )} {/* Title */}