Wire admin studio SSR and search infrastructure
This commit is contained in:
@@ -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} />
|
||||
|
||||
Reference in New Issue
Block a user