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

2042 lines
88 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 DateTimePicker from '../ui/DateTimePicker';
import Modal from '../ui/Modal';
import NovaSelect from '../ui/NovaSelect';
type StoryType = {
slug: string;
name: string;
};
type Artwork = {
id: number;
title: string;
url: string;
thumb: string | null;
thumbs?: {
xs?: string | null;
sm?: string | null;
md?: string | null;
lg?: string | null;
xl?: string | null;
};
};
type StoryPayload = {
id?: number;
title: string;
excerpt: string;
cover_image: string;
story_type: string;
tags_csv: string;
meta_title: string;
meta_description: string;
og_image: string;
status: string;
scheduled_for: string;
content: Record<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;
};
type InsertDialogKind = 'image' | 'video' | 'download' | 'link' | null;
const EMPTY_DOC = {
type: 'doc',
content: [{ type: 'paragraph' }],
};
const lowlight = createLowlight(common);
const CODE_BLOCK_LANGUAGES = [
{ value: 'bash', label: 'Bash / Shell' },
{ value: 'plaintext', label: 'Plain text' },
{ value: 'php', label: 'PHP' },
{ value: 'javascript', label: 'JavaScript' },
{ value: 'typescript', label: 'TypeScript' },
{ value: 'json', label: 'JSON' },
{ value: 'html', label: 'HTML' },
{ value: 'css', label: 'CSS' },
{ value: 'sql', label: 'SQL' },
{ value: 'xml', label: 'XML / SVG' },
{ value: 'yaml', label: 'YAML' },
{ value: 'markdown', label: 'Markdown' },
];
const INSERT_DIALOG_CONTENT = {
image: {
title: 'Add image from URL',
description: 'Paste a direct image URL to insert a full image block into the story body.',
confirmLabel: 'Insert image',
urlLabel: 'Image URL',
urlPlaceholder: 'https://images.example.com/story-scene.jpg',
urlHint: 'Use a direct image file URL when possible for the most reliable preview.',
},
video: {
title: 'Embed a video',
description: 'Paste a YouTube or Vimeo link. Common watch and share URLs will be converted to embed URLs automatically.',
confirmLabel: 'Embed video',
urlLabel: 'Video URL',
urlPlaceholder: 'https://www.youtube.com/watch?v=example',
urlHint: 'You can paste a normal watch URL, share URL, or a direct embed URL.',
},
download: {
title: 'Add a download link',
description: 'Create a downloadable asset button with a friendly label for readers.',
confirmLabel: 'Add download',
urlLabel: 'File URL',
urlPlaceholder: 'https://cdn.example.com/files/asset.zip',
urlHint: 'Point this at the exact file you want readers to download.',
},
link: {
title: 'Add link to selection',
description: 'Attach a link to the currently selected text in your story.',
confirmLabel: 'Save link',
urlLabel: 'Link URL',
urlPlaceholder: 'https://skinbase.org/help',
urlHint: 'Paste any http or https URL. Leave it empty and use Remove link to clear an existing link.',
},
};
const INSERT_DIALOG_INITIAL_STATE = {
kind: null as InsertDialogKind,
url: '',
title: '',
label: 'Download asset',
error: '',
};
function normalizeHttpUrl(rawValue: string): string | null {
const trimmed = rawValue.trim();
if (trimmed === '') {
return null;
}
const withProtocol = /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed) ? trimmed : `https://${trimmed.replace(/^\/+/, '')}`;
try {
const parsed = new URL(withProtocol);
if (!['http:', 'https:'].includes(parsed.protocol)) {
return null;
}
return parsed.toString();
} catch {
return null;
}
}
function normalizeVideoEmbedUrl(rawValue: string): string | null {
const normalized = normalizeHttpUrl(rawValue);
if (!normalized) {
return null;
}
const parsed = new URL(normalized);
const host = parsed.hostname.replace(/^www\./i, '').toLowerCase();
const path = parsed.pathname;
if (host === 'youtu.be') {
const videoId = path.replace(/^\//, '').split('/')[0];
return videoId ? `https://www.youtube.com/embed/${videoId}` : normalized;
}
if (host === 'youtube.com' || host === 'm.youtube.com') {
if (path === '/watch') {
const videoId = parsed.searchParams.get('v');
return videoId ? `https://www.youtube.com/embed/${videoId}` : normalized;
}
const pathMatch = path.match(/^\/(embed|shorts|live)\/([^/?#]+)/i);
if (pathMatch?.[2]) {
return `https://www.youtube.com/embed/${pathMatch[2]}`;
}
}
if (host === 'vimeo.com') {
const videoId = path.replace(/^\//, '').split('/')[0];
return videoId ? `https://player.vimeo.com/video/${videoId}` : normalized;
}
if (host === 'player.vimeo.com') {
return normalized;
}
return normalized;
}
const ArtworkBlock = Node.create({
name: 'artworkEmbed',
group: 'block',
atom: true,
addAttributes() {
return {
artworkId: { default: null },
title: { default: '' },
url: { default: '' },
thumb: { default: '' },
};
},
parseHTML() {
return [{ tag: 'figure[data-artwork-embed]' }];
},
renderHTML({ HTMLAttributes }) {
return [
'figure',
mergeAttributes(HTMLAttributes, {
'data-artwork-embed': 'true',
class: 'my-4 overflow-hidden rounded-xl border border-gray-700 bg-gray-800/70',
}),
[
'a',
{
href: HTMLAttributes.url || '#',
class: 'block',
rel: 'noopener noreferrer nofollow',
target: '_blank',
},
[
'img',
{
src: HTMLAttributes.thumb || '',
alt: HTMLAttributes.title || 'Artwork',
class: 'h-48 w-full object-cover',
loading: 'lazy',
},
],
[
'figcaption',
{ class: 'p-3 text-sm text-gray-200' },
`${HTMLAttributes.title || 'Artwork'} (#${HTMLAttributes.artworkId || 'n/a'})`,
],
],
];
},
});
const GalleryBlock = Node.create({
name: 'galleryBlock',
group: 'block',
atom: true,
addAttributes() {
return {
images: { default: [] },
};
},
parseHTML() {
return [{ tag: 'div[data-gallery-block]' }];
},
renderHTML({ HTMLAttributes }) {
const images = Array.isArray(HTMLAttributes.images) ? HTMLAttributes.images : [];
const children: Array<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;
part: () => void;
gallery: () => void;
video: () => void;
download: () => void;
}) {
return Extension.create({
name: 'slashCommands',
addOptions() {
return {
suggestion: {
char: '/',
startOfLine: true,
items: ({ query }: { query: string }) => {
const all = [
{ title: 'Upload Image', key: 'uploadImage' },
{ title: 'Image', key: 'image' },
{ title: 'Artwork', key: 'artwork' },
{ title: 'Code', key: 'code' },
{ title: 'Quote', key: 'quote' },
{ title: 'Add a new part', key: 'part' },
{ title: 'Divider', key: 'divider' },
{ title: 'Gallery', key: 'gallery' },
{ title: 'Video', key: 'video' },
{ title: 'Download', key: 'download' },
];
return all.filter((item) => item.key.startsWith(query.toLowerCase()));
},
command: ({ props }: { editor: any; props: { key: string } }) => {
if (props.key === 'uploadImage') insert.uploadImage();
if (props.key === 'image') insert.image();
if (props.key === 'artwork') insert.artwork();
if (props.key === 'code') insert.code();
if (props.key === 'quote') insert.quote();
if (props.key === 'part') insert.part();
if (props.key === 'divider') insert.divider();
if (props.key === 'gallery') insert.gallery();
if (props.key === 'video') insert.video();
if (props.key === 'download') insert.download();
},
render: () => {
let popup: any;
let root: HTMLDivElement | null = null;
let selected = 0;
let items: Array<{ title: string; key: string }> = [];
let command: ((item: { title: string; key: string }) => void) | null = null;
const draw = () => {
if (!root) return;
root.innerHTML = items
.map((item, index) => {
const active = index === selected ? 'bg-sky-500/20 text-sky-200' : 'text-gray-200';
return `<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 [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 [insertDialog, setInsertDialog] = useState(INSERT_DIALOG_INITIAL_STATE);
const [wordCount, setWordCount] = useState(0);
const [readMinutes, setReadMinutes] = useState(1);
const [codeBlockLanguage, setCodeBlockLanguage] = useState('bash');
const [isSubmitting, setIsSubmitting] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
const [focusMode, setFocusMode] = useState(false);
const [plusMenuOpen, setPlusMenuOpen] = useState(false);
const [plusButtonState, setPlusButtonState] = useState({ visible: false, top: 0, left: 0 });
const editorContainerRef = useRef<HTMLDivElement | null>(null);
const insertSelectionRef = useRef<{ from: number; to: number } | null>(null);
const titleInputRef = useRef<HTMLTextAreaElement | null>(null);
const excerptInputRef = useRef<HTMLTextAreaElement | 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 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 closeInsertDialog = useCallback(() => {
insertSelectionRef.current = null;
setInsertDialog(INSERT_DIALOG_INITIAL_STATE);
}, []);
const openInsertDialog = useCallback((kind: Exclude<InsertDialogKind, null>) => {
const currentEditor = editorRef.current;
if (!currentEditor) {
return;
}
const { from, to } = currentEditor.state.selection;
insertSelectionRef.current = { from, to };
setInsertDialog({
kind,
url: '',
title: kind === 'video' ? 'Embedded video' : '',
label: 'Download asset',
error: '',
});
}, []);
const openLinkDialog = useCallback(() => {
const currentEditor = editorRef.current;
if (!currentEditor) {
return;
}
const { from, to } = currentEditor.state.selection;
if (from === to) {
return;
}
insertSelectionRef.current = { from, to };
setInsertDialog({
kind: 'link',
url: currentEditor.getAttributes('link').href || '',
title: '',
label: 'Download asset',
error: '',
});
}, []);
const removeSelectedLink = useCallback(() => {
const currentEditor = editorRef.current;
if (!currentEditor) {
closeInsertDialog();
return;
}
const selection = insertSelectionRef.current;
const chain = currentEditor.chain().focus();
if (selection) {
chain.setTextSelection(selection).extendMarkRange('link');
}
chain.unsetLink().run();
closeInsertDialog();
}, [closeInsertDialog]);
const submitInsertDialog = useCallback((event?: React.FormEvent<HTMLFormElement>) => {
event?.preventDefault();
if (!insertDialog.kind) {
return;
}
const currentEditor = editorRef.current;
if (!currentEditor) {
closeInsertDialog();
return;
}
if (insertDialog.kind === 'link') {
const selection = insertSelectionRef.current;
const chain = currentEditor.chain().focus();
if (selection) {
chain.setTextSelection(selection).extendMarkRange('link');
}
const normalizedLink = normalizeHttpUrl(insertDialog.url);
if (!normalizedLink) {
setInsertDialog((previous) => ({
...previous,
error: 'Enter a valid http or https URL for the selected text.',
}));
return;
}
chain.setLink({ href: normalizedLink }).run();
closeInsertDialog();
return;
}
let normalizedUrl = normalizeHttpUrl(insertDialog.url);
if (insertDialog.kind === 'video') {
normalizedUrl = normalizeVideoEmbedUrl(insertDialog.url);
}
if (!normalizedUrl) {
setInsertDialog((previous) => ({
...previous,
error: insertDialog.kind === 'video'
? 'Enter a valid YouTube, Vimeo, or direct embed URL.'
: 'Enter a valid http or https URL.',
}));
return;
}
const selection = insertSelectionRef.current;
const chain = currentEditor.chain().focus();
if (selection) {
chain.setTextSelection(selection);
}
if (insertDialog.kind === 'image') {
chain.setImage({ src: normalizedUrl }).run();
closeInsertDialog();
return;
}
if (insertDialog.kind === 'video') {
chain.insertContent({
type: 'videoEmbed',
attrs: {
src: normalizedUrl,
title: insertDialog.title.trim() || 'Embedded video',
},
}).run();
closeInsertDialog();
return;
}
chain.insertContent({
type: 'downloadAsset',
attrs: {
url: normalizedUrl,
label: insertDialog.label.trim() || 'Download asset',
},
}).run();
closeInsertDialog();
}, [closeInsertDialog, insertDialog]);
const insertActions = useMemo(() => ({
image: () => {
openInsertDialog('image');
},
uploadImage: () => bodyImageInputRef.current?.click(),
artwork: () => setArtworkModalOpen(true),
code: () => {
toggleCodeBlockWithLanguage();
},
quote: () => {
const currentEditor = editorRef.current;
if (!currentEditor) return;
currentEditor.chain().focus().toggleBlockquote().run();
},
divider: () => {
const currentEditor = editorRef.current;
if (!currentEditor) return;
currentEditor.chain().focus().setHorizontalRule().run();
},
part: () => {
const currentEditor = editorRef.current;
if (!currentEditor) return;
currentEditor.chain().focus().setHorizontalRule().run();
},
gallery: () => {
const currentEditor = editorRef.current;
if (!currentEditor) return;
const raw = window.prompt('Gallery image URLs (comma separated)', '');
const images = (raw || '').split(',').map((value) => value.trim()).filter(Boolean);
currentEditor.chain().focus().insertContent({ type: 'galleryBlock', attrs: { images } }).run();
},
video: () => {
openInsertDialog('video');
},
download: () => {
openInsertDialog('download');
},
}), [openInsertDialog, toggleCodeBlockWithLanguage]);
const editor = useEditor({
extensions: [
StarterKit.configure({
codeBlock: false,
link: false,
underline: false,
heading: { levels: [1, 2, 3] },
}),
CodeBlockLowlight.configure({
lowlight,
}),
Underline,
Image,
Link.configure({
openOnClick: false,
HTMLAttributes: {
class: 'text-sky-300 underline',
rel: 'noopener noreferrer nofollow',
target: '_blank',
},
}),
Placeholder.configure({
placeholder: 'Start writing your story...',
}),
ArtworkBlock,
GalleryBlock,
VideoEmbedBlock,
DownloadAssetBlock,
createSlashCommandExtension(insertActions),
],
immediatelyRender: false,
content: initialStory.content || EMPTY_DOC,
editorProps: {
attributes: {
class: 'tiptap prose prose-xl prose-headings:text-white prose-headings:font-bold prose-headings:tracking-tight prose-p:text-white/90 prose-p:leading-[1.9] prose-li:leading-[1.9] prose-strong:text-white prose-a:text-sky-400 prose-a:underline prose-blockquote:border-l-sky-400/60 prose-blockquote:text-white/65 prose-blockquote:italic prose-code:text-sky-300 prose-pre:bg-black/30 prose-pre:text-sky-100 max-w-none min-h-[32rem] bg-transparent px-0 py-0 text-white/90 focus:outline-none',
},
handleDrop: (_view, event) => {
const file = event.dataTransfer?.files?.[0];
if (!file || !file.type.startsWith('image/')) return false;
void (async () => {
setSaveStatus('Uploading image...');
const uploaded = await uploadImageFile(file);
if (uploaded && editor) {
editor.chain().focus().setImage({ src: uploaded }).run();
setSaveStatus('Image uploaded');
} else {
setSaveStatus('Image upload failed');
}
})();
return true;
},
handlePaste: (_view, event) => {
const file = event.clipboardData?.files?.[0];
if (!file || !file.type.startsWith('image/')) return false;
void (async () => {
setSaveStatus('Uploading image...');
const uploaded = await uploadImageFile(file);
if (uploaded && editor) {
editor.chain().focus().setImage({ src: uploaded }).run();
setSaveStatus('Image uploaded');
} else {
setSaveStatus('Image upload failed');
}
})();
return true;
},
},
});
editorRef.current = editor;
useEffect(() => {
if (!editor) return;
const updatePreview = () => {
const text = editor.getText().replace(/\s+/g, ' ').trim();
const words = text === '' ? 0 : text.split(' ').length;
setWordCount(words);
setReadMinutes(Math.max(1, Math.ceil(words / 200)));
};
updatePreview();
editor.on('update', updatePreview);
return () => {
editor.off('update', updatePreview);
};
}, [editor]);
useEffect(() => {
if (!editor) return;
const syncCodeBlockLanguage = () => {
if (!editor.isActive('codeBlock')) {
return;
}
const nextLanguage = String(editor.getAttributes('codeBlock').language || '').trim();
if (nextLanguage !== '') {
setCodeBlockLanguage(nextLanguage);
}
};
syncCodeBlockLanguage();
editor.on('selectionUpdate', syncCodeBlockLanguage);
editor.on('update', syncCodeBlockLanguage);
return () => {
editor.off('selectionUpdate', syncCodeBlockLanguage);
editor.off('update', syncCodeBlockLanguage);
};
}, [editor]);
useEffect(() => {
if (!artworkModalOpen) return;
void fetchArtworks(artworkQuery);
}, [artworkModalOpen, artworkQuery, fetchArtworks]);
useEffect(() => {
if (!editor) return;
const updateToolbar = () => {
const { from, to } = editor.state.selection;
if (from === to) {
setInlineToolbar({ visible: false, top: 0, left: 0 });
return;
}
const start = editor.view.coordsAtPos(from);
const end = editor.view.coordsAtPos(to);
setInlineToolbar({
visible: true,
top: Math.max(10, start.top + window.scrollY - 48),
left: Math.max(10, (start.left + end.right) / 2 + window.scrollX - 120),
});
};
editor.on('selectionUpdate', updateToolbar);
editor.on('blur', () => setInlineToolbar({ visible: false, top: 0, left: 0 }));
return () => {
editor.off('selectionUpdate', updateToolbar);
};
}, [editor]);
useEffect(() => {
if (!editor) return;
const hidePlusButton = () => {
setPlusButtonState({ visible: false, top: 0, left: 0 });
setPlusMenuOpen(false);
};
const updatePlusButton = () => {
const { from, to } = editor.state.selection;
if (from !== to || !editor.isFocused) {
hidePlusButton();
return;
}
const container = editorContainerRef.current;
if (!container) {
hidePlusButton();
return;
}
const domAtPos = editor.view.domAtPos(from);
const anchorNode = domAtPos.node instanceof Element ? domAtPos.node : domAtPos.node.parentElement;
const blockElement = anchorNode?.closest('p, h1, h2, h3, blockquote, pre, li');
if (!blockElement || !container.contains(blockElement)) {
hidePlusButton();
return;
}
const blockRect = blockElement.getBoundingClientRect();
const computedStyle = window.getComputedStyle(blockElement);
const parsedLineHeight = Number.parseFloat(computedStyle.lineHeight);
const lineHeight = Number.isFinite(parsedLineHeight) ? parsedLineHeight : 32;
setPlusButtonState({
visible: true,
top: blockRect.top + Math.max((lineHeight - 32) / 2, 0),
left: Math.max(16, blockRect.left - 44),
});
};
editor.on('selectionUpdate', updatePlusButton);
editor.on('update', updatePlusButton);
editor.on('focus', updatePlusButton);
editor.on('blur', hidePlusButton);
const frameId = window.requestAnimationFrame(updatePlusButton);
window.addEventListener('scroll', updatePlusButton, true);
window.addEventListener('resize', updatePlusButton);
return () => {
window.cancelAnimationFrame(frameId);
window.removeEventListener('scroll', updatePlusButton, true);
window.removeEventListener('resize', updatePlusButton);
editor.off('selectionUpdate', updatePlusButton);
editor.off('update', updatePlusButton);
editor.off('focus', updatePlusButton);
editor.off('blur', hidePlusButton);
};
}, [editor]);
const payload = useCallback(() => ({
story_id: storyId,
title,
excerpt,
cover_image: coverImage,
story_type: storyType,
tags_csv: tagsCsv,
tags: tagsCsv.split(',').map((tag) => tag.trim()).filter(Boolean),
meta_title: metaTitle || title,
meta_description: metaDescription || excerpt,
og_image: ogImage || coverImage,
status,
scheduled_for: scheduledFor || null,
content: editor?.getJSON() || EMPTY_DOC,
}), [storyId, title, excerpt, coverImage, storyType, tagsCsv, metaTitle, metaDescription, ogImage, status, scheduledFor, editor]);
useEffect(() => {
if (!editor) return;
const timer = window.setInterval(async () => {
if (isSubmitting) {
return;
}
const body = payload();
const snapshot = JSON.stringify(body);
if (snapshot === lastSavedRef.current) {
return;
}
try {
clearFeedback();
setSaveStatus('Saving...');
const data = await requestJson<{ story_id?: number; message?: string; edit_url?: string }>(
endpoints.autosave,
'POST',
captchaState.required && captchaState.inputName ? {
...body,
[captchaState.inputName]: captchaState.token || '',
} : body,
csrfToken,
captchaState,
);
if (data.story_id && !storyId) {
setStoryId(data.story_id);
}
if (data.edit_url && window.location.pathname.endsWith('/create')) {
window.history.replaceState({}, '', data.edit_url);
}
lastSavedRef.current = snapshot;
setSaveStatus(data.message || 'Saved just now');
if (captchaState.required && captchaState.token) {
resetCaptchaState();
}
emitSaveEvent('autosave', data.story_id || storyId);
} catch (error) {
applyFailure(error, 'Autosave failed');
}
}, 10000);
return () => window.clearInterval(timer);
}, [applyFailure, captchaState, clearFeedback, csrfToken, editor, emitSaveEvent, endpoints.autosave, isSubmitting, payload, resetCaptchaState, storyId]);
const persistStory = async (submitAction: 'save_draft' | 'submit_review' | 'publish_now' | 'schedule_publish') => {
const body = {
...payload(),
submit_action: submitAction,
status: submitAction === 'submit_review' ? 'pending_review' : submitAction === 'publish_now' ? 'published' : submitAction === 'schedule_publish' ? 'scheduled' : status,
scheduled_for: submitAction === 'schedule_publish' ? scheduledFor : null,
};
try {
clearFeedback();
setIsSubmitting(true);
setSaveStatus('Saving...');
const endpoint = storyId ? endpoints.update : endpoints.create;
const method = storyId ? 'PUT' : 'POST';
const data = await requestJson<{ story_id: number; message?: string; status?: string; edit_url?: string; public_url?: string }>(endpoint, method, captchaState.required && captchaState.inputName ? {
...body,
[captchaState.inputName]: captchaState.token || '',
} : body, csrfToken, captchaState);
if (data.story_id) {
setStoryId(data.story_id);
}
if (data.edit_url && window.location.pathname.endsWith('/create')) {
window.history.replaceState({}, '', data.edit_url);
}
lastSavedRef.current = JSON.stringify(payload());
setSaveStatus(data.message || 'Saved just now');
if (captchaState.required && captchaState.token) {
resetCaptchaState();
}
emitSaveEvent('manual', data.story_id || storyId);
if (submitAction === 'publish_now' && data.public_url) {
window.location.assign(data.public_url);
return;
}
} catch (error) {
applyFailure(error, submitAction === 'publish_now' ? 'Publish failed' : 'Save failed');
} finally {
setIsSubmitting(false);
}
};
const handleBodyImagePicked = async (event: React.ChangeEvent<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 completedChecks = readinessChecks.filter((check) => check.ok).length;
const progressPercent = Math.max(20, Math.round((completedChecks / Math.max(readinessChecks.length, 1)) * 100));
const topActions = [
{
key: 'cover',
label: coverImage ? 'Change cover' : 'Add cover',
detail: coverImage ? 'Refresh the hero image.' : 'Give the story a visual anchor.',
onClick: () => coverImageInputRef.current?.click(),
tone: 'sky',
},
{
key: 'part',
label: 'New part',
detail: 'Drop in the three-dot chapter separator.',
onClick: () => insertActions.part(),
tone: 'violet',
},
{
key: 'settings',
label: 'Story settings',
detail: 'Manage SEO, workflow, and metadata.',
onClick: () => setSettingsOpen(true),
tone: 'slate',
},
];
const desktopInsertActions = [
{ key: 'uploadImage', label: 'Upload photo', detail: 'Drop a full-width image into the body.' },
{ key: 'artwork', label: 'Embed artwork', detail: 'Showcase one of your published pieces.' },
{ key: 'video', label: 'Embed video', detail: 'Paste YouTube or Vimeo and let Nova normalize it.' },
{ key: 'download', label: 'Download link', detail: 'Add a clear file CTA for readers.' },
{ key: 'part', label: 'Add a new part', detail: 'Break long stories into readable chapters.' },
] as Array<{ key: keyof typeof insertActions; label: string; detail: string }>;
const quickLinks = storyId ? [
{ key: 'preview', label: 'Preview story', href: `${endpoints.previewBase}/${storyId}/preview` },
{ key: 'analytics', label: 'Story analytics', href: `${endpoints.analyticsBase}/${storyId}/analytics` },
] : [];
const storySuggestions = [
!coverImage ? {
key: 'cover',
label: 'Add a cover image',
detail: 'A strong visual anchor makes the draft feel finished faster.',
onClick: () => coverImageInputRef.current?.click(),
tone: 'sky',
} : null,
excerpt.trim().length < 40 ? {
key: 'excerpt',
label: 'Sharpen the subtitle',
detail: 'Give readers one sentence that sets the tone before the first paragraph.',
onClick: () => excerptInputRef.current?.focus(),
tone: 'violet',
} : null,
wordCount >= 220 ? {
key: 'part',
label: 'Split the next chapter',
detail: 'This draft is long enough for a visual chapter break.',
onClick: () => insertActions.part(),
tone: 'emerald',
} : null,
tagsCsv.trim().length === '' ? {
key: 'tags',
label: 'Add discovery tags',
detail: 'Open settings and add a few tags so the story is easier to surface later.',
onClick: () => setSettingsOpen(true),
tone: 'amber',
} : null,
].filter(Boolean) as Array<{ key: string; label: string; detail: string; onClick: () => void; tone: string }>;
const topActionToneClasses: Record<string, string> = {
sky: 'border-sky-300/18 bg-sky-400/10 text-sky-100 hover:border-sky-300/35 hover:bg-sky-400/15',
violet: 'border-violet-300/18 bg-violet-400/10 text-violet-100 hover:border-violet-300/35 hover:bg-violet-400/15',
slate: 'border-white/10 bg-white/[0.045] text-white/78 hover:border-white/20 hover:bg-white/[0.08]',
};
const suggestionToneClasses: Record<string, string> = {
sky: 'border-sky-300/18 bg-sky-400/10 text-sky-100',
violet: 'border-violet-300/18 bg-violet-400/10 text-violet-100',
emerald: 'border-emerald-300/18 bg-emerald-400/10 text-emerald-100',
amber: 'border-amber-300/18 bg-amber-400/10 text-amber-100',
};
const insertArtwork = (item: Artwork) => {
if (!editor) return;
editor.chain().focus().insertContent({
type: 'artworkEmbed',
attrs: {
artworkId: item.id,
title: item.title,
url: item.url,
thumb: item.thumbs?.md || item.thumbs?.sm || item.thumb || '',
},
}).run();
setArtworkModalOpen(false);
};
return (
<div className={`min-h-screen px-4 py-4 pb-24 md:px-8 ${focusMode ? 'bg-[linear-gradient(180deg,rgba(6,10,16,0.99),rgba(4,7,12,1))]' : 'bg-[radial-gradient(circle_at_top,_rgba(56,189,248,0.09),_transparent_30%),radial-gradient(circle_at_20%_20%,_rgba(14,165,233,0.07),_transparent_24%),linear-gradient(180deg,rgba(7,11,18,0.98),rgba(4,7,12,1))]'}`}>
<div className={`mx-auto ${focusMode ? 'max-w-[1180px]' : 'max-w-[1400px]'}`}>
{/* ── 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={() => setFocusMode((current) => !current)}
className={`rounded-full border px-3 py-1.5 text-sm transition ${focusMode ? 'border-sky-400/30 bg-sky-400/10 text-sky-100 hover:bg-sky-400/15' : 'border-white/10 bg-white/[0.04] text-white/70 hover:bg-white/[0.08] hover:text-white'}`}
>
{focusMode ? 'Exit focus' : 'Focus mode'}
</button>
<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>
<div className={`grid gap-6 ${focusMode ? '' : 'xl:grid-cols-[minmax(0,1fr)_300px] xl:items-start'}`}>
<main>
{!focusMode && (
<div className="mb-6 overflow-hidden rounded-[2rem] border border-white/10 bg-[linear-gradient(135deg,rgba(15,23,36,0.9),rgba(9,14,24,0.96))] shadow-[0_24px_80px_rgba(2,6,23,0.28)] backdrop-blur-xl">
<div className="flex flex-col gap-5 px-6 py-6 md:px-8 lg:flex-row lg:items-end lg:justify-between">
<div className="max-w-3xl">
<p className="text-[11px] font-semibold uppercase tracking-[0.28em] text-sky-200/55">Story Studio</p>
<h1 className="mt-3 text-3xl font-semibold tracking-tight text-white md:text-[2.35rem]">Shape the narrative before readers ever see the first line.</h1>
<p className="mt-3 max-w-2xl text-sm leading-7 text-slate-300/82 md:text-[15px]">Use the writing canvas for the draft itself, keep your metadata close, and drop in chapter breaks or rich media without leaving the flow.</p>
</div>
<div className="grid gap-3 sm:grid-cols-3 lg:min-w-[420px] lg:max-w-[460px] lg:flex-1">
{topActions.map((action) => (
<button
key={action.key}
type="button"
onClick={action.onClick}
className={`rounded-[1.35rem] border px-4 py-4 text-left transition ${topActionToneClasses[action.tone]}`}
>
<div className="text-sm font-semibold">{action.label}</div>
<div className="mt-1.5 text-xs leading-5 text-inherit/70">{action.detail}</div>
</button>
))}
</div>
</div>
</div>
)}
<div className="nb-scrollbar-none mb-5 overflow-x-auto overflow-y-hidden rounded-[1.6rem] border border-white/10 bg-[linear-gradient(180deg,rgba(11,17,27,0.94),rgba(7,10,17,0.96))] px-4 py-3 shadow-[0_18px_50px_rgba(2,6,23,0.22)] backdrop-blur-xl sm:px-5">
<div className="flex min-w-max items-center gap-2">
{desktopInsertActions.map((action) => (
<button
key={`top-toolbar-${action.key}`}
type="button"
onClick={() => insertActions[action.key]()}
className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-3 py-2 text-sm text-white/78 transition hover:border-sky-400/28 hover:bg-sky-400/[0.08] hover:text-white"
>
<span className="inline-flex h-6 w-6 items-center justify-center rounded-full bg-white/[0.05] text-[11px] text-sky-200">+</span>
{action.label}
</button>
))}
<span className="mx-1 hidden h-5 w-px bg-white/10 md:block" />
<button
type="button"
onClick={() => setSettingsOpen(true)}
className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-3 py-2 text-sm text-white/78 transition hover:border-white/20 hover:bg-white/[0.08] hover:text-white"
>
Story settings
</button>
<button
type="button"
onClick={() => setFocusMode((current) => !current)}
className={`inline-flex items-center gap-2 rounded-full border px-3 py-2 text-sm transition ${focusMode ? 'border-sky-400/28 bg-sky-400/[0.08] text-sky-100 hover:bg-sky-400/[0.14]' : 'border-white/10 bg-white/[0.04] text-white/78 hover:border-white/20 hover:bg-white/[0.08] hover:text-white'}`}
>
{focusMode ? 'Exit focus' : 'Focus mode'}
</button>
{quickLinks.map((link) => (
<a
key={`top-toolbar-${link.key}`}
href={link.href}
className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-3 py-2 text-sm text-white/78 transition hover:border-white/20 hover:bg-white/[0.08] hover:text-white"
>
{link.label}
</a>
))}
</div>
</div>
{/* ── Writing canvas ───────────────────────────────────────────────── */}
<div className={`mx-auto 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)] ${focusMode ? 'max-w-[920px]' : 'max-w-[780px]'}`}>
{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
ref={titleInputRef}
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
ref={excerptInputRef}
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>
</main>
{!focusMode ? (
<aside className="hidden xl:block">
<div className="sticky top-[5.5rem] space-y-4">
<div className="overflow-hidden rounded-[1.75rem] border border-white/10 bg-[linear-gradient(180deg,rgba(16,22,34,0.96),rgba(8,12,20,0.96))] shadow-[0_20px_60px_rgba(2,6,23,0.32)] backdrop-blur-xl">
<div className="border-b border-white/10 px-5 py-4">
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-white/40">Story pulse</p>
<div className="mt-3 flex items-end justify-between gap-3">
<div>
<p className="text-2xl font-semibold text-white">{completedChecks}/{readinessChecks.length}</p>
<p className="mt-1 text-sm text-slate-300/72">Publishing readiness</p>
</div>
<div className="rounded-2xl border border-white/10 bg-white/[0.04] px-3 py-2 text-right">
<div className="text-[11px] uppercase tracking-[0.18em] text-white/35">Rhythm</div>
<div className="mt-1 text-sm font-medium text-white/85">{wordCount > 0 ? `${wordCount.toLocaleString()} words` : 'Start writing'}</div>
<div className="mt-1 text-xs text-white/45">{readMinutes} min read</div>
</div>
</div>
<div className="mt-4 h-2 overflow-hidden rounded-full bg-white/[0.06]">
<div className="h-full rounded-full bg-[linear-gradient(90deg,rgba(56,189,248,0.9),rgba(59,130,246,0.92))]" style={{ width: `${progressPercent}%` }} />
</div>
</div>
<div className="space-y-2 px-5 py-4">
{readinessChecks.map((check) => (
<div key={check.label} className={`rounded-2xl border px-4 py-3 ${check.ok ? 'border-emerald-400/18 bg-emerald-500/10' : 'border-amber-400/18 bg-amber-500/10'}`}>
<div className="flex items-start gap-3">
<span className={`mt-0.5 inline-flex h-5 w-5 items-center justify-center rounded-full text-[11px] font-bold ${check.ok ? 'bg-emerald-400/20 text-emerald-200' : 'bg-amber-400/20 text-amber-200'}`}>{check.ok ? '✓' : '!'}</span>
<div>
<p className="text-sm font-medium text-white/88">{check.label}</p>
<p className="mt-1 text-xs leading-5 text-white/48">{check.hint}</p>
</div>
</div>
</div>
))}
</div>
</div>
{storySuggestions.length > 0 ? (
<div className="overflow-hidden rounded-[1.75rem] border border-white/10 bg-[linear-gradient(180deg,rgba(13,19,29,0.97),rgba(8,12,20,0.97))] shadow-[0_20px_60px_rgba(2,6,23,0.32)] backdrop-blur-xl">
<div className="border-b border-white/10 px-5 py-4">
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-white/40">Suggestions</p>
<p className="mt-2 text-sm leading-6 text-slate-300/78">A few next moves based on the draft you have right now.</p>
</div>
<div className="space-y-2 px-5 py-4">
{storySuggestions.map((suggestion) => (
<button
key={suggestion.key}
type="button"
onClick={suggestion.onClick}
className={`w-full rounded-2xl border px-4 py-3 text-left transition hover:translate-x-0.5 ${suggestionToneClasses[suggestion.tone]}`}
>
<div className="text-sm font-semibold">{suggestion.label}</div>
<div className="mt-1 text-xs leading-5 text-inherit/70">{suggestion.detail}</div>
</button>
))}
</div>
</div>
) : null}
<div className="overflow-hidden rounded-[1.75rem] border border-white/10 bg-[linear-gradient(180deg,rgba(13,19,29,0.97),rgba(8,12,20,0.97))] shadow-[0_20px_60px_rgba(2,6,23,0.32)] backdrop-blur-xl">
<div className="border-b border-white/10 px-5 py-4">
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-white/40">Desktop shortcuts</p>
<p className="mt-2 text-sm leading-6 text-slate-300/78">Keep the heavy-lift actions nearby while the canvas stays clean.</p>
</div>
<div className="space-y-2 px-5 py-4">
{desktopInsertActions.map((action) => (
<button
key={action.key}
type="button"
onClick={() => insertActions[action.key]()}
className="w-full rounded-2xl border border-white/10 bg-white/[0.035] px-4 py-3 text-left transition hover:border-sky-400/30 hover:bg-sky-400/[0.08]"
>
<div className="text-sm font-semibold text-white/88">{action.label}</div>
<div className="mt-1 text-xs leading-5 text-white/48">{action.detail}</div>
</button>
))}
</div>
{quickLinks.length > 0 ? (
<div className="border-t border-white/10 px-5 py-4">
<div className="space-y-2">
{quickLinks.map((link) => (
<a
key={link.key}
href={link.href}
className="block rounded-2xl border border-white/10 bg-white/[0.035] px-4 py-3 text-sm font-medium text-white/80 transition hover:border-white/20 hover:bg-white/[0.08] hover:text-white"
>
{link.label}
</a>
))}
</div>
</div>
) : null}
</div>
</div>
</aside>
) : null}
</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: 'Add a new part', icon: '⋯', key: 'part' },
{ 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 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: openLinkDialog, 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<Array<{ label: string; title: string; action: () => void; active: boolean; extra: string }>>).map((group, groupIndex) => (
<React.Fragment key={`inline-toolbar-group-${groupIndex}`}>
{groupIndex > 0 ? <span className="mx-1 h-6 w-px bg-white/10" aria-hidden="true" /> : null}
<div className="flex items-center gap-0.5 px-0.5">
{group.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>
</React.Fragment>
))}
</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' }]} />
<DateTimePicker
value={scheduledFor}
onChange={setScheduledFor}
placeholder="Pick a publish date"
clearable
className="bg-slate-950/60"
/>
</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={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>
)}
<Modal
open={Boolean(insertDialog.kind)}
onClose={closeInsertDialog}
title={insertDialog.kind ? INSERT_DIALOG_CONTENT[insertDialog.kind].title : ''}
size="md"
footer={insertDialog.kind ? (
<div className="ml-auto flex items-center gap-2">
{insertDialog.kind === 'link' && (
<button
type="button"
onClick={removeSelectedLink}
className="rounded-xl border border-rose-400/20 bg-rose-500/10 px-4 py-2 text-sm text-rose-200 transition hover:bg-rose-500/20"
>
Remove link
</button>
)}
<button
type="button"
onClick={closeInsertDialog}
className="rounded-xl border border-white/10 bg-white/[0.04] px-4 py-2 text-sm text-white/70 transition hover:bg-white/[0.08] hover:text-white"
>
Cancel
</button>
<button
type="submit"
form="story-insert-dialog-form"
className="rounded-xl bg-sky-500 px-4 py-2 text-sm font-medium text-white shadow-[0_6px_20px_rgba(14,165,233,0.35)] transition hover:bg-sky-400"
>
{INSERT_DIALOG_CONTENT[insertDialog.kind].confirmLabel}
</button>
</div>
) : null}
>
{insertDialog.kind ? (
<form id="story-insert-dialog-form" onSubmit={submitInsertDialog} className="space-y-5">
<div className="space-y-2">
<p className="text-sm leading-6 text-slate-200">{INSERT_DIALOG_CONTENT[insertDialog.kind].description}</p>
<p className="text-xs leading-5 text-slate-400">{INSERT_DIALOG_CONTENT[insertDialog.kind].urlHint}</p>
</div>
<div className="space-y-2">
<label className="block text-xs font-semibold uppercase tracking-[0.18em] text-white/40">
{INSERT_DIALOG_CONTENT[insertDialog.kind].urlLabel}
</label>
<input
value={insertDialog.url}
onChange={(event) => setInsertDialog((previous) => ({ ...previous, url: event.target.value, error: '' }))}
placeholder={INSERT_DIALOG_CONTENT[insertDialog.kind].urlPlaceholder}
className="w-full rounded-2xl border border-white/10 bg-slate-950/70 px-4 py-3 text-sm text-white placeholder:text-white/25 focus:border-sky-400/40 focus:outline-none"
/>
</div>
{insertDialog.kind === 'video' && (
<div className="space-y-2">
<label className="block text-xs font-semibold uppercase tracking-[0.18em] text-white/40">
Accessible title
</label>
<input
value={insertDialog.title}
onChange={(event) => setInsertDialog((previous) => ({ ...previous, title: event.target.value }))}
placeholder="Embedded video"
className="w-full rounded-2xl border border-white/10 bg-slate-950/70 px-4 py-3 text-sm text-white placeholder:text-white/25 focus:border-sky-400/40 focus:outline-none"
/>
<p className="text-xs leading-5 text-slate-400">This helps screen readers describe the embedded video block.</p>
</div>
)}
{insertDialog.kind === 'download' && (
<div className="space-y-2">
<label className="block text-xs font-semibold uppercase tracking-[0.18em] text-white/40">
Button label
</label>
<input
value={insertDialog.label}
onChange={(event) => setInsertDialog((previous) => ({ ...previous, label: event.target.value }))}
placeholder="Download asset"
className="w-full rounded-2xl border border-white/10 bg-slate-950/70 px-4 py-3 text-sm text-white placeholder:text-white/25 focus:border-sky-400/40 focus:outline-none"
/>
<p className="text-xs leading-5 text-slate-400">Readers will see this label on the download button inside the story.</p>
</div>
)}
{insertDialog.error ? (
<div className="rounded-2xl border border-rose-400/20 bg-rose-500/10 px-4 py-3 text-sm text-rose-200">
{insertDialog.error}
</div>
) : null}
</form>
) : null}
</Modal>
{/* 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>
);
}