Files
SkinbaseNova/resources/js/components/editor/StoryEditor.tsx

1420 lines
61 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @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 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;
canonical_url: string;
og_image: string;
status: string;
scheduled_for: string;
content: Record<string, unknown>;
};
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<unknown> = 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 `<button data-index="${index}" class="block w-full rounded-md px-3 py-2 text-left text-sm ${active}">/${item.key} <span class="text-gray-400">${item.title}</span></button>`;
})
.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<string, string> = {}, captcha: { token?: string } = {}) {
const fingerprint = await buildBotFingerprint();
return {
...extra,
'X-Bot-Fingerprint': fingerprint,
...(captcha?.token ? { 'X-Captcha-Token': captcha.token } : {}),
};
}
async function requestJson<T>(url: string, method: string, body: unknown, csrfToken: string, captcha: { token?: string } = {}): Promise<T> {
const response = await fetch(url, {
method,
headers: 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<number | undefined>(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<Artwork[]>([]);
const [artworkQuery, setArtworkQuery] = useState('');
const [inlineToolbar, setInlineToolbar] = useState({ visible: false, top: 0, left: 0 });
const [fieldErrors, setFieldErrors] = useState<Record<string, string[]>>({});
const [generalError, setGeneralError] = useState('');
const [wordCount, setWordCount] = useState(0);
const [readMinutes, setReadMinutes] = useState(1);
const [codeBlockLanguage, setCodeBlockLanguage] = useState('bash');
const [isSubmitting, setIsSubmitting] = useState(false);
const [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: '',
message: '',
nonce: 0,
provider: 'turnstile',
siteKey: '',
inputName: 'cf-turnstile-response',
scriptUrl: '',
});
const lastSavedRef = useRef('');
const editorRef = useRef<any>(null);
const bodyImageInputRef = useRef<HTMLInputElement | null>(null);
const coverImageInputRef = useRef<HTMLInputElement | null>(null);
const emitSaveEvent = useCallback((kind: 'autosave' | 'manual', id?: number) => {
window.dispatchEvent(new CustomEvent('story-editor:saved', {
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<string | null> => {
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,
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-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-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 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,
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<HTMLInputElement>) => {
const file = event.target.files?.[0];
event.target.value = '';
if (!file) return;
setSaveStatus('Uploading image...');
const uploaded = await uploadImageFile(file);
if (!uploaded || !editor) {
setSaveStatus('Image upload failed');
return;
}
editor.chain().focus().setImage({ src: uploaded }).run();
setSaveStatus('Image uploaded');
};
const handleCoverImagePicked = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
event.target.value = '';
if (!file) return;
setSaveStatus('Uploading cover...');
const uploaded = await uploadImageFile(file);
if (!uploaded) {
setSaveStatus('Cover upload failed');
return;
}
setCoverImage(uploaded);
setSaveStatus('Cover uploaded');
};
const readinessChecks = useMemo(() => ([
{ label: 'Title', ok: title.trim().length > 0, hint: 'Give the story a clear headline.' },
{ label: 'Body', ok: wordCount >= 50, hint: 'Aim for at least 50 words before publishing.' },
{ label: 'Story type', ok: storyType.trim().length > 0, hint: 'Choose the format that fits the post.' },
]), [storyType, title, wordCount]);
const titleError = fieldErrors?.title?.[0] || '';
const contentError = fieldErrors?.content?.[0] || '';
const excerptError = fieldErrors?.excerpt?.[0] || '';
const tagsError = fieldErrors?.tags_csv?.[0] || '';
const insertArtwork = (item: Artwork) => {
if (!editor) return;
editor.chain().focus().insertContent({
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 (
<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/50 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>
{/* ── 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"
>
<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}
<div className="px-6 pb-24 pt-10 md:px-14 md:pt-14">
{/* Error / captcha banner */}
{(generalError || captchaState.required) && (
<div className="mb-8 rounded-xl border border-amber-400/20 bg-amber-500/10 p-4">
<p className="text-sm text-amber-200">{generalError || captchaState.message || 'Complete the captcha to continue.'}</p>
{captchaState.required && captchaState.siteKey ? (
<div className="mt-3">
<TurnstileField
key={`story-editor-captcha-${captchaState.nonce}`}
provider={captchaState.provider}
siteKey={captchaState.siteKey}
scriptUrl={captchaState.scriptUrl}
onToken={(token) => setCaptchaState((prev) => ({ ...prev, token }))}
className="min-h-16"
/>
</div>
) : null}
</div>
)}
{/* 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>
)}
{/* Title */}
<div className="mb-3">
<textarea
value={title}
onChange={(event) => {
setTitle(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="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>
{/* 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>
{/* 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>
{/* 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 type="hidden" name="_token" value={csrfToken} />
<input type="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>
{/* ── 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>
{/* Insert block dropdown */}
{plusMenuOpen && (
<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 Array<{ 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>
);
}