Wire admin studio SSR and search infrastructure

This commit is contained in:
2026-05-01 11:46:06 +02:00
parent 257b0dbef6
commit 18cea8b0f0
329 changed files with 197465 additions and 2741 deletions

View File

@@ -13,6 +13,8 @@ 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 = {
@@ -43,7 +45,6 @@ type StoryPayload = {
tags_csv: string;
meta_title: string;
meta_description: string;
canonical_url: string;
og_image: string;
status: string;
scheduled_for: string;
@@ -68,6 +69,8 @@ type Props = {
csrfToken: string;
};
type InsertDialogKind = 'image' | 'video' | 'download' | 'link' | null;
const EMPTY_DOC = {
type: 'doc',
content: [{ type: 'paragraph' }],
@@ -90,6 +93,108 @@ const CODE_BLOCK_LANGUAGES = [
{ 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',
@@ -263,6 +368,7 @@ function createSlashCommandExtension(insert: {
code: () => void;
quote: () => void;
divider: () => void;
part: () => void;
gallery: () => void;
video: () => void;
download: () => void;
@@ -282,6 +388,7 @@ function createSlashCommandExtension(insert: {
{ 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' },
@@ -295,6 +402,7 @@ function createSlashCommandExtension(insert: {
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();
@@ -438,7 +546,6 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
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 || '');
@@ -449,14 +556,19 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
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: '',
@@ -534,17 +646,6 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
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}`, {
@@ -612,12 +713,152 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
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: () => {
const currentEditor = editorRef.current;
const url = window.prompt('Image URL', 'https://');
if (!url || !currentEditor) return;
currentEditor.chain().focus().setImage({ src: url }).run();
openInsertDialog('image');
},
uploadImage: () => bodyImageInputRef.current?.click(),
artwork: () => setArtworkModalOpen(true),
@@ -634,6 +875,11 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
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;
@@ -642,21 +888,12 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
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();
openInsertDialog('video');
},
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();
openInsertDialog('download');
},
}), [toggleCodeBlockWithLanguage]);
}), [openInsertDialog, toggleCodeBlockWithLanguage]);
const editor = useEditor({
extensions: [
@@ -692,7 +929,7 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
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',
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];
@@ -810,39 +1047,62 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
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) {
setPlusButtonState({ visible: false, top: 0, left: 0 });
setPlusMenuOpen(false);
if (from !== to || !editor.isFocused) {
hidePlusButton();
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);
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]);
@@ -856,12 +1116,11 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
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]);
}), [storyId, title, excerpt, coverImage, storyType, tagsCsv, metaTitle, metaDescription, ogImage, status, scheduledFor, editor]);
useEffect(() => {
if (!editor) return;
@@ -993,6 +1252,84 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
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;
@@ -1009,7 +1346,8 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
};
return (
<div className="mx-auto max-w-4xl px-4 py-4 pb-24 md:px-8">
<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">
@@ -1022,6 +1360,13 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
</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)}
@@ -1049,8 +1394,75 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
</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 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)]">
<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" />
@@ -1110,6 +1522,7 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
{/* Title */}
<div className="mb-3">
<textarea
ref={titleInputRef}
value={title}
onChange={(event) => {
setTitle(event.target.value);
@@ -1130,6 +1543,7 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
{/* 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);
@@ -1183,6 +1597,104 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
)}
</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) ── */}
@@ -1218,6 +1730,7 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
{ 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
@@ -1242,29 +1755,42 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
{/* ── 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"
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: () => 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>
[
{ 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>
)}
@@ -1348,7 +1874,13 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
<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" />
<DateTimePicker
value={scheduledFor}
onChange={setScheduledFor}
placeholder="Pick a publish date"
clearable
className="bg-slate-950/60"
/>
</div>
{/* SEO */}
@@ -1357,7 +1889,6 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
<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>
@@ -1411,6 +1942,97 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
</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} />