@@ -13,7 +13,7 @@ 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' ;
import Nova Select from '../ui/Nova Select' ;
type StoryType = {
slug : string ;
@@ -446,9 +446,6 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
const [ artworkModalOpen , setArtworkModalOpen ] = useState ( false ) ;
const [ artworkResults , setArtworkResults ] = useState < Artwork [ ] > ( [ ] ) ;
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 < Record < string , string [ ] > > ( { } ) ;
const [ generalError , setGeneralError ] = useState ( '' ) ;
@@ -456,6 +453,10 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
const [ readMinutes , setReadMinutes ] = useState ( 1 ) ;
const [ codeBlockLanguage , setCodeBlockLanguage ] = useState ( 'bash' ) ;
const [ isSubmitting , setIsSubmitting ] = useState ( false ) ;
const [ settingsOpen , setSettingsOpen ] = useState ( false ) ;
const [ plusMenuOpen , setPlusMenuOpen ] = useState ( false ) ;
const [ plusButtonState , setPlusButtonState ] = useState ( { visible : false , top : 0 , left : 0 } ) ;
const editorContainerRef = useRef < HTMLDivElement | null > ( null ) ;
const [ captchaState , setCaptchaState ] = useState ( {
required : false ,
token : '' ,
@@ -661,6 +662,8 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
extensions : [
StarterKit . configure ( {
codeBlock : false ,
link : false ,
underline : false ,
heading : { levels : [ 1 , 2 , 3 ] } ,
} ) ,
CodeBlockLowlight . configure ( {
@@ -685,10 +688,11 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
DownloadAssetBlock ,
createSlashCommandExtension ( insertActions ) ,
] ,
immediatelyRender : false ,
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-3 00 prose-blockquote:border-l-sky-400 prose-blockquote:text-stone-300 prose-cod e:text-sky-2 00 max-w-none min-h-[32rem] bg-transparent px-0 py-0 text-stone-20 0 focus:outline-none' ,
class : 'tiptap prose prose-lg prose-headings:text-white prose-headings:font-bold prose-headings:tracking-tight prose-p:text-white/90 prose-p:leading-[1.85] prose-strong:text-white prose-a:text-sky-4 00 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-pr e:text-sky-1 00 max-w-none min-h-[32rem] bg-transparent px-0 py-0 text-white/9 0 focus:outline-none' ,
} ,
handleDrop : ( _view , event ) = > {
const file = event . dataTransfer ? . files ? . [ 0 ] ;
@@ -733,7 +737,6 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
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 ) ;
@@ -804,6 +807,45 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
} ;
} , [ editor ] ) ;
useEffect ( ( ) = > {
if ( ! editor ) return ;
const updatePlusButton = ( ) = > {
const { from , to } = editor . state . selection ;
if ( from !== to ) {
setPlusButtonState ( { visible : false , top : 0 , left : 0 } ) ;
setPlusMenuOpen ( false ) ;
return ;
}
const resolvedPos = editor . state . doc . resolve ( from ) ;
const parentNode = resolvedPos . parent ;
if ( parentNode . type . name === 'paragraph' && parentNode . content . size === 0 ) {
const coords = editor . view . coordsAtPos ( from ) ;
const containerRect = editorContainerRef . current ? . getBoundingClientRect ( ) ;
if ( ! containerRect ) {
setPlusButtonState ( { visible : false , top : 0 , left : 0 } ) ;
return ;
}
setPlusButtonState ( {
visible : true ,
top : coords.top - 14 ,
left : containerRect.left - 48 ,
} ) ;
} else {
setPlusButtonState ( { visible : false , top : 0 , left : 0 } ) ;
setPlusMenuOpen ( false ) ;
}
} ;
editor . on ( 'selectionUpdate' , updatePlusButton ) ;
editor . on ( 'update' , updatePlusButton ) ;
return ( ) = > {
editor . off ( 'selectionUpdate' , updatePlusButton ) ;
editor . off ( 'update' , updatePlusButton ) ;
} ;
} , [ editor ] ) ;
const payload = useCallback ( ( ) = > ( {
story_id : storyId ,
title ,
@@ -967,176 +1009,79 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
} ;
return (
< div className = "space-y-6 " >
< 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 = "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-10 0 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 className = "mx-auto max-w-4xl px-4 py-4 pb-24 md:px-8 " >
{ /* ── Nova top bar ─────────────────────────────────────────────────── */ }
< div className = "sticky top-0 z-30 mb-6 flex h-14 items-center justify-between overflow-hidden rounded-2xl border border-white/10 bg-[linear-gradient(135deg,rgba(12,18,28,0.97),rgba(8,12,20,0.97))] px-5 shadow-[0_8px_32px_rgba(3,7,18,0.32)] backdrop-blur-xl " >
< div className = "flex items-center gap-4 " >
< a href = "/studio/stories" className = "flex items-center gap-1.5 text-sm text-white/50 transition-colors hover: text-white/90 " >
< svg className = "h-4 w-4" fill = "none" viewBox = "0 0 24 24" stroke = "currentColor" strokeWidth = { 2 } > < path strokeLinecap = "round" strokeLinejoin = "round" d = "M10 19l-7-7m0 0l7-7m-7 7h18" / > < / svg >
Stories
< / a >
< span className = "h-4 w-px bg-white/10" / >
< span className = "hidden text-sm text-white/65 sm:inline" > { saveStatus } < / span >
< / div >
< div className = "flex items-center gap-2" >
< span className = "hidden text-xs text-white/55 lg:inline" > { wordCount > 0 ? ` ${ wordCount . toLocaleString ( ) } words · ${ readMinutes } min ` : '' } < / span >
< button
type = "button"
onClick = { ( ) = > setSettingsOpen ( true ) }
title = "Story settings"
className = "rounded-full p-2 text-white/5 0 transition-colors hover:bg-white/[0.07] hover:text-white"
>
< svg className = "h-5 w-5" fill = "none" viewBox = "0 0 24 24" stroke = "currentColor" strokeWidth = { 1.75 } > < path strokeLinecap = "round" strokeLinejoin = "round" d = "M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" / > < path strokeLinecap = "round" strokeLinejoin = "round" d = "M15 12a3 3 0 11-6 0 3 3 0 016 0z" / > < / svg >
< / button >
< button
type = "button"
onClick = { ( ) = > persistStory ( 'save_draft' ) }
disabled = { isSubmitting }
className = "rounded-full border border-white/10 bg-white/[0.05] px-4 py-1.5 text-sm text-white/80 transition hover:bg-white/[0.10] disabled:opacity-50"
>
Save
< / button >
< button
type = "button"
onClick = { ( ) = > persistStory ( 'publish_now' ) }
disabled = { isSubmitting }
className = "rounded-full bg-sky-500 px-4 py-1.5 text-sm font-medium text-white shadow-[0_2px_12px_rgba(14,165,233,0.45)] transition hover:bg-sky-400 disabled:opacity-50"
>
Publish
< / button >
< / div >
< / div >
< 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 ` } }
{ /* ── Writing canvas ───────────────────────────────────────────────── */ }
< div className = "mx-auto max-w-[760px] overflow-hidden rounded-[2rem] border border-white/10 bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.07),_transparent_32%),linear-gradient(180deg,rgba(14,18,27,0.99),rgba(9,12,19,0.98))] shadow-[0_24px_80px_rgba(4,8,20,0.36)] " >
{ coverImage ? (
< div className = "group relative overflow-hidden rounded-t-2xl" >
< img src = { coverImage } alt = "Story cover" className = "h-64 w-full object-cover md:h-80" / >
< div className = "absolute inset-0 flex items-center justify-center gap-3 bg-black/50 opacity-0 transition-opacity group-hover:opacity-100" >
< button
type = "button"
onClick = { ( ) = > coverImageInputRef . current ? . click ( ) }
className = "flex items-center gap-1.5 rounded-lg bg-white/15 px-4 py-2 text-sm font-medium text-white backdrop-blur-sm transition hover:bg-white/25"
>
< 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 }
< svg className = "h-4 w-4" fill = "none" viewBox = "0 0 24 24" stroke = "currentColor" strokeWidth = { 2 } > < path strokeLinecap = "round" strokeLinejoin = "round" d = "M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" / > < / svg >
Change
< / button >
< button
type = "button"
onClick = { ( ) = > setCoverImage ( '' ) }
className = "flex items-center gap-1.5 rounded-lg bg-rose-500/70 px-4 py-2 text-sm font-medium text-white backdrop-blur-sm transition hover:bg-rose-500/90"
>
< svg className = "h-4 w-4" fill = "none" viewBox = "0 0 24 24" stroke = "currentColor" strokeWidth = { 2 } > < path strokeLinecap = "round" strokeLinejoin = "round" d = "M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" / > < / svg >
Remove
< / button >
< / div >
< / div >
) : null }
{ 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 >
< aside className = "space-y-4 xl:sticky xl:top-24 self-start" >
< div className = "px-6 pb-24 pt-10 md:px-14 md:pt-14" >
{ /* Error / captcha banner */ }
{ ( 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-1 00/70 " > Action needed < / p >
< p className = "mt-3 text-sm text-amber-50" > { generalError || captchaState . message || 'Complete the captcha challenge to continue.' } < / p >
< div className = "mb-8 rounded-xl border border-amber-400/20 bg-amber-500/10 p-4 " >
< p className = "text-sm text-amber-2 00" > { generalError || captchaState . message || 'Complete the captcha to continue.' } < / p >
{ captchaState . required && captchaState . siteKey ? (
< div className = "mt-4 rounded-2xl border border-white/10 bg-black/20 p- 3" >
< div className = "mt-3" >
< TurnstileField
key = { ` story-editor-captcha- ${ captchaState . nonce } ` }
provider = { captchaState . provider }
@@ -1147,126 +1092,328 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
/ >
< / div >
) : null }
< / section >
< / 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 >
{ /* Cover image upload shortcut */ }
{ ! coverImage && (
< button
type = "button"
onClick = { ( ) = > coverImageInputRef . current ? . click ( ) }
className = "mb-6 flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-white/55 transition hover:bg-white/[0.05] hover:text-white/80"
>
< svg className = "h-4 w-4" fill = "none" viewBox = "0 0 24 24" stroke = "currentColor" strokeWidth = { 2 } > < path strokeLinecap = "round" strokeLinejoin = "round" d = "M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" / > < / svg >
Add a cover image
< / button >
) }
< 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 ( ( typ e ) = > (
< option key = { type . slug } value = { type . slug } > { type . name } < / option >
) ) }
< / select >
< / div >
{ /* Title */ }
< div className = "mb-3" >
< textarea
value = { title }
onChange = { ( event ) = > {
setTitle ( event . target . value ) ;
event . target . s tyl e. height = 'auto' ;
event . target . style . height = ` ${ event . target . scrollHeight } px ` ;
} }
onFocus = { ( event ) = > {
event . target . style . height = 'auto' ;
event . target . style . height = ` ${ event . target . scrollHeight } px ` ;
} }
placeholder = "Title"
rows = { 1 }
className = "w-full resize-none overflow-hidden border-0 bg-transparent p-0 text-[2.4rem] font-bold leading-tight tracking-tight text-white placeholder:text-white/35 focus:outline-none md:text-[2.8rem]"
/ >
{ titleError ? < p className = "mt-1 text-sm text-rose-300" > { titleError } < / p > : null }
< / 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 >
{ /* Excerpt / subtitle */ }
< div className = "mb-10 border-b border-white/[0.07] pb-8" >
< textarea
value = { excerpt }
onChange = { ( event ) = > {
setExcerpt ( event . target . value ) ;
event . target . style . height = 'auto' ;
event . target . style . height = ` ${ event . target . scrollHeight } px ` ;
} }
onFocus = { ( event ) = > {
event . target . style . height = 'auto' ;
event . target . style . height = ` ${ event . target . scrollHeight } px ` ;
} }
placeholder = "Write a short subtitle that sets the scene…"
rows = { 1 }
className = "w-full resize-none overflow-hidden border-0 bg-transparent p-0 text-xl leading-relaxed text-white/75 placeholder:text-white/35 focus:outline-none"
/ >
{ excerptError ? < p className = "mt-1 text-sm text-rose-300" > { excerptError } < / p > : null }
< / 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 >
{ /* Body editor — the ref is on the wrapper so we can measure its left edge */ }
< div className = "relative" ref = { editorContainerRef } >
< EditorContent editor = { editor } / >
{ contentError ? < p className = "mt-4 text-sm text-rose-300" > { contentError } < / p > : null }
< / 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 valu e= { 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 valu e= { 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 >
{ /* Footer actions */ }
< div className = "mt-16 flex flex-wrap items-center gap-3 border-t border-white/[0.07] pt-8 text-sm" >
{ storyId && (
< a href = { ` ${ endpoints . previewBase } / ${ storyId } /preview ` } className = "rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-white/60 transition hover:bg-white/[0.08] hover:text-white/90" > Preview < / a >
) }
{ storyId && (
< a href = { ` ${ endpoints . analyticsBase } / ${ storyId } /analytics ` } className = "rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-white/60 transition hover:bg-white/[0.08] hover:text-white/90" > Analytics < / a >
) }
< button
type = "button"
onClick = { ( ) = > persistStory ( 'submit_review' ) }
disabled = { isSubmitting }
className = "rounded-full border border-amber-400/30 bg-amber-400/10 px-4 py-2 text-amber-200 transition hover:bg-amber-400/20 disabled:opacity-50"
>
Submit for review
< / button >
{ mode === 'edit' && storyId && (
< form
method = "POST"
action = { ` /creator/stories/ ${ storyId } ` }
onSubmit = { ( e ) = > { if ( ! window . confirm ( 'Delete this story permanently?' ) ) e . preventDefault ( ) ; } }
className = "ml-auto"
>
< input typ e= "hidden" name = "_token" value = { csrfToken } / >
< input typ e= "hidden" name = "_method" value = "DELETE " / >
< button type = "submit" className = "rounded-full border border-rose-500/30 bg-rose-500/10 px-4 py-2 text-rose-300 transition hover:bg-rose-500/20" > Delete story < / button >
< / form >
) }
< / div >
< / div >
< / div >
< div className = "flex flex-wrap gap-3" >
{ storyId && (
< a href = { ` ${ endpoints . previewBase } / ${ storyId } /preview ` } className = "rounded-xl border border-sky-500/40 bg-sky-500/10 px-3 py-2 text-sm text-sky-200" > Preview < / a >
) }
{ storyId && (
< a href = { ` ${ endpoints . analyticsBase } / ${ storyId } /analytics ` } className = "rounded-xl border border-violet-500/40 bg-violet-500/10 px-3 py-2 text-sm text-violet-200" > Analytics < / a >
) }
{ mode === 'edit' && storyId && (
< form method = "POST" action = { ` /creator/stories/ ${ storyId } ` } onSubmit = { ( event ) = > {
if ( ! window . confirm ( 'Delete this story?' ) ) {
event . preventDefault ( ) ;
}
} } >
< input type = "hidden" name = "_token" value = { csrfToken } / >
< input type = "hidden" name = "_method" value = "DELETE" / >
< button type = "submit" className = "rounded-xl border border-rose-500/40 bg-rose-500/20 px-3 py-2 text-sm text-rose-200" > Delete < / button >
< / form >
) }
< / div >
{ /* ── Floating + block insertion button (fixed, always visible when on empty line) ── */ }
{ plusButtonState . visible && (
< div className = "fixed z-40" style = { { top : ` ${ plusButtonState . top } px ` , left : ` ${ plusButtonState . left } px ` } } >
< button
type = "button"
onMouseDown = { ( e ) = > { e . preventDefault ( ) ; setPlusMenuOpen ( ( v ) = > ! v ) ; } }
className = { ` flex h-8 w-8 items-center justify-center rounded-full border transition ${
plusMenuOpen
? 'border-sky-400/60 bg-sky-500/20 text-sky-300 shadow-[0_0_12px_rgba(14,165,233,0.35)]'
: 'border-white/20 bg-slate-900/90 text-white/60 shadow-[0_4px_16px_rgba(3,7,18,0.4)] hover:border-sky-400/50 hover:text-sky-300'
} ` }
title = "Add a block (or type / for commands)"
>
< svg
className = { ` h-4 w-4 transition-transform duration-200 ${ plusMenuOpen ? 'rotate-45' : '' } ` }
fill = "none" viewBox = "0 0 24 24" stroke = "currentColor" strokeWidth = { 2.5 }
>
< path strokeLinecap = "round" strokeLinejoin = "round" d = "M12 4v16m8-8H4" / >
< / svg >
< / button >
< input ref = { bodyImageInputRef } type = "file" accept = "image/*" className = "hidden" onChange = { handleBodyImagePicked } />
< input ref = { coverImageInputRef } type = "file" accept = "image/*" className = "hidd en" 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" >
< div className = "mb-3 flex items-center justify-between" >
< h3 className = "text-sm font-semibold text-white" > Embed Artwork < / h3 >
< button type = "button" onClick = { ( ) = > setArtworkModalOpen ( false ) } className = "rounded border border-gray-600 px-2 py-1 text-xs text-gray-200" > Close < / button >
< / div >
< input value = { artworkQuery } onChange = { ( event ) = > setArtworkQuery ( event . target . value ) } className = "mb-3 w-full rounded-xl border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-200" placeholder = "Search artworks" / >
< div className = "grid max-h-80 gap-3 overflow-y-auto sm:grid-cols-2" >
{ artworkResults . map ( ( item ) = > (
< button ke y = { item . id } type = "button" onClick = { ( ) = > insertArtwork ( item ) } className = "rounded-xl border border-gray-700 bg-gray-800 p-3 text-left hover:border-sky-400" >
{ ( item . thumbs ? . sm || item . thumb ) && < img src = { item . thumbs ? . sm || item . thumb || '' } alt = { item . title } className = "h-28 w-full rounded-lg object-cover" / > }
< div className = "mt-2 text-sm font-semibold text-white" > { item . title } < / div >
< div className = "text-xs text-gray-400" > # { item . id } < / div >
{ /* Insert block dropdown * / }
{ plusMenuOp en && (
< div className = "absolute left-10 top-0 w-52 overflow-hidden rounded-2xl border border-white/10 bg-[linear-gradient(180deg,rgba(18,24,36,0.99),rgba(10,14,22,0.99))] py-1 shadow-[0_16px_48px_rgba(3,7,18,0.5)] backdrop-blur-xl" >
{ ([
{ label : 'Upload photo' , icon : '🖼' , key : 'uploadImage' } ,
{ label : 'Image URL' , icon : '🔗' , key : 'image' } ,
{ label : 'Artwork embed' , icon : '🎨' , key : 'artwork' } ,
{ label : 'Video (YouTube…)' , icon : '▶' , key : 'video' } ,
{ label : 'Gallery' , icon : '⊞' , key : 'gallery' } ,
{ label : 'Blockquote' , icon : '❝' , key : 'quote' } ,
{ label : 'Code block' , icon : '⌨' , key : 'code' } ,
{ label : 'Download link' , icon : '↓' , key : 'download' } ,
{ label : 'Divider' , icon : '—' , key : 'divider' } ,
] as Arra y< { label : string ; icon : string ; key : keyof typeof insertActions } > ) . map ( ( item ) = > (
< button
key = { item . key }
type = "button"
onMouseDown = { ( e ) = > {
e . preventDefault ( ) ;
setPlusMenuOpen ( false ) ;
insertActions [ item . key ] ( ) ;
} }
className = "flex w-full items-center gap-3 px-4 py-2.5 text-left text-sm text-white/75 transition-colors hover:bg-white/[0.07] hover:text-white"
>
< span className = "w-5 text-center text-base leading-none opacity-70" > { item . icon } < / span >
{ item . label }
< / button >
) ) }
< / div >
) }
< / div >
) }
{ /* ── Floating inline formatting toolbar ───────────────────────────── */ }
{ editor && inlineToolbar . visible && (
< div
className = "fixed z-50 flex items-center gap-0.5 overflow-hidden rounded-2xl border border-white/10 bg-[linear-gradient(135deg,rgba(12,18,28,0.98),rgba(8,12,20,0.98))] p-1.5 shadow-[0_8px_32px_rgba(3,7,18,0.5)] backdrop-blur-xl"
style = { { top : ` ${ inlineToolbar . top } px ` , left : ` ${ inlineToolbar . left } px ` } }
>
{ ( [
{ label : 'B' , title : 'Bold' , action : ( ) = > editor . chain ( ) . focus ( ) . toggleBold ( ) . run ( ) , active : editor.isActive ( 'bold' ) , extra : 'font-bold' } ,
{ label : 'I' , title : 'Italic' , action : ( ) = > editor . chain ( ) . focus ( ) . toggleItalic ( ) . run ( ) , active : editor.isActive ( 'italic' ) , extra : 'italic' } ,
{ label : 'U' , title : 'Underline' , action : ( ) = > editor . chain ( ) . focus ( ) . toggleUnderline ( ) . run ( ) , active : editor.isActive ( 'underline' ) , extra : 'underline' } ,
{ label : 'H2' , title : 'Heading 2' , action : ( ) = > editor . chain ( ) . focus ( ) . toggleHeading ( { level : 2 } ) . run ( ) , active : editor.isActive ( 'heading' , { level : 2 } ) , extra : 'font-semibold text-xs' } ,
{ label : 'H3' , title : 'Heading 3' , action : ( ) = > editor . chain ( ) . focus ( ) . toggleHeading ( { level : 3 } ) . run ( ) , active : editor.isActive ( 'heading' , { level : 3 } ) , extra : 'font-semibold text-xs' } ,
{ label : '❝' , title : 'Blockquote' , action : ( ) = > editor . chain ( ) . focus ( ) . toggleBlockquote ( ) . run ( ) , active : editor.isActive ( 'blockquote' ) , extra : 'text-base font-serif' } ,
{ label : '⛓' , title : 'Link' , action : ( ) = > openLinkPrompt ( editor ) , active : editor.isActive ( 'link' ) , extra : '' } ,
{ label : '</>' , title : 'Inline code' , action : ( ) = > editor . chain ( ) . focus ( ) . toggleCode ( ) . run ( ) , active : editor.isActive ( 'code' ) , extra : 'font-mono text-[10px]' } ,
] as Array < { label : string ; title : string ; action : ( ) = > void ; active : boolean ; extra : string } > ) . map ( ( item ) = > (
< button
key = { item . title }
type = "button"
title = { item . title }
onMouseDown = { ( e ) = > e . preventDefault ( ) }
onClick = { item . action }
className = { ` flex h-8 min-w-[2rem] items-center justify-center rounded-xl px-1.5 text-sm transition ${ item . extra } ${ item . active ? 'bg-sky-500/25 text-sky-200' : 'text-white/70 hover:bg-white/[0.07] hover:text-white' } ` }
>
{ item . label }
< / button >
) ) }
< / div >
) }
{ /* ── Settings slide-over panel ─────────────────────────────────────── */ }
{ settingsOpen && (
< >
< button
type = "button"
className = "fixed inset-0 z-40 bg-black/50 backdrop-blur-sm"
onClick = { ( ) = > setSettingsOpen ( false ) }
aria-label = "Close settings"
/ >
< div className = "fixed bottom-0 right-0 top-0 z-50 flex w-full max-w-sm flex-col overflow-hidden border-l border-white/10 bg-[linear-gradient(180deg,rgba(14,18,27,0.99),rgba(9,12,19,0.99))] shadow-[− 20px_0_60px_rgba(3,7,18,0.5)]" >
< div className = "flex shrink-0 items-center justify-between border-b border-white/10 px-5 py-4" >
< h2 className = "font-semibold text-white" > Story settings < / h2 >
< button type = "button" onClick = { ( ) = > setSettingsOpen ( false ) } className = "rounded-full p-1.5 text-white/40 transition-colors hover:bg-white/[0.07] hover:text-white" >
< svg className = "h-5 w-5" fill = "none" viewBox = "0 0 24 24" stroke = "currentColor" strokeWidth = { 2 } > < path strokeLinecap = "round" strokeLinejoin = "round" d = "M6 18L18 6M6 6l12 12" / > < / svg >
< / button >
< / div >
< div className = "flex-1 overflow-y-auto" >
< div className = "space-y-6 p-5" >
{ /* Readiness checklist */ }
< div >
< p className = "mb-3 text-xs font-semibold uppercase tracking-widest text-white/35" > Ready to publish ? < / p >
< div className = "space-y-2" >
{ readinessChecks . map ( ( check ) = > (
< div key = { check . label } className = { ` flex items-start gap-3 rounded-xl p-3 ${ check . ok ? 'bg-emerald-500/10 border border-emerald-500/20' : 'bg-amber-500/10 border border-amber-500/20' } ` } >
< span className = { ` mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-[11px] font-bold ${ check . ok ? 'bg-emerald-500/25 text-emerald-300' : 'bg-amber-500/25 text-amber-300' } ` } >
{ check . ok ? '✓' : '!' }
< / span >
< div >
< p className = "text-sm font-medium text-white/85" > { check . label } < / p >
< p className = "text-xs text-white/40" > { check . hint } < / p >
< / div >
< / div >
) ) }
< / div >
< / div >
{ /* Publish actions */ }
< div >
< p className = "mb-3 text-xs font-semibold uppercase tracking-widest text-white/35" > Publish < / p >
< div className = "space-y-2" >
< button type = "button" onClick = { ( ) = > { void persistStory ( 'publish_now' ) ; setSettingsOpen ( false ) ; } } disabled = { isSubmitting } className = "w-full rounded-xl bg-sky-500 px-4 py-3 text-sm font-medium text-white shadow-[0_2px_12px_rgba(14,165,233,0.35)] transition hover:bg-sky-400 disabled:opacity-50" > Publish now < / button >
< button type = "button" onClick = { ( ) = > { void persistStory ( 'save_draft' ) ; setSettingsOpen ( false ) ; } } disabled = { isSubmitting } className = "w-full rounded-xl border border-white/10 bg-white/[0.05] px-4 py-3 text-sm text-white/75 transition hover:bg-white/[0.09] disabled:opacity-50" > Save as draft < / button >
< button type = "button" onClick = { ( ) = > { void persistStory ( 'submit_review' ) ; setSettingsOpen ( false ) ; } } disabled = { isSubmitting } className = "w-full rounded-xl border border-amber-400/30 bg-amber-400/10 px-4 py-3 text-sm text-amber-200 transition hover:bg-amber-400/20 disabled:opacity-50" > Submit for review < / button >
{ scheduledFor && (
< button type = "button" onClick = { ( ) = > { void persistStory ( 'schedule_publish' ) ; setSettingsOpen ( false ) ; } } disabled = { isSubmitting } className = "w-full rounded-xl border border-sky-400/30 bg-sky-400/10 px-4 py-3 text-sm text-sky-200 transition hover:bg-sky-400/20 disabled:opacity-50" > Schedule publish < / button >
) }
< / div >
< / div >
{ /* Cover image */ }
< div >
< div className = "mb-3 flex items-center justify-between" >
< p className = "text-xs font-semibold uppercase tracking-widest text-white/35" > Cover image < / p >
< div className = "flex items-center gap-3" >
< button type = "button" onClick = { ( ) = > coverImageInputRef . current ? . click ( ) } className = "text-xs text-sky-400 underline-offset-2 transition-colors hover:underline" > Upload file < / button >
{ coverImage && < button type = "button" onClick = { ( ) = > setCoverImage ( '' ) } className = "text-xs text-rose-400 underline-offset-2 transition-colors hover:underline" > Remove < / button > }
< / div >
< / div >
< input value = { coverImage } onChange = { ( e ) = > setCoverImage ( e . target . value ) } placeholder = "Paste an image URL…" className = "w-full rounded-xl border border-white/10 bg-slate-950/60 px-3 py-2.5 text-sm text-white placeholder:text-white/25 focus:border-white/20 focus:outline-none" / >
{ coverImage && < img src = { coverImage } alt = "Cover preview" className = "mt-3 h-36 w-full rounded-xl object-cover" / > }
< / div >
{ /* Format */ }
< div >
< p className = "mb-2 text-xs font-semibold uppercase tracking-widest text-white/35" > Format < / p >
< NovaSelect value = { storyType } onChange = { ( val ) = > setStoryType ( val ) } options = { storyTypes . map ( ( t ) = > ( { value : t.slug , label : t.name } ) ) } / >
< / div >
{ /* Tags */ }
< div >
< p className = "mb-2 text-xs font-semibold uppercase tracking-widest text-white/35" > Tags < / p >
< input value = { tagsCsv } onChange = { ( e ) = > setTagsCsv ( e . target . value ) } placeholder = "art direction, tutorial, process" className = "w-full rounded-xl border border-white/10 bg-slate-950/60 px-3 py-2.5 text-sm text-white placeholder:text-white/25 focus:border-white/20 focus:outline-none" / >
{ tagsError ? < p className = "mt-1 text-xs text-rose-400" > { tagsError } < / p > : < p className = "mt-1 text-xs text-white/30" > Comma - separated . New tags created automatically . < / p > }
< / div >
{ /* Status + schedule */ }
< div >
< p className = "mb-2 text-xs font-semibold uppercase tracking-widest text-white/35" > Workflow < / p >
< NovaSelect value = { status } onChange = { ( val ) = > setStatus ( val ) } searchable = { false } options = { [ { value : 'draft' , label : 'Draft' } , { value : 'pending_review' , label : 'Pending Review' } , { value : 'published' , label : 'Published' } , { value : 'scheduled' , label : 'Scheduled' } , { value : 'archived' , label : 'Archived' } ] } / >
< input type = "datetime-local" value = { scheduledFor } onChange = { ( e ) = > setScheduledFor ( e . target . value ) } className = "w-full rounded-xl border border-white/10 bg-slate-950/60 px-3 py-2.5 text-sm text-white focus:border-white/20 focus:outline-none" / >
< / div >
{ /* SEO */ }
< div >
< p className = "mb-3 text-xs font-semibold uppercase tracking-widest text-white/35" > SEO & social < / p >
< div className = "space-y-2" >
< input value = { metaTitle } onChange = { ( e ) = > setMetaTitle ( e . target . value ) } placeholder = "Meta title (defaults to story title)" className = "w-full rounded-xl border border-white/10 bg-slate-950/60 px-3 py-2.5 text-sm text-white placeholder:text-white/25 focus:border-white/20 focus:outline-none" / >
< textarea value = { metaDescription } onChange = { ( e ) = > setMetaDescription ( e . target . value ) } rows = { 3 } placeholder = "Meta description (defaults to excerpt)" className = "w-full resize-none rounded-xl border border-white/10 bg-slate-950/60 px-3 py-2.5 text-sm text-white placeholder:text-white/25 focus:border-white/20 focus:outline-none" / >
< input value = { canonicalUrl } onChange = { ( e ) = > setCanonicalUrl ( e . target . value ) } placeholder = "Canonical URL (optional)" className = "w-full rounded-xl border border-white/10 bg-slate-950/60 px-3 py-2.5 text-sm text-white placeholder:text-white/25 focus:border-white/20 focus:outline-none" / >
< input value = { ogImage } onChange = { ( e ) = > setOgImage ( e . target . value ) } placeholder = "OG image URL (defaults to cover)" className = "w-full rounded-xl border border-white/10 bg-slate-950/60 px-3 py-2.5 text-sm text-white placeholder:text-white/25 focus:border-white/20 focus:outline-none" / >
< / div >
< / div >
{ /* Quick links */ }
{ storyId && (
< div className = "space-y-2" >
< a href = { ` ${ endpoints . previewBase } / ${ storyId } /preview ` } className = "block rounded-xl border border-white/10 bg-white/[0.04] px-4 py-2.5 text-center text-sm text-white/65 transition hover:bg-white/[0.08] hover:text-white" > Preview story < / a >
< a href = { ` ${ endpoints . analyticsBase } / ${ storyId } /analytics ` } className = "block rounded-xl border border-white/10 bg-white/[0.04] px-4 py-2.5 text-center text-sm text-white/65 transition hover:bg-white/[0.08] hover:text-white" > View analytics < / a >
< / div >
) }
< / div >
< / div >
< / div >
< / >
) }
{ /* ── Artwork picker modal ──────────────────────────────────────────── */ }
{ artworkModalOpen && (
< div className = "fixed inset-0 z-50 flex items-end justify-center bg-black/60 p-4 backdrop-blur-sm sm:items-center" >
< div className = "w-full max-w-2xl overflow-hidden rounded-[1.75rem] border border-white/10 bg-[linear-gradient(180deg,rgba(14,18,27,0.99),rgba(9,12,19,0.99))] shadow-[0_24px_80px_rgba(3,7,18,0.7)]" >
< div className = "flex items-center justify-between border-b border-white/10 px-6 py-4" >
< h3 className = "font-semibold text-white" > Embed an artwork < / h3 >
< button type = "button" onClick = { ( ) = > setArtworkModalOpen ( false ) } className = "rounded-full p-1.5 text-white/40 transition-colors hover:bg-white/[0.07] hover:text-white" >
< svg className = "h-5 w-5" fill = "none" viewBox = "0 0 24 24" stroke = "currentColor" strokeWidth = { 2 } > < path strokeLinecap = "round" strokeLinejoin = "round" d = "M6 18L18 6M6 6l12 12" / > < / svg >
< / button >
< / div >
< div className = "p-6" >
< input
value = { artworkQuery }
onChange = { ( e ) = > setArtworkQuery ( e . target . value ) }
className = "mb-4 w-full rounded-xl border border-white/10 bg-slate-950/60 px-3 py-2.5 text-sm text-white placeholder:text-white/30 focus:border-white/20 focus:outline-none"
placeholder = "Search your artworks…"
autoFocus
/ >
< div className = "grid max-h-72 gap-3 overflow-y-auto sm:grid-cols-3" >
{ artworkResults . map ( ( item ) = > (
< button key = { item . id } type = "button" onClick = { ( ) = > insertArtwork ( item ) } className = "overflow-hidden rounded-xl border border-white/10 bg-white/[0.04] text-left transition hover:border-sky-400/40 hover:shadow-lg" >
{ ( item . thumbs ? . sm || item . thumb ) && < img src = { item . thumbs ? . sm || item . thumb || '' } alt = { item . title } className = "h-24 w-full object-cover" / > }
< div className = "p-2" >
< p className = "line-clamp-1 text-xs font-medium text-white/80" > { item . title } < / p >
< / div >
< / button >
) ) }
{ artworkResults . length === 0 && artworkQuery . length > 0 && (
< p className = "col-span-3 py-8 text-center text-sm text-white/35" > No artworks found for & ldquo ; { artworkQuery } & rdquo ; < / p >
) }
< / div >
< / div >
< / div >
< / div >
) }
{ /* Hidden file inputs */ }
< input ref = { bodyImageInputRef } type = "file" accept = "image/*" className = "hidden" onChange = { handleBodyImagePicked } / >
< input ref = { coverImageInputRef } type = "file" accept = "image/*" className = "hidden" onChange = { handleCoverImagePicked } / >
< / div >
) ;
}