2042 lines
88 KiB
TypeScript
2042 lines
88 KiB
TypeScript
// @ts-nocheck
|
||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||
import { EditorContent, Extension, useEditor } from '@tiptap/react';
|
||
import StarterKit from '@tiptap/starter-kit';
|
||
import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight';
|
||
import Image from '@tiptap/extension-image';
|
||
import Link from '@tiptap/extension-link';
|
||
import Placeholder from '@tiptap/extension-placeholder';
|
||
import Underline from '@tiptap/extension-underline';
|
||
import Suggestion from '@tiptap/suggestion';
|
||
import { Node, mergeAttributes } from '@tiptap/core';
|
||
import { common, createLowlight } from 'lowlight';
|
||
import tippy from 'tippy.js';
|
||
import { buildBotFingerprint } from '../../lib/security/botFingerprint';
|
||
import TurnstileField from '../security/TurnstileField';
|
||
import DateTimePicker from '../ui/DateTimePicker';
|
||
import Modal from '../ui/Modal';
|
||
import NovaSelect from '../ui/NovaSelect';
|
||
|
||
type StoryType = {
|
||
slug: string;
|
||
name: string;
|
||
};
|
||
|
||
type Artwork = {
|
||
id: number;
|
||
title: string;
|
||
url: string;
|
||
thumb: string | null;
|
||
thumbs?: {
|
||
xs?: string | null;
|
||
sm?: string | null;
|
||
md?: string | null;
|
||
lg?: string | null;
|
||
xl?: string | null;
|
||
};
|
||
};
|
||
|
||
type StoryPayload = {
|
||
id?: number;
|
||
title: string;
|
||
excerpt: string;
|
||
cover_image: string;
|
||
story_type: string;
|
||
tags_csv: string;
|
||
meta_title: string;
|
||
meta_description: string;
|
||
og_image: string;
|
||
status: string;
|
||
scheduled_for: string;
|
||
content: Record<string, unknown>;
|
||
};
|
||
|
||
type Endpoints = {
|
||
create: string;
|
||
update: string;
|
||
autosave: string;
|
||
uploadImage: string;
|
||
artworks: string;
|
||
previewBase: string;
|
||
analyticsBase: string;
|
||
};
|
||
|
||
type Props = {
|
||
mode: 'create' | 'edit';
|
||
initialStory: StoryPayload;
|
||
storyTypes: StoryType[];
|
||
endpoints: Endpoints;
|
||
csrfToken: string;
|
||
};
|
||
|
||
type InsertDialogKind = 'image' | 'video' | 'download' | 'link' | null;
|
||
|
||
const EMPTY_DOC = {
|
||
type: 'doc',
|
||
content: [{ type: 'paragraph' }],
|
||
};
|
||
|
||
const lowlight = createLowlight(common);
|
||
|
||
const CODE_BLOCK_LANGUAGES = [
|
||
{ value: 'bash', label: 'Bash / Shell' },
|
||
{ value: 'plaintext', label: 'Plain text' },
|
||
{ value: 'php', label: 'PHP' },
|
||
{ value: 'javascript', label: 'JavaScript' },
|
||
{ value: 'typescript', label: 'TypeScript' },
|
||
{ value: 'json', label: 'JSON' },
|
||
{ value: 'html', label: 'HTML' },
|
||
{ value: 'css', label: 'CSS' },
|
||
{ value: 'sql', label: 'SQL' },
|
||
{ value: 'xml', label: 'XML / SVG' },
|
||
{ value: 'yaml', label: 'YAML' },
|
||
{ value: 'markdown', label: 'Markdown' },
|
||
];
|
||
|
||
const INSERT_DIALOG_CONTENT = {
|
||
image: {
|
||
title: 'Add image from URL',
|
||
description: 'Paste a direct image URL to insert a full image block into the story body.',
|
||
confirmLabel: 'Insert image',
|
||
urlLabel: 'Image URL',
|
||
urlPlaceholder: 'https://images.example.com/story-scene.jpg',
|
||
urlHint: 'Use a direct image file URL when possible for the most reliable preview.',
|
||
},
|
||
video: {
|
||
title: 'Embed a video',
|
||
description: 'Paste a YouTube or Vimeo link. Common watch and share URLs will be converted to embed URLs automatically.',
|
||
confirmLabel: 'Embed video',
|
||
urlLabel: 'Video URL',
|
||
urlPlaceholder: 'https://www.youtube.com/watch?v=example',
|
||
urlHint: 'You can paste a normal watch URL, share URL, or a direct embed URL.',
|
||
},
|
||
download: {
|
||
title: 'Add a download link',
|
||
description: 'Create a downloadable asset button with a friendly label for readers.',
|
||
confirmLabel: 'Add download',
|
||
urlLabel: 'File URL',
|
||
urlPlaceholder: 'https://cdn.example.com/files/asset.zip',
|
||
urlHint: 'Point this at the exact file you want readers to download.',
|
||
},
|
||
link: {
|
||
title: 'Add link to selection',
|
||
description: 'Attach a link to the currently selected text in your story.',
|
||
confirmLabel: 'Save link',
|
||
urlLabel: 'Link URL',
|
||
urlPlaceholder: 'https://skinbase.org/help',
|
||
urlHint: 'Paste any http or https URL. Leave it empty and use Remove link to clear an existing link.',
|
||
},
|
||
};
|
||
|
||
const INSERT_DIALOG_INITIAL_STATE = {
|
||
kind: null as InsertDialogKind,
|
||
url: '',
|
||
title: '',
|
||
label: 'Download asset',
|
||
error: '',
|
||
};
|
||
|
||
function normalizeHttpUrl(rawValue: string): string | null {
|
||
const trimmed = rawValue.trim();
|
||
if (trimmed === '') {
|
||
return null;
|
||
}
|
||
|
||
const withProtocol = /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed) ? trimmed : `https://${trimmed.replace(/^\/+/, '')}`;
|
||
|
||
try {
|
||
const parsed = new URL(withProtocol);
|
||
if (!['http:', 'https:'].includes(parsed.protocol)) {
|
||
return null;
|
||
}
|
||
|
||
return parsed.toString();
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function normalizeVideoEmbedUrl(rawValue: string): string | null {
|
||
const normalized = normalizeHttpUrl(rawValue);
|
||
if (!normalized) {
|
||
return null;
|
||
}
|
||
|
||
const parsed = new URL(normalized);
|
||
const host = parsed.hostname.replace(/^www\./i, '').toLowerCase();
|
||
const path = parsed.pathname;
|
||
|
||
if (host === 'youtu.be') {
|
||
const videoId = path.replace(/^\//, '').split('/')[0];
|
||
return videoId ? `https://www.youtube.com/embed/${videoId}` : normalized;
|
||
}
|
||
|
||
if (host === 'youtube.com' || host === 'm.youtube.com') {
|
||
if (path === '/watch') {
|
||
const videoId = parsed.searchParams.get('v');
|
||
return videoId ? `https://www.youtube.com/embed/${videoId}` : normalized;
|
||
}
|
||
|
||
const pathMatch = path.match(/^\/(embed|shorts|live)\/([^/?#]+)/i);
|
||
if (pathMatch?.[2]) {
|
||
return `https://www.youtube.com/embed/${pathMatch[2]}`;
|
||
}
|
||
}
|
||
|
||
if (host === 'vimeo.com') {
|
||
const videoId = path.replace(/^\//, '').split('/')[0];
|
||
return videoId ? `https://player.vimeo.com/video/${videoId}` : normalized;
|
||
}
|
||
|
||
if (host === 'player.vimeo.com') {
|
||
return normalized;
|
||
}
|
||
|
||
return normalized;
|
||
}
|
||
|
||
const ArtworkBlock = Node.create({
|
||
name: 'artworkEmbed',
|
||
group: 'block',
|
||
atom: true,
|
||
|
||
addAttributes() {
|
||
return {
|
||
artworkId: { default: null },
|
||
title: { default: '' },
|
||
url: { default: '' },
|
||
thumb: { default: '' },
|
||
};
|
||
},
|
||
|
||
parseHTML() {
|
||
return [{ tag: 'figure[data-artwork-embed]' }];
|
||
},
|
||
|
||
renderHTML({ HTMLAttributes }) {
|
||
return [
|
||
'figure',
|
||
mergeAttributes(HTMLAttributes, {
|
||
'data-artwork-embed': 'true',
|
||
class: 'my-4 overflow-hidden rounded-xl border border-gray-700 bg-gray-800/70',
|
||
}),
|
||
[
|
||
'a',
|
||
{
|
||
href: HTMLAttributes.url || '#',
|
||
class: 'block',
|
||
rel: 'noopener noreferrer nofollow',
|
||
target: '_blank',
|
||
},
|
||
[
|
||
'img',
|
||
{
|
||
src: HTMLAttributes.thumb || '',
|
||
alt: HTMLAttributes.title || 'Artwork',
|
||
class: 'h-48 w-full object-cover',
|
||
loading: 'lazy',
|
||
},
|
||
],
|
||
[
|
||
'figcaption',
|
||
{ class: 'p-3 text-sm text-gray-200' },
|
||
`${HTMLAttributes.title || 'Artwork'} (#${HTMLAttributes.artworkId || 'n/a'})`,
|
||
],
|
||
],
|
||
];
|
||
},
|
||
});
|
||
|
||
const GalleryBlock = Node.create({
|
||
name: 'galleryBlock',
|
||
group: 'block',
|
||
atom: true,
|
||
|
||
addAttributes() {
|
||
return {
|
||
images: { default: [] },
|
||
};
|
||
},
|
||
|
||
parseHTML() {
|
||
return [{ tag: 'div[data-gallery-block]' }];
|
||
},
|
||
|
||
renderHTML({ HTMLAttributes }) {
|
||
const images = Array.isArray(HTMLAttributes.images) ? HTMLAttributes.images : [];
|
||
const children: Array<unknown> = images.slice(0, 6).map((src: string) => [
|
||
'img',
|
||
{ src, class: 'h-36 w-full rounded-lg object-cover', loading: 'lazy', alt: 'Gallery image' },
|
||
]);
|
||
|
||
if (children.length === 0) {
|
||
children.push(['div', { class: 'rounded-lg border border-dashed border-gray-600 p-4 text-xs text-gray-400' }, 'Empty gallery block']);
|
||
}
|
||
|
||
return [
|
||
'div',
|
||
mergeAttributes(HTMLAttributes, {
|
||
'data-gallery-block': 'true',
|
||
class: 'my-4 grid grid-cols-2 gap-3 rounded-xl border border-gray-700 bg-gray-800/50 p-3',
|
||
}),
|
||
...children,
|
||
];
|
||
},
|
||
});
|
||
|
||
const VideoEmbedBlock = Node.create({
|
||
name: 'videoEmbed',
|
||
group: 'block',
|
||
atom: true,
|
||
|
||
addAttributes() {
|
||
return {
|
||
src: { default: '' },
|
||
title: { default: 'Embedded video' },
|
||
};
|
||
},
|
||
|
||
parseHTML() {
|
||
return [{ tag: 'figure[data-video-embed]' }];
|
||
},
|
||
|
||
renderHTML({ HTMLAttributes }) {
|
||
return [
|
||
'figure',
|
||
mergeAttributes(HTMLAttributes, {
|
||
'data-video-embed': 'true',
|
||
class: 'my-4 overflow-hidden rounded-xl border border-gray-700 bg-gray-800/60',
|
||
}),
|
||
[
|
||
'iframe',
|
||
{
|
||
src: HTMLAttributes.src || '',
|
||
title: HTMLAttributes.title || 'Embedded video',
|
||
class: 'aspect-video w-full',
|
||
allow: 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share',
|
||
allowfullscreen: 'true',
|
||
frameborder: '0',
|
||
referrerpolicy: 'strict-origin-when-cross-origin',
|
||
},
|
||
],
|
||
];
|
||
},
|
||
});
|
||
|
||
const DownloadAssetBlock = Node.create({
|
||
name: 'downloadAsset',
|
||
group: 'block',
|
||
atom: true,
|
||
|
||
addAttributes() {
|
||
return {
|
||
url: { default: '' },
|
||
label: { default: 'Download asset' },
|
||
};
|
||
},
|
||
|
||
parseHTML() {
|
||
return [{ tag: 'div[data-download-asset]' }];
|
||
},
|
||
|
||
renderHTML({ HTMLAttributes }) {
|
||
return [
|
||
'div',
|
||
mergeAttributes(HTMLAttributes, {
|
||
'data-download-asset': 'true',
|
||
class: 'my-4 rounded-xl border border-gray-700 bg-gray-800/60 p-4',
|
||
}),
|
||
[
|
||
'a',
|
||
{
|
||
href: HTMLAttributes.url || '#',
|
||
class: 'inline-flex items-center rounded-lg border border-sky-500/40 bg-sky-500/10 px-3 py-2 text-sm text-sky-200',
|
||
target: '_blank',
|
||
rel: 'noopener noreferrer nofollow',
|
||
download: 'true',
|
||
},
|
||
HTMLAttributes.label || 'Download asset',
|
||
],
|
||
];
|
||
},
|
||
});
|
||
|
||
function createSlashCommandExtension(insert: {
|
||
image: () => void;
|
||
uploadImage: () => void;
|
||
artwork: () => void;
|
||
code: () => void;
|
||
quote: () => void;
|
||
divider: () => void;
|
||
part: () => void;
|
||
gallery: () => void;
|
||
video: () => void;
|
||
download: () => void;
|
||
}) {
|
||
return Extension.create({
|
||
name: 'slashCommands',
|
||
|
||
addOptions() {
|
||
return {
|
||
suggestion: {
|
||
char: '/',
|
||
startOfLine: true,
|
||
items: ({ query }: { query: string }) => {
|
||
const all = [
|
||
{ title: 'Upload Image', key: 'uploadImage' },
|
||
{ title: 'Image', key: 'image' },
|
||
{ title: 'Artwork', key: 'artwork' },
|
||
{ title: 'Code', key: 'code' },
|
||
{ title: 'Quote', key: 'quote' },
|
||
{ title: 'Add a new part', key: 'part' },
|
||
{ title: 'Divider', key: 'divider' },
|
||
{ title: 'Gallery', key: 'gallery' },
|
||
{ title: 'Video', key: 'video' },
|
||
{ title: 'Download', key: 'download' },
|
||
];
|
||
return all.filter((item) => item.key.startsWith(query.toLowerCase()));
|
||
},
|
||
command: ({ props }: { editor: any; props: { key: string } }) => {
|
||
if (props.key === 'uploadImage') insert.uploadImage();
|
||
if (props.key === 'image') insert.image();
|
||
if (props.key === 'artwork') insert.artwork();
|
||
if (props.key === 'code') insert.code();
|
||
if (props.key === 'quote') insert.quote();
|
||
if (props.key === 'part') insert.part();
|
||
if (props.key === 'divider') insert.divider();
|
||
if (props.key === 'gallery') insert.gallery();
|
||
if (props.key === 'video') insert.video();
|
||
if (props.key === 'download') insert.download();
|
||
},
|
||
render: () => {
|
||
let popup: any;
|
||
let root: HTMLDivElement | null = null;
|
||
let selected = 0;
|
||
let items: Array<{ title: string; key: string }> = [];
|
||
let command: ((item: { title: string; key: string }) => void) | null = null;
|
||
|
||
const draw = () => {
|
||
if (!root) return;
|
||
root.innerHTML = items
|
||
.map((item, index) => {
|
||
const active = index === selected ? 'bg-sky-500/20 text-sky-200' : 'text-gray-200';
|
||
return `<button data-index="${index}" class="block w-full rounded-md px-3 py-2 text-left text-sm ${active}">/${item.key} <span class="text-gray-400">${item.title}</span></button>`;
|
||
})
|
||
.join('');
|
||
|
||
root.querySelectorAll('button').forEach((button) => {
|
||
button.addEventListener('mousedown', (event) => {
|
||
event.preventDefault();
|
||
const idx = Number((event.currentTarget as HTMLButtonElement).dataset.index || 0);
|
||
const choice = items[idx];
|
||
if (choice && command) command(choice);
|
||
});
|
||
});
|
||
};
|
||
|
||
return {
|
||
onStart: (props: any) => {
|
||
items = props.items;
|
||
command = props.command;
|
||
selected = 0;
|
||
root = document.createElement('div');
|
||
root.className = 'w-52 rounded-lg border border-gray-700 bg-gray-900 p-1 shadow-xl';
|
||
draw();
|
||
|
||
popup = tippy('body', {
|
||
getReferenceClientRect: props.clientRect,
|
||
appendTo: () => document.body,
|
||
content: root,
|
||
showOnCreate: true,
|
||
interactive: true,
|
||
trigger: 'manual',
|
||
placement: 'bottom-start',
|
||
});
|
||
},
|
||
onUpdate: (props: any) => {
|
||
items = props.items;
|
||
command = props.command;
|
||
if (selected >= items.length) selected = 0;
|
||
draw();
|
||
popup?.[0]?.setProps({ getReferenceClientRect: props.clientRect });
|
||
},
|
||
onKeyDown: (props: any) => {
|
||
if (props.event.key === 'ArrowDown') {
|
||
selected = (selected + 1) % Math.max(items.length, 1);
|
||
draw();
|
||
return true;
|
||
}
|
||
if (props.event.key === 'ArrowUp') {
|
||
selected = (selected + Math.max(items.length, 1) - 1) % Math.max(items.length, 1);
|
||
draw();
|
||
return true;
|
||
}
|
||
if (props.event.key === 'Enter') {
|
||
const choice = items[selected];
|
||
if (choice && command) command(choice);
|
||
return true;
|
||
}
|
||
if (props.event.key === 'Escape') {
|
||
popup?.[0]?.hide();
|
||
return true;
|
||
}
|
||
return false;
|
||
},
|
||
onExit: () => {
|
||
popup?.[0]?.destroy();
|
||
popup = null;
|
||
root = null;
|
||
},
|
||
};
|
||
},
|
||
},
|
||
};
|
||
},
|
||
|
||
addProseMirrorPlugins() {
|
||
return [
|
||
Suggestion({
|
||
editor: this.editor,
|
||
...this.options.suggestion,
|
||
}),
|
||
];
|
||
},
|
||
});
|
||
}
|
||
|
||
async function botHeaders(extra: Record<string, string> = {}, captcha: { token?: string } = {}) {
|
||
const fingerprint = await buildBotFingerprint();
|
||
|
||
return {
|
||
...extra,
|
||
'X-Bot-Fingerprint': fingerprint,
|
||
...(captcha?.token ? { 'X-Captcha-Token': captcha.token } : {}),
|
||
};
|
||
}
|
||
|
||
async function requestJson<T>(url: string, method: string, body: unknown, csrfToken: string, captcha: { token?: string } = {}): Promise<T> {
|
||
const response = await fetch(url, {
|
||
method,
|
||
headers: await botHeaders({
|
||
'Content-Type': 'application/json',
|
||
'X-CSRF-TOKEN': csrfToken,
|
||
'X-Requested-With': 'XMLHttpRequest',
|
||
}, captcha),
|
||
body: JSON.stringify(body),
|
||
});
|
||
|
||
const payload = await response.json().catch(() => ({}));
|
||
|
||
if (!response.ok) {
|
||
const error = new Error((payload as any)?.message || `Request failed: ${response.status}`) as Error & { status?: number; payload?: unknown };
|
||
error.status = response.status;
|
||
error.payload = payload;
|
||
throw error;
|
||
}
|
||
|
||
return payload as T;
|
||
}
|
||
|
||
export default function StoryEditor({ mode, initialStory, storyTypes, endpoints, csrfToken }: Props) {
|
||
const [storyId, setStoryId] = useState<number | undefined>(initialStory.id);
|
||
const [title, setTitle] = useState(initialStory.title || '');
|
||
const [excerpt, setExcerpt] = useState(initialStory.excerpt || '');
|
||
const [coverImage, setCoverImage] = useState(initialStory.cover_image || '');
|
||
const [storyType, setStoryType] = useState(initialStory.story_type || 'creator_story');
|
||
const [tagsCsv, setTagsCsv] = useState(initialStory.tags_csv || '');
|
||
const [metaTitle, setMetaTitle] = useState(initialStory.meta_title || '');
|
||
const [metaDescription, setMetaDescription] = useState(initialStory.meta_description || '');
|
||
const [ogImage, setOgImage] = useState(initialStory.og_image || '');
|
||
const [status, setStatus] = useState(initialStory.status || 'draft');
|
||
const [scheduledFor, setScheduledFor] = useState(initialStory.scheduled_for || '');
|
||
const [saveStatus, setSaveStatus] = useState('Autosave idle');
|
||
const [artworkModalOpen, setArtworkModalOpen] = useState(false);
|
||
const [artworkResults, setArtworkResults] = useState<Artwork[]>([]);
|
||
const [artworkQuery, setArtworkQuery] = useState('');
|
||
const [inlineToolbar, setInlineToolbar] = useState({ visible: false, top: 0, left: 0 });
|
||
const [fieldErrors, setFieldErrors] = useState<Record<string, string[]>>({});
|
||
const [generalError, setGeneralError] = useState('');
|
||
const [insertDialog, setInsertDialog] = useState(INSERT_DIALOG_INITIAL_STATE);
|
||
const [wordCount, setWordCount] = useState(0);
|
||
const [readMinutes, setReadMinutes] = useState(1);
|
||
const [codeBlockLanguage, setCodeBlockLanguage] = useState('bash');
|
||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||
const [focusMode, setFocusMode] = useState(false);
|
||
const [plusMenuOpen, setPlusMenuOpen] = useState(false);
|
||
const [plusButtonState, setPlusButtonState] = useState({ visible: false, top: 0, left: 0 });
|
||
const editorContainerRef = useRef<HTMLDivElement | null>(null);
|
||
const insertSelectionRef = useRef<{ from: number; to: number } | null>(null);
|
||
const titleInputRef = useRef<HTMLTextAreaElement | null>(null);
|
||
const excerptInputRef = useRef<HTMLTextAreaElement | null>(null);
|
||
const [captchaState, setCaptchaState] = useState({
|
||
required: false,
|
||
token: '',
|
||
message: '',
|
||
nonce: 0,
|
||
provider: 'turnstile',
|
||
siteKey: '',
|
||
inputName: 'cf-turnstile-response',
|
||
scriptUrl: '',
|
||
});
|
||
const lastSavedRef = useRef('');
|
||
const editorRef = useRef<any>(null);
|
||
const bodyImageInputRef = useRef<HTMLInputElement | null>(null);
|
||
const coverImageInputRef = useRef<HTMLInputElement | null>(null);
|
||
|
||
const emitSaveEvent = useCallback((kind: 'autosave' | 'manual', id?: number) => {
|
||
window.dispatchEvent(new CustomEvent('story-editor:saved', {
|
||
detail: {
|
||
kind,
|
||
storyId: id,
|
||
savedAt: new Date().toISOString(),
|
||
},
|
||
}));
|
||
}, []);
|
||
|
||
const resetCaptchaState = useCallback(() => {
|
||
setCaptchaState((prev) => ({
|
||
...prev,
|
||
required: false,
|
||
token: '',
|
||
message: '',
|
||
nonce: prev.nonce + 1,
|
||
}));
|
||
}, []);
|
||
|
||
const captureCaptchaRequirement = useCallback((payload: any = {}) => {
|
||
const requiresCaptcha = !!(payload?.requires_captcha || payload?.requiresCaptcha);
|
||
if (!requiresCaptcha) {
|
||
return false;
|
||
}
|
||
|
||
const nextCaptcha = payload?.captcha || {};
|
||
const message = payload?.errors?.captcha?.[0] || payload?.message || 'Complete the captcha challenge to continue.';
|
||
|
||
setCaptchaState((prev) => ({
|
||
required: true,
|
||
token: '',
|
||
message,
|
||
nonce: prev.nonce + 1,
|
||
provider: nextCaptcha.provider || payload?.captcha_provider || prev.provider || 'turnstile',
|
||
siteKey: nextCaptcha.siteKey || payload?.captcha_site_key || prev.siteKey || '',
|
||
inputName: nextCaptcha.inputName || payload?.captcha_input || prev.inputName || 'cf-turnstile-response',
|
||
scriptUrl: nextCaptcha.scriptUrl || payload?.captcha_script_url || prev.scriptUrl || '',
|
||
}));
|
||
|
||
return true;
|
||
}, []);
|
||
|
||
const applyFailure = useCallback((error: any, fallback: string) => {
|
||
const payload = error?.payload || {};
|
||
const nextErrors = payload?.errors && typeof payload.errors === 'object' ? payload.errors : {};
|
||
setFieldErrors(nextErrors);
|
||
const requiresCaptcha = captureCaptchaRequirement(payload);
|
||
const message = nextErrors?.captcha?.[0]
|
||
|| nextErrors?.title?.[0]
|
||
|| nextErrors?.content?.[0]
|
||
|| payload?.message
|
||
|| fallback;
|
||
setGeneralError(message);
|
||
setSaveStatus(requiresCaptcha ? 'Captcha required' : message);
|
||
}, [captureCaptchaRequirement]);
|
||
|
||
const clearFeedback = useCallback(() => {
|
||
setGeneralError('');
|
||
setFieldErrors({});
|
||
}, []);
|
||
|
||
const fetchArtworks = useCallback(async (query: string) => {
|
||
const q = encodeURIComponent(query);
|
||
const response = await fetch(`${endpoints.artworks}?q=${q}`, {
|
||
headers: {
|
||
'X-Requested-With': 'XMLHttpRequest',
|
||
},
|
||
});
|
||
if (!response.ok) return;
|
||
const data = await response.json();
|
||
setArtworkResults(Array.isArray(data.artworks) ? data.artworks : []);
|
||
}, [endpoints.artworks]);
|
||
|
||
const uploadImageFile = useCallback(async (file: File): Promise<string | null> => {
|
||
const formData = new FormData();
|
||
formData.append('image', file);
|
||
|
||
const response = await fetch(endpoints.uploadImage, {
|
||
method: 'POST',
|
||
headers: await botHeaders({
|
||
'X-CSRF-TOKEN': csrfToken,
|
||
'X-Requested-With': 'XMLHttpRequest',
|
||
}, captchaState),
|
||
body: formData,
|
||
});
|
||
|
||
const payload = await response.json().catch(() => ({}));
|
||
|
||
if (!response.ok) {
|
||
setFieldErrors(payload?.errors && typeof payload.errors === 'object' ? payload.errors : {});
|
||
captureCaptchaRequirement(payload);
|
||
setGeneralError(payload?.errors?.captcha?.[0] || payload?.message || 'Image upload failed');
|
||
return null;
|
||
}
|
||
|
||
clearFeedback();
|
||
if (captchaState.required && captchaState.token) {
|
||
resetCaptchaState();
|
||
}
|
||
|
||
const data = payload;
|
||
return data.medium_url || data.original_url || data.thumbnail_url || null;
|
||
}, [captchaState, captureCaptchaRequirement, clearFeedback, endpoints.uploadImage, csrfToken, resetCaptchaState]);
|
||
|
||
const applyCodeBlockLanguage = useCallback((language: string) => {
|
||
const nextLanguage = (language || 'plaintext').trim() || 'plaintext';
|
||
setCodeBlockLanguage(nextLanguage);
|
||
|
||
const currentEditor = editorRef.current;
|
||
if (!currentEditor || !currentEditor.isActive('codeBlock')) {
|
||
return;
|
||
}
|
||
|
||
currentEditor.chain().focus().updateAttributes('codeBlock', { language: nextLanguage }).run();
|
||
}, []);
|
||
|
||
const toggleCodeBlockWithLanguage = useCallback(() => {
|
||
const currentEditor = editorRef.current;
|
||
if (!currentEditor) return;
|
||
|
||
if (currentEditor.isActive('codeBlock')) {
|
||
currentEditor.chain().focus().toggleCodeBlock().run();
|
||
return;
|
||
}
|
||
|
||
currentEditor.chain().focus().setCodeBlock({ language: codeBlockLanguage }).run();
|
||
}, [codeBlockLanguage]);
|
||
|
||
const closeInsertDialog = useCallback(() => {
|
||
insertSelectionRef.current = null;
|
||
setInsertDialog(INSERT_DIALOG_INITIAL_STATE);
|
||
}, []);
|
||
|
||
const openInsertDialog = useCallback((kind: Exclude<InsertDialogKind, null>) => {
|
||
const currentEditor = editorRef.current;
|
||
if (!currentEditor) {
|
||
return;
|
||
}
|
||
|
||
const { from, to } = currentEditor.state.selection;
|
||
insertSelectionRef.current = { from, to };
|
||
setInsertDialog({
|
||
kind,
|
||
url: '',
|
||
title: kind === 'video' ? 'Embedded video' : '',
|
||
label: 'Download asset',
|
||
error: '',
|
||
});
|
||
}, []);
|
||
|
||
const openLinkDialog = useCallback(() => {
|
||
const currentEditor = editorRef.current;
|
||
if (!currentEditor) {
|
||
return;
|
||
}
|
||
|
||
const { from, to } = currentEditor.state.selection;
|
||
if (from === to) {
|
||
return;
|
||
}
|
||
|
||
insertSelectionRef.current = { from, to };
|
||
setInsertDialog({
|
||
kind: 'link',
|
||
url: currentEditor.getAttributes('link').href || '',
|
||
title: '',
|
||
label: 'Download asset',
|
||
error: '',
|
||
});
|
||
}, []);
|
||
|
||
const removeSelectedLink = useCallback(() => {
|
||
const currentEditor = editorRef.current;
|
||
if (!currentEditor) {
|
||
closeInsertDialog();
|
||
return;
|
||
}
|
||
|
||
const selection = insertSelectionRef.current;
|
||
const chain = currentEditor.chain().focus();
|
||
if (selection) {
|
||
chain.setTextSelection(selection).extendMarkRange('link');
|
||
}
|
||
|
||
chain.unsetLink().run();
|
||
closeInsertDialog();
|
||
}, [closeInsertDialog]);
|
||
|
||
const submitInsertDialog = useCallback((event?: React.FormEvent<HTMLFormElement>) => {
|
||
event?.preventDefault();
|
||
|
||
if (!insertDialog.kind) {
|
||
return;
|
||
}
|
||
|
||
const currentEditor = editorRef.current;
|
||
if (!currentEditor) {
|
||
closeInsertDialog();
|
||
return;
|
||
}
|
||
|
||
if (insertDialog.kind === 'link') {
|
||
const selection = insertSelectionRef.current;
|
||
const chain = currentEditor.chain().focus();
|
||
if (selection) {
|
||
chain.setTextSelection(selection).extendMarkRange('link');
|
||
}
|
||
|
||
const normalizedLink = normalizeHttpUrl(insertDialog.url);
|
||
if (!normalizedLink) {
|
||
setInsertDialog((previous) => ({
|
||
...previous,
|
||
error: 'Enter a valid http or https URL for the selected text.',
|
||
}));
|
||
return;
|
||
}
|
||
|
||
chain.setLink({ href: normalizedLink }).run();
|
||
closeInsertDialog();
|
||
return;
|
||
}
|
||
|
||
let normalizedUrl = normalizeHttpUrl(insertDialog.url);
|
||
if (insertDialog.kind === 'video') {
|
||
normalizedUrl = normalizeVideoEmbedUrl(insertDialog.url);
|
||
}
|
||
|
||
if (!normalizedUrl) {
|
||
setInsertDialog((previous) => ({
|
||
...previous,
|
||
error: insertDialog.kind === 'video'
|
||
? 'Enter a valid YouTube, Vimeo, or direct embed URL.'
|
||
: 'Enter a valid http or https URL.',
|
||
}));
|
||
return;
|
||
}
|
||
|
||
const selection = insertSelectionRef.current;
|
||
const chain = currentEditor.chain().focus();
|
||
if (selection) {
|
||
chain.setTextSelection(selection);
|
||
}
|
||
|
||
if (insertDialog.kind === 'image') {
|
||
chain.setImage({ src: normalizedUrl }).run();
|
||
closeInsertDialog();
|
||
return;
|
||
}
|
||
|
||
if (insertDialog.kind === 'video') {
|
||
chain.insertContent({
|
||
type: 'videoEmbed',
|
||
attrs: {
|
||
src: normalizedUrl,
|
||
title: insertDialog.title.trim() || 'Embedded video',
|
||
},
|
||
}).run();
|
||
closeInsertDialog();
|
||
return;
|
||
}
|
||
|
||
chain.insertContent({
|
||
type: 'downloadAsset',
|
||
attrs: {
|
||
url: normalizedUrl,
|
||
label: insertDialog.label.trim() || 'Download asset',
|
||
},
|
||
}).run();
|
||
closeInsertDialog();
|
||
}, [closeInsertDialog, insertDialog]);
|
||
|
||
const insertActions = useMemo(() => ({
|
||
image: () => {
|
||
openInsertDialog('image');
|
||
},
|
||
uploadImage: () => bodyImageInputRef.current?.click(),
|
||
artwork: () => setArtworkModalOpen(true),
|
||
code: () => {
|
||
toggleCodeBlockWithLanguage();
|
||
},
|
||
quote: () => {
|
||
const currentEditor = editorRef.current;
|
||
if (!currentEditor) return;
|
||
currentEditor.chain().focus().toggleBlockquote().run();
|
||
},
|
||
divider: () => {
|
||
const currentEditor = editorRef.current;
|
||
if (!currentEditor) return;
|
||
currentEditor.chain().focus().setHorizontalRule().run();
|
||
},
|
||
part: () => {
|
||
const currentEditor = editorRef.current;
|
||
if (!currentEditor) return;
|
||
currentEditor.chain().focus().setHorizontalRule().run();
|
||
},
|
||
gallery: () => {
|
||
const currentEditor = editorRef.current;
|
||
if (!currentEditor) return;
|
||
const raw = window.prompt('Gallery image URLs (comma separated)', '');
|
||
const images = (raw || '').split(',').map((value) => value.trim()).filter(Boolean);
|
||
currentEditor.chain().focus().insertContent({ type: 'galleryBlock', attrs: { images } }).run();
|
||
},
|
||
video: () => {
|
||
openInsertDialog('video');
|
||
},
|
||
download: () => {
|
||
openInsertDialog('download');
|
||
},
|
||
}), [openInsertDialog, toggleCodeBlockWithLanguage]);
|
||
|
||
const editor = useEditor({
|
||
extensions: [
|
||
StarterKit.configure({
|
||
codeBlock: false,
|
||
link: false,
|
||
underline: false,
|
||
heading: { levels: [1, 2, 3] },
|
||
}),
|
||
CodeBlockLowlight.configure({
|
||
lowlight,
|
||
}),
|
||
Underline,
|
||
Image,
|
||
Link.configure({
|
||
openOnClick: false,
|
||
HTMLAttributes: {
|
||
class: 'text-sky-300 underline',
|
||
rel: 'noopener noreferrer nofollow',
|
||
target: '_blank',
|
||
},
|
||
}),
|
||
Placeholder.configure({
|
||
placeholder: 'Start writing your story...',
|
||
}),
|
||
ArtworkBlock,
|
||
GalleryBlock,
|
||
VideoEmbedBlock,
|
||
DownloadAssetBlock,
|
||
createSlashCommandExtension(insertActions),
|
||
],
|
||
immediatelyRender: false,
|
||
content: initialStory.content || EMPTY_DOC,
|
||
editorProps: {
|
||
attributes: {
|
||
class: 'tiptap prose prose-xl prose-headings:text-white prose-headings:font-bold prose-headings:tracking-tight prose-p:text-white/90 prose-p:leading-[1.9] prose-li:leading-[1.9] prose-strong:text-white prose-a:text-sky-400 prose-a:underline prose-blockquote:border-l-sky-400/60 prose-blockquote:text-white/65 prose-blockquote:italic prose-code:text-sky-300 prose-pre:bg-black/30 prose-pre:text-sky-100 max-w-none min-h-[32rem] bg-transparent px-0 py-0 text-white/90 focus:outline-none',
|
||
},
|
||
handleDrop: (_view, event) => {
|
||
const file = event.dataTransfer?.files?.[0];
|
||
if (!file || !file.type.startsWith('image/')) return false;
|
||
|
||
void (async () => {
|
||
setSaveStatus('Uploading image...');
|
||
const uploaded = await uploadImageFile(file);
|
||
if (uploaded && editor) {
|
||
editor.chain().focus().setImage({ src: uploaded }).run();
|
||
setSaveStatus('Image uploaded');
|
||
} else {
|
||
setSaveStatus('Image upload failed');
|
||
}
|
||
})();
|
||
|
||
return true;
|
||
},
|
||
handlePaste: (_view, event) => {
|
||
const file = event.clipboardData?.files?.[0];
|
||
if (!file || !file.type.startsWith('image/')) return false;
|
||
|
||
void (async () => {
|
||
setSaveStatus('Uploading image...');
|
||
const uploaded = await uploadImageFile(file);
|
||
if (uploaded && editor) {
|
||
editor.chain().focus().setImage({ src: uploaded }).run();
|
||
setSaveStatus('Image uploaded');
|
||
} else {
|
||
setSaveStatus('Image upload failed');
|
||
}
|
||
})();
|
||
|
||
return true;
|
||
},
|
||
},
|
||
});
|
||
|
||
editorRef.current = editor;
|
||
|
||
useEffect(() => {
|
||
if (!editor) return;
|
||
|
||
const updatePreview = () => {
|
||
const text = editor.getText().replace(/\s+/g, ' ').trim();
|
||
const words = text === '' ? 0 : text.split(' ').length;
|
||
setWordCount(words);
|
||
setReadMinutes(Math.max(1, Math.ceil(words / 200)));
|
||
};
|
||
|
||
updatePreview();
|
||
editor.on('update', updatePreview);
|
||
|
||
return () => {
|
||
editor.off('update', updatePreview);
|
||
};
|
||
}, [editor]);
|
||
|
||
useEffect(() => {
|
||
if (!editor) return;
|
||
|
||
const syncCodeBlockLanguage = () => {
|
||
if (!editor.isActive('codeBlock')) {
|
||
return;
|
||
}
|
||
|
||
const nextLanguage = String(editor.getAttributes('codeBlock').language || '').trim();
|
||
if (nextLanguage !== '') {
|
||
setCodeBlockLanguage(nextLanguage);
|
||
}
|
||
};
|
||
|
||
syncCodeBlockLanguage();
|
||
editor.on('selectionUpdate', syncCodeBlockLanguage);
|
||
editor.on('update', syncCodeBlockLanguage);
|
||
|
||
return () => {
|
||
editor.off('selectionUpdate', syncCodeBlockLanguage);
|
||
editor.off('update', syncCodeBlockLanguage);
|
||
};
|
||
}, [editor]);
|
||
|
||
useEffect(() => {
|
||
if (!artworkModalOpen) return;
|
||
void fetchArtworks(artworkQuery);
|
||
}, [artworkModalOpen, artworkQuery, fetchArtworks]);
|
||
|
||
useEffect(() => {
|
||
if (!editor) return;
|
||
|
||
const updateToolbar = () => {
|
||
const { from, to } = editor.state.selection;
|
||
if (from === to) {
|
||
setInlineToolbar({ visible: false, top: 0, left: 0 });
|
||
return;
|
||
}
|
||
|
||
const start = editor.view.coordsAtPos(from);
|
||
const end = editor.view.coordsAtPos(to);
|
||
setInlineToolbar({
|
||
visible: true,
|
||
top: Math.max(10, start.top + window.scrollY - 48),
|
||
left: Math.max(10, (start.left + end.right) / 2 + window.scrollX - 120),
|
||
});
|
||
};
|
||
|
||
editor.on('selectionUpdate', updateToolbar);
|
||
editor.on('blur', () => setInlineToolbar({ visible: false, top: 0, left: 0 }));
|
||
|
||
return () => {
|
||
editor.off('selectionUpdate', updateToolbar);
|
||
};
|
||
}, [editor]);
|
||
|
||
useEffect(() => {
|
||
if (!editor) return;
|
||
|
||
const hidePlusButton = () => {
|
||
setPlusButtonState({ visible: false, top: 0, left: 0 });
|
||
setPlusMenuOpen(false);
|
||
};
|
||
|
||
const updatePlusButton = () => {
|
||
const { from, to } = editor.state.selection;
|
||
if (from !== to || !editor.isFocused) {
|
||
hidePlusButton();
|
||
return;
|
||
}
|
||
|
||
const container = editorContainerRef.current;
|
||
if (!container) {
|
||
hidePlusButton();
|
||
return;
|
||
}
|
||
|
||
const domAtPos = editor.view.domAtPos(from);
|
||
const anchorNode = domAtPos.node instanceof Element ? domAtPos.node : domAtPos.node.parentElement;
|
||
const blockElement = anchorNode?.closest('p, h1, h2, h3, blockquote, pre, li');
|
||
|
||
if (!blockElement || !container.contains(blockElement)) {
|
||
hidePlusButton();
|
||
return;
|
||
}
|
||
|
||
const blockRect = blockElement.getBoundingClientRect();
|
||
const computedStyle = window.getComputedStyle(blockElement);
|
||
const parsedLineHeight = Number.parseFloat(computedStyle.lineHeight);
|
||
const lineHeight = Number.isFinite(parsedLineHeight) ? parsedLineHeight : 32;
|
||
|
||
setPlusButtonState({
|
||
visible: true,
|
||
top: blockRect.top + Math.max((lineHeight - 32) / 2, 0),
|
||
left: Math.max(16, blockRect.left - 44),
|
||
});
|
||
};
|
||
|
||
editor.on('selectionUpdate', updatePlusButton);
|
||
editor.on('update', updatePlusButton);
|
||
editor.on('focus', updatePlusButton);
|
||
editor.on('blur', hidePlusButton);
|
||
|
||
const frameId = window.requestAnimationFrame(updatePlusButton);
|
||
window.addEventListener('scroll', updatePlusButton, true);
|
||
window.addEventListener('resize', updatePlusButton);
|
||
|
||
return () => {
|
||
window.cancelAnimationFrame(frameId);
|
||
window.removeEventListener('scroll', updatePlusButton, true);
|
||
window.removeEventListener('resize', updatePlusButton);
|
||
editor.off('selectionUpdate', updatePlusButton);
|
||
editor.off('update', updatePlusButton);
|
||
editor.off('focus', updatePlusButton);
|
||
editor.off('blur', hidePlusButton);
|
||
};
|
||
}, [editor]);
|
||
|
||
const payload = useCallback(() => ({
|
||
story_id: storyId,
|
||
title,
|
||
excerpt,
|
||
cover_image: coverImage,
|
||
story_type: storyType,
|
||
tags_csv: tagsCsv,
|
||
tags: tagsCsv.split(',').map((tag) => tag.trim()).filter(Boolean),
|
||
meta_title: metaTitle || title,
|
||
meta_description: metaDescription || excerpt,
|
||
og_image: ogImage || coverImage,
|
||
status,
|
||
scheduled_for: scheduledFor || null,
|
||
content: editor?.getJSON() || EMPTY_DOC,
|
||
}), [storyId, title, excerpt, coverImage, storyType, tagsCsv, metaTitle, metaDescription, ogImage, status, scheduledFor, editor]);
|
||
|
||
useEffect(() => {
|
||
if (!editor) return;
|
||
|
||
const timer = window.setInterval(async () => {
|
||
if (isSubmitting) {
|
||
return;
|
||
}
|
||
|
||
const body = payload();
|
||
const snapshot = JSON.stringify(body);
|
||
if (snapshot === lastSavedRef.current) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
clearFeedback();
|
||
setSaveStatus('Saving...');
|
||
const data = await requestJson<{ story_id?: number; message?: string; edit_url?: string }>(
|
||
endpoints.autosave,
|
||
'POST',
|
||
captchaState.required && captchaState.inputName ? {
|
||
...body,
|
||
[captchaState.inputName]: captchaState.token || '',
|
||
} : body,
|
||
csrfToken,
|
||
captchaState,
|
||
);
|
||
if (data.story_id && !storyId) {
|
||
setStoryId(data.story_id);
|
||
}
|
||
if (data.edit_url && window.location.pathname.endsWith('/create')) {
|
||
window.history.replaceState({}, '', data.edit_url);
|
||
}
|
||
lastSavedRef.current = snapshot;
|
||
setSaveStatus(data.message || 'Saved just now');
|
||
if (captchaState.required && captchaState.token) {
|
||
resetCaptchaState();
|
||
}
|
||
emitSaveEvent('autosave', data.story_id || storyId);
|
||
} catch (error) {
|
||
applyFailure(error, 'Autosave failed');
|
||
}
|
||
}, 10000);
|
||
|
||
return () => window.clearInterval(timer);
|
||
}, [applyFailure, captchaState, clearFeedback, csrfToken, editor, emitSaveEvent, endpoints.autosave, isSubmitting, payload, resetCaptchaState, storyId]);
|
||
|
||
const persistStory = async (submitAction: 'save_draft' | 'submit_review' | 'publish_now' | 'schedule_publish') => {
|
||
const body = {
|
||
...payload(),
|
||
submit_action: submitAction,
|
||
status: submitAction === 'submit_review' ? 'pending_review' : submitAction === 'publish_now' ? 'published' : submitAction === 'schedule_publish' ? 'scheduled' : status,
|
||
scheduled_for: submitAction === 'schedule_publish' ? scheduledFor : null,
|
||
};
|
||
|
||
try {
|
||
clearFeedback();
|
||
setIsSubmitting(true);
|
||
setSaveStatus('Saving...');
|
||
const endpoint = storyId ? endpoints.update : endpoints.create;
|
||
const method = storyId ? 'PUT' : 'POST';
|
||
const data = await requestJson<{ story_id: number; message?: string; status?: string; edit_url?: string; public_url?: string }>(endpoint, method, captchaState.required && captchaState.inputName ? {
|
||
...body,
|
||
[captchaState.inputName]: captchaState.token || '',
|
||
} : body, csrfToken, captchaState);
|
||
if (data.story_id) {
|
||
setStoryId(data.story_id);
|
||
}
|
||
if (data.edit_url && window.location.pathname.endsWith('/create')) {
|
||
window.history.replaceState({}, '', data.edit_url);
|
||
}
|
||
lastSavedRef.current = JSON.stringify(payload());
|
||
setSaveStatus(data.message || 'Saved just now');
|
||
if (captchaState.required && captchaState.token) {
|
||
resetCaptchaState();
|
||
}
|
||
emitSaveEvent('manual', data.story_id || storyId);
|
||
if (submitAction === 'publish_now' && data.public_url) {
|
||
window.location.assign(data.public_url);
|
||
return;
|
||
}
|
||
} catch (error) {
|
||
applyFailure(error, submitAction === 'publish_now' ? 'Publish failed' : 'Save failed');
|
||
} finally {
|
||
setIsSubmitting(false);
|
||
}
|
||
};
|
||
|
||
const handleBodyImagePicked = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||
const file = event.target.files?.[0];
|
||
event.target.value = '';
|
||
if (!file) return;
|
||
|
||
setSaveStatus('Uploading image...');
|
||
const uploaded = await uploadImageFile(file);
|
||
if (!uploaded || !editor) {
|
||
setSaveStatus('Image upload failed');
|
||
return;
|
||
}
|
||
|
||
editor.chain().focus().setImage({ src: uploaded }).run();
|
||
setSaveStatus('Image uploaded');
|
||
};
|
||
|
||
const handleCoverImagePicked = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||
const file = event.target.files?.[0];
|
||
event.target.value = '';
|
||
if (!file) return;
|
||
|
||
setSaveStatus('Uploading cover...');
|
||
const uploaded = await uploadImageFile(file);
|
||
if (!uploaded) {
|
||
setSaveStatus('Cover upload failed');
|
||
return;
|
||
}
|
||
|
||
setCoverImage(uploaded);
|
||
setSaveStatus('Cover uploaded');
|
||
};
|
||
|
||
const readinessChecks = useMemo(() => ([
|
||
{ label: 'Title', ok: title.trim().length > 0, hint: 'Give the story a clear headline.' },
|
||
{ label: 'Body', ok: wordCount >= 50, hint: 'Aim for at least 50 words before publishing.' },
|
||
{ label: 'Story type', ok: storyType.trim().length > 0, hint: 'Choose the format that fits the post.' },
|
||
]), [storyType, title, wordCount]);
|
||
|
||
const titleError = fieldErrors?.title?.[0] || '';
|
||
const contentError = fieldErrors?.content?.[0] || '';
|
||
const excerptError = fieldErrors?.excerpt?.[0] || '';
|
||
const tagsError = fieldErrors?.tags_csv?.[0] || '';
|
||
const completedChecks = readinessChecks.filter((check) => check.ok).length;
|
||
const progressPercent = Math.max(20, Math.round((completedChecks / Math.max(readinessChecks.length, 1)) * 100));
|
||
const topActions = [
|
||
{
|
||
key: 'cover',
|
||
label: coverImage ? 'Change cover' : 'Add cover',
|
||
detail: coverImage ? 'Refresh the hero image.' : 'Give the story a visual anchor.',
|
||
onClick: () => coverImageInputRef.current?.click(),
|
||
tone: 'sky',
|
||
},
|
||
{
|
||
key: 'part',
|
||
label: 'New part',
|
||
detail: 'Drop in the three-dot chapter separator.',
|
||
onClick: () => insertActions.part(),
|
||
tone: 'violet',
|
||
},
|
||
{
|
||
key: 'settings',
|
||
label: 'Story settings',
|
||
detail: 'Manage SEO, workflow, and metadata.',
|
||
onClick: () => setSettingsOpen(true),
|
||
tone: 'slate',
|
||
},
|
||
];
|
||
const desktopInsertActions = [
|
||
{ key: 'uploadImage', label: 'Upload photo', detail: 'Drop a full-width image into the body.' },
|
||
{ key: 'artwork', label: 'Embed artwork', detail: 'Showcase one of your published pieces.' },
|
||
{ key: 'video', label: 'Embed video', detail: 'Paste YouTube or Vimeo and let Nova normalize it.' },
|
||
{ key: 'download', label: 'Download link', detail: 'Add a clear file CTA for readers.' },
|
||
{ key: 'part', label: 'Add a new part', detail: 'Break long stories into readable chapters.' },
|
||
] as Array<{ key: keyof typeof insertActions; label: string; detail: string }>;
|
||
const quickLinks = storyId ? [
|
||
{ key: 'preview', label: 'Preview story', href: `${endpoints.previewBase}/${storyId}/preview` },
|
||
{ key: 'analytics', label: 'Story analytics', href: `${endpoints.analyticsBase}/${storyId}/analytics` },
|
||
] : [];
|
||
const storySuggestions = [
|
||
!coverImage ? {
|
||
key: 'cover',
|
||
label: 'Add a cover image',
|
||
detail: 'A strong visual anchor makes the draft feel finished faster.',
|
||
onClick: () => coverImageInputRef.current?.click(),
|
||
tone: 'sky',
|
||
} : null,
|
||
excerpt.trim().length < 40 ? {
|
||
key: 'excerpt',
|
||
label: 'Sharpen the subtitle',
|
||
detail: 'Give readers one sentence that sets the tone before the first paragraph.',
|
||
onClick: () => excerptInputRef.current?.focus(),
|
||
tone: 'violet',
|
||
} : null,
|
||
wordCount >= 220 ? {
|
||
key: 'part',
|
||
label: 'Split the next chapter',
|
||
detail: 'This draft is long enough for a visual chapter break.',
|
||
onClick: () => insertActions.part(),
|
||
tone: 'emerald',
|
||
} : null,
|
||
tagsCsv.trim().length === '' ? {
|
||
key: 'tags',
|
||
label: 'Add discovery tags',
|
||
detail: 'Open settings and add a few tags so the story is easier to surface later.',
|
||
onClick: () => setSettingsOpen(true),
|
||
tone: 'amber',
|
||
} : null,
|
||
].filter(Boolean) as Array<{ key: string; label: string; detail: string; onClick: () => void; tone: string }>;
|
||
|
||
const topActionToneClasses: Record<string, string> = {
|
||
sky: 'border-sky-300/18 bg-sky-400/10 text-sky-100 hover:border-sky-300/35 hover:bg-sky-400/15',
|
||
violet: 'border-violet-300/18 bg-violet-400/10 text-violet-100 hover:border-violet-300/35 hover:bg-violet-400/15',
|
||
slate: 'border-white/10 bg-white/[0.045] text-white/78 hover:border-white/20 hover:bg-white/[0.08]',
|
||
};
|
||
const suggestionToneClasses: Record<string, string> = {
|
||
sky: 'border-sky-300/18 bg-sky-400/10 text-sky-100',
|
||
violet: 'border-violet-300/18 bg-violet-400/10 text-violet-100',
|
||
emerald: 'border-emerald-300/18 bg-emerald-400/10 text-emerald-100',
|
||
amber: 'border-amber-300/18 bg-amber-400/10 text-amber-100',
|
||
};
|
||
|
||
const insertArtwork = (item: Artwork) => {
|
||
if (!editor) return;
|
||
editor.chain().focus().insertContent({
|
||
type: 'artworkEmbed',
|
||
attrs: {
|
||
artworkId: item.id,
|
||
title: item.title,
|
||
url: item.url,
|
||
thumb: item.thumbs?.md || item.thumbs?.sm || item.thumb || '',
|
||
},
|
||
}).run();
|
||
setArtworkModalOpen(false);
|
||
};
|
||
|
||
return (
|
||
<div className={`min-h-screen px-4 py-4 pb-24 md:px-8 ${focusMode ? 'bg-[linear-gradient(180deg,rgba(6,10,16,0.99),rgba(4,7,12,1))]' : 'bg-[radial-gradient(circle_at_top,_rgba(56,189,248,0.09),_transparent_30%),radial-gradient(circle_at_20%_20%,_rgba(14,165,233,0.07),_transparent_24%),linear-gradient(180deg,rgba(7,11,18,0.98),rgba(4,7,12,1))]'}`}>
|
||
<div className={`mx-auto ${focusMode ? 'max-w-[1180px]' : 'max-w-[1400px]'}`}>
|
||
{/* ── Nova top bar ─────────────────────────────────────────────────── */}
|
||
<div className="sticky top-0 z-30 mb-6 flex h-14 items-center justify-between overflow-hidden rounded-2xl border border-white/10 bg-[linear-gradient(135deg,rgba(12,18,28,0.97),rgba(8,12,20,0.97))] px-5 shadow-[0_8px_32px_rgba(3,7,18,0.32)] backdrop-blur-xl">
|
||
<div className="flex items-center gap-4">
|
||
<a href="/studio/stories" className="flex items-center gap-1.5 text-sm text-white/50 transition-colors hover:text-white/90">
|
||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M10 19l-7-7m0 0l7-7m-7 7h18" /></svg>
|
||
Stories
|
||
</a>
|
||
<span className="h-4 w-px bg-white/10" />
|
||
<span className="hidden text-sm text-white/65 sm:inline">{saveStatus}</span>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<span className="hidden text-xs text-white/55 lg:inline">{wordCount > 0 ? `${wordCount.toLocaleString()} words · ${readMinutes} min` : ''}</span>
|
||
<button
|
||
type="button"
|
||
onClick={() => setFocusMode((current) => !current)}
|
||
className={`rounded-full border px-3 py-1.5 text-sm transition ${focusMode ? 'border-sky-400/30 bg-sky-400/10 text-sky-100 hover:bg-sky-400/15' : 'border-white/10 bg-white/[0.04] text-white/70 hover:bg-white/[0.08] hover:text-white'}`}
|
||
>
|
||
{focusMode ? 'Exit focus' : 'Focus mode'}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => setSettingsOpen(true)}
|
||
title="Story settings"
|
||
className="rounded-full p-2 text-white/50 transition-colors hover:bg-white/[0.07] hover:text-white"
|
||
>
|
||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.75}><path strokeLinecap="round" strokeLinejoin="round" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" /><path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /></svg>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => persistStory('save_draft')}
|
||
disabled={isSubmitting}
|
||
className="rounded-full border border-white/10 bg-white/[0.05] px-4 py-1.5 text-sm text-white/80 transition hover:bg-white/[0.10] disabled:opacity-50"
|
||
>
|
||
Save
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => persistStory('publish_now')}
|
||
disabled={isSubmitting}
|
||
className="rounded-full bg-sky-500 px-4 py-1.5 text-sm font-medium text-white shadow-[0_2px_12px_rgba(14,165,233,0.45)] transition hover:bg-sky-400 disabled:opacity-50"
|
||
>
|
||
Publish
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className={`grid gap-6 ${focusMode ? '' : 'xl:grid-cols-[minmax(0,1fr)_300px] xl:items-start'}`}>
|
||
<main>
|
||
{!focusMode && (
|
||
<div className="mb-6 overflow-hidden rounded-[2rem] border border-white/10 bg-[linear-gradient(135deg,rgba(15,23,36,0.9),rgba(9,14,24,0.96))] shadow-[0_24px_80px_rgba(2,6,23,0.28)] backdrop-blur-xl">
|
||
<div className="flex flex-col gap-5 px-6 py-6 md:px-8 lg:flex-row lg:items-end lg:justify-between">
|
||
<div className="max-w-3xl">
|
||
<p className="text-[11px] font-semibold uppercase tracking-[0.28em] text-sky-200/55">Story Studio</p>
|
||
<h1 className="mt-3 text-3xl font-semibold tracking-tight text-white md:text-[2.35rem]">Shape the narrative before readers ever see the first line.</h1>
|
||
<p className="mt-3 max-w-2xl text-sm leading-7 text-slate-300/82 md:text-[15px]">Use the writing canvas for the draft itself, keep your metadata close, and drop in chapter breaks or rich media without leaving the flow.</p>
|
||
</div>
|
||
<div className="grid gap-3 sm:grid-cols-3 lg:min-w-[420px] lg:max-w-[460px] lg:flex-1">
|
||
{topActions.map((action) => (
|
||
<button
|
||
key={action.key}
|
||
type="button"
|
||
onClick={action.onClick}
|
||
className={`rounded-[1.35rem] border px-4 py-4 text-left transition ${topActionToneClasses[action.tone]}`}
|
||
>
|
||
<div className="text-sm font-semibold">{action.label}</div>
|
||
<div className="mt-1.5 text-xs leading-5 text-inherit/70">{action.detail}</div>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div className="nb-scrollbar-none mb-5 overflow-x-auto overflow-y-hidden rounded-[1.6rem] border border-white/10 bg-[linear-gradient(180deg,rgba(11,17,27,0.94),rgba(7,10,17,0.96))] px-4 py-3 shadow-[0_18px_50px_rgba(2,6,23,0.22)] backdrop-blur-xl sm:px-5">
|
||
<div className="flex min-w-max items-center gap-2">
|
||
{desktopInsertActions.map((action) => (
|
||
<button
|
||
key={`top-toolbar-${action.key}`}
|
||
type="button"
|
||
onClick={() => insertActions[action.key]()}
|
||
className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-3 py-2 text-sm text-white/78 transition hover:border-sky-400/28 hover:bg-sky-400/[0.08] hover:text-white"
|
||
>
|
||
<span className="inline-flex h-6 w-6 items-center justify-center rounded-full bg-white/[0.05] text-[11px] text-sky-200">+</span>
|
||
{action.label}
|
||
</button>
|
||
))}
|
||
<span className="mx-1 hidden h-5 w-px bg-white/10 md:block" />
|
||
<button
|
||
type="button"
|
||
onClick={() => setSettingsOpen(true)}
|
||
className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-3 py-2 text-sm text-white/78 transition hover:border-white/20 hover:bg-white/[0.08] hover:text-white"
|
||
>
|
||
Story settings
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => setFocusMode((current) => !current)}
|
||
className={`inline-flex items-center gap-2 rounded-full border px-3 py-2 text-sm transition ${focusMode ? 'border-sky-400/28 bg-sky-400/[0.08] text-sky-100 hover:bg-sky-400/[0.14]' : 'border-white/10 bg-white/[0.04] text-white/78 hover:border-white/20 hover:bg-white/[0.08] hover:text-white'}`}
|
||
>
|
||
{focusMode ? 'Exit focus' : 'Focus mode'}
|
||
</button>
|
||
{quickLinks.map((link) => (
|
||
<a
|
||
key={`top-toolbar-${link.key}`}
|
||
href={link.href}
|
||
className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-3 py-2 text-sm text-white/78 transition hover:border-white/20 hover:bg-white/[0.08] hover:text-white"
|
||
>
|
||
{link.label}
|
||
</a>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* ── Writing canvas ───────────────────────────────────────────────── */}
|
||
<div className={`mx-auto overflow-hidden rounded-[2rem] border border-white/10 bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.07),_transparent_32%),linear-gradient(180deg,rgba(14,18,27,0.99),rgba(9,12,19,0.98))] shadow-[0_24px_80px_rgba(4,8,20,0.36)] ${focusMode ? 'max-w-[920px]' : 'max-w-[780px]'}`}>
|
||
{coverImage ? (
|
||
<div className="group relative overflow-hidden rounded-t-2xl">
|
||
<img src={coverImage} alt="Story cover" className="h-64 w-full object-cover md:h-80" />
|
||
<div className="absolute inset-0 flex items-center justify-center gap-3 bg-black/50 opacity-0 transition-opacity group-hover:opacity-100">
|
||
<button
|
||
type="button"
|
||
onClick={() => coverImageInputRef.current?.click()}
|
||
className="flex items-center gap-1.5 rounded-lg bg-white/15 px-4 py-2 text-sm font-medium text-white backdrop-blur-sm transition hover:bg-white/25"
|
||
>
|
||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg>
|
||
Change
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => setCoverImage('')}
|
||
className="flex items-center gap-1.5 rounded-lg bg-rose-500/70 px-4 py-2 text-sm font-medium text-white backdrop-blur-sm transition hover:bg-rose-500/90"
|
||
>
|
||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /></svg>
|
||
Remove
|
||
</button>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
<div className="px-6 pb-24 pt-10 md:px-14 md:pt-14">
|
||
{/* Error / captcha banner */}
|
||
{(generalError || captchaState.required) && (
|
||
<div className="mb-8 rounded-xl border border-amber-400/20 bg-amber-500/10 p-4">
|
||
<p className="text-sm text-amber-200">{generalError || captchaState.message || 'Complete the captcha to continue.'}</p>
|
||
{captchaState.required && captchaState.siteKey ? (
|
||
<div className="mt-3">
|
||
<TurnstileField
|
||
key={`story-editor-captcha-${captchaState.nonce}`}
|
||
provider={captchaState.provider}
|
||
siteKey={captchaState.siteKey}
|
||
scriptUrl={captchaState.scriptUrl}
|
||
onToken={(token) => setCaptchaState((prev) => ({ ...prev, token }))}
|
||
className="min-h-16"
|
||
/>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
)}
|
||
|
||
{/* Cover image upload shortcut */}
|
||
{!coverImage && (
|
||
<button
|
||
type="button"
|
||
onClick={() => coverImageInputRef.current?.click()}
|
||
className="mb-6 flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-white/55 transition hover:bg-white/[0.05] hover:text-white/80"
|
||
>
|
||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg>
|
||
Add a cover image
|
||
</button>
|
||
)}
|
||
|
||
{/* Title */}
|
||
<div className="mb-3">
|
||
<textarea
|
||
ref={titleInputRef}
|
||
value={title}
|
||
onChange={(event) => {
|
||
setTitle(event.target.value);
|
||
event.target.style.height = 'auto';
|
||
event.target.style.height = `${event.target.scrollHeight}px`;
|
||
}}
|
||
onFocus={(event) => {
|
||
event.target.style.height = 'auto';
|
||
event.target.style.height = `${event.target.scrollHeight}px`;
|
||
}}
|
||
placeholder="Title"
|
||
rows={1}
|
||
className="w-full resize-none overflow-hidden border-0 bg-transparent p-0 text-[2.4rem] font-bold leading-tight tracking-tight text-white placeholder:text-white/35 focus:outline-none md:text-[2.8rem]"
|
||
/>
|
||
{titleError ? <p className="mt-1 text-sm text-rose-300">{titleError}</p> : null}
|
||
</div>
|
||
|
||
{/* Excerpt / subtitle */}
|
||
<div className="mb-10 border-b border-white/[0.07] pb-8">
|
||
<textarea
|
||
ref={excerptInputRef}
|
||
value={excerpt}
|
||
onChange={(event) => {
|
||
setExcerpt(event.target.value);
|
||
event.target.style.height = 'auto';
|
||
event.target.style.height = `${event.target.scrollHeight}px`;
|
||
}}
|
||
onFocus={(event) => {
|
||
event.target.style.height = 'auto';
|
||
event.target.style.height = `${event.target.scrollHeight}px`;
|
||
}}
|
||
placeholder="Write a short subtitle that sets the scene…"
|
||
rows={1}
|
||
className="w-full resize-none overflow-hidden border-0 bg-transparent p-0 text-xl leading-relaxed text-white/75 placeholder:text-white/35 focus:outline-none"
|
||
/>
|
||
{excerptError ? <p className="mt-1 text-sm text-rose-300">{excerptError}</p> : null}
|
||
</div>
|
||
|
||
{/* Body editor — the ref is on the wrapper so we can measure its left edge */}
|
||
<div className="relative" ref={editorContainerRef}>
|
||
<EditorContent editor={editor} />
|
||
{contentError ? <p className="mt-4 text-sm text-rose-300">{contentError}</p> : null}
|
||
</div>
|
||
|
||
{/* Footer actions */}
|
||
<div className="mt-16 flex flex-wrap items-center gap-3 border-t border-white/[0.07] pt-8 text-sm">
|
||
{storyId && (
|
||
<a href={`${endpoints.previewBase}/${storyId}/preview`} className="rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-white/60 transition hover:bg-white/[0.08] hover:text-white/90">Preview</a>
|
||
)}
|
||
{storyId && (
|
||
<a href={`${endpoints.analyticsBase}/${storyId}/analytics`} className="rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-white/60 transition hover:bg-white/[0.08] hover:text-white/90">Analytics</a>
|
||
)}
|
||
<button
|
||
type="button"
|
||
onClick={() => persistStory('submit_review')}
|
||
disabled={isSubmitting}
|
||
className="rounded-full border border-amber-400/30 bg-amber-400/10 px-4 py-2 text-amber-200 transition hover:bg-amber-400/20 disabled:opacity-50"
|
||
>
|
||
Submit for review
|
||
</button>
|
||
{mode === 'edit' && storyId && (
|
||
<form
|
||
method="POST"
|
||
action={`/creator/stories/${storyId}`}
|
||
onSubmit={(e) => { if (!window.confirm('Delete this story permanently?')) e.preventDefault(); }}
|
||
className="ml-auto"
|
||
>
|
||
<input type="hidden" name="_token" value={csrfToken} />
|
||
<input type="hidden" name="_method" value="DELETE" />
|
||
<button type="submit" className="rounded-full border border-rose-500/30 bg-rose-500/10 px-4 py-2 text-rose-300 transition hover:bg-rose-500/20">Delete story</button>
|
||
</form>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</main>
|
||
{!focusMode ? (
|
||
<aside className="hidden xl:block">
|
||
<div className="sticky top-[5.5rem] space-y-4">
|
||
<div className="overflow-hidden rounded-[1.75rem] border border-white/10 bg-[linear-gradient(180deg,rgba(16,22,34,0.96),rgba(8,12,20,0.96))] shadow-[0_20px_60px_rgba(2,6,23,0.32)] backdrop-blur-xl">
|
||
<div className="border-b border-white/10 px-5 py-4">
|
||
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-white/40">Story pulse</p>
|
||
<div className="mt-3 flex items-end justify-between gap-3">
|
||
<div>
|
||
<p className="text-2xl font-semibold text-white">{completedChecks}/{readinessChecks.length}</p>
|
||
<p className="mt-1 text-sm text-slate-300/72">Publishing readiness</p>
|
||
</div>
|
||
<div className="rounded-2xl border border-white/10 bg-white/[0.04] px-3 py-2 text-right">
|
||
<div className="text-[11px] uppercase tracking-[0.18em] text-white/35">Rhythm</div>
|
||
<div className="mt-1 text-sm font-medium text-white/85">{wordCount > 0 ? `${wordCount.toLocaleString()} words` : 'Start writing'}</div>
|
||
<div className="mt-1 text-xs text-white/45">{readMinutes} min read</div>
|
||
</div>
|
||
</div>
|
||
<div className="mt-4 h-2 overflow-hidden rounded-full bg-white/[0.06]">
|
||
<div className="h-full rounded-full bg-[linear-gradient(90deg,rgba(56,189,248,0.9),rgba(59,130,246,0.92))]" style={{ width: `${progressPercent}%` }} />
|
||
</div>
|
||
</div>
|
||
<div className="space-y-2 px-5 py-4">
|
||
{readinessChecks.map((check) => (
|
||
<div key={check.label} className={`rounded-2xl border px-4 py-3 ${check.ok ? 'border-emerald-400/18 bg-emerald-500/10' : 'border-amber-400/18 bg-amber-500/10'}`}>
|
||
<div className="flex items-start gap-3">
|
||
<span className={`mt-0.5 inline-flex h-5 w-5 items-center justify-center rounded-full text-[11px] font-bold ${check.ok ? 'bg-emerald-400/20 text-emerald-200' : 'bg-amber-400/20 text-amber-200'}`}>{check.ok ? '✓' : '!'}</span>
|
||
<div>
|
||
<p className="text-sm font-medium text-white/88">{check.label}</p>
|
||
<p className="mt-1 text-xs leading-5 text-white/48">{check.hint}</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{storySuggestions.length > 0 ? (
|
||
<div className="overflow-hidden rounded-[1.75rem] border border-white/10 bg-[linear-gradient(180deg,rgba(13,19,29,0.97),rgba(8,12,20,0.97))] shadow-[0_20px_60px_rgba(2,6,23,0.32)] backdrop-blur-xl">
|
||
<div className="border-b border-white/10 px-5 py-4">
|
||
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-white/40">Suggestions</p>
|
||
<p className="mt-2 text-sm leading-6 text-slate-300/78">A few next moves based on the draft you have right now.</p>
|
||
</div>
|
||
<div className="space-y-2 px-5 py-4">
|
||
{storySuggestions.map((suggestion) => (
|
||
<button
|
||
key={suggestion.key}
|
||
type="button"
|
||
onClick={suggestion.onClick}
|
||
className={`w-full rounded-2xl border px-4 py-3 text-left transition hover:translate-x-0.5 ${suggestionToneClasses[suggestion.tone]}`}
|
||
>
|
||
<div className="text-sm font-semibold">{suggestion.label}</div>
|
||
<div className="mt-1 text-xs leading-5 text-inherit/70">{suggestion.detail}</div>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
<div className="overflow-hidden rounded-[1.75rem] border border-white/10 bg-[linear-gradient(180deg,rgba(13,19,29,0.97),rgba(8,12,20,0.97))] shadow-[0_20px_60px_rgba(2,6,23,0.32)] backdrop-blur-xl">
|
||
<div className="border-b border-white/10 px-5 py-4">
|
||
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-white/40">Desktop shortcuts</p>
|
||
<p className="mt-2 text-sm leading-6 text-slate-300/78">Keep the heavy-lift actions nearby while the canvas stays clean.</p>
|
||
</div>
|
||
<div className="space-y-2 px-5 py-4">
|
||
{desktopInsertActions.map((action) => (
|
||
<button
|
||
key={action.key}
|
||
type="button"
|
||
onClick={() => insertActions[action.key]()}
|
||
className="w-full rounded-2xl border border-white/10 bg-white/[0.035] px-4 py-3 text-left transition hover:border-sky-400/30 hover:bg-sky-400/[0.08]"
|
||
>
|
||
<div className="text-sm font-semibold text-white/88">{action.label}</div>
|
||
<div className="mt-1 text-xs leading-5 text-white/48">{action.detail}</div>
|
||
</button>
|
||
))}
|
||
</div>
|
||
{quickLinks.length > 0 ? (
|
||
<div className="border-t border-white/10 px-5 py-4">
|
||
<div className="space-y-2">
|
||
{quickLinks.map((link) => (
|
||
<a
|
||
key={link.key}
|
||
href={link.href}
|
||
className="block rounded-2xl border border-white/10 bg-white/[0.035] px-4 py-3 text-sm font-medium text-white/80 transition hover:border-white/20 hover:bg-white/[0.08] hover:text-white"
|
||
>
|
||
{link.label}
|
||
</a>
|
||
))}
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
</aside>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
|
||
{/* ── Floating + block insertion button (fixed, always visible when on empty line) ── */}
|
||
{plusButtonState.visible && (
|
||
<div className="fixed z-40" style={{ top: `${plusButtonState.top}px`, left: `${plusButtonState.left}px` }}>
|
||
<button
|
||
type="button"
|
||
onMouseDown={(e) => { e.preventDefault(); setPlusMenuOpen((v) => !v); }}
|
||
className={`flex h-8 w-8 items-center justify-center rounded-full border transition ${
|
||
plusMenuOpen
|
||
? 'border-sky-400/60 bg-sky-500/20 text-sky-300 shadow-[0_0_12px_rgba(14,165,233,0.35)]'
|
||
: 'border-white/20 bg-slate-900/90 text-white/60 shadow-[0_4px_16px_rgba(3,7,18,0.4)] hover:border-sky-400/50 hover:text-sky-300'
|
||
}`}
|
||
title="Add a block (or type / for commands)"
|
||
>
|
||
<svg
|
||
className={`h-4 w-4 transition-transform duration-200 ${plusMenuOpen ? 'rotate-45' : ''}`}
|
||
fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}
|
||
>
|
||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
|
||
</svg>
|
||
</button>
|
||
|
||
{/* Insert block dropdown */}
|
||
{plusMenuOpen && (
|
||
<div className="absolute left-10 top-0 w-52 overflow-hidden rounded-2xl border border-white/10 bg-[linear-gradient(180deg,rgba(18,24,36,0.99),rgba(10,14,22,0.99))] py-1 shadow-[0_16px_48px_rgba(3,7,18,0.5)] backdrop-blur-xl">
|
||
{([
|
||
{ label: 'Upload photo', icon: '🖼', key: 'uploadImage' },
|
||
{ label: 'Image URL', icon: '🔗', key: 'image' },
|
||
{ label: 'Artwork embed', icon: '🎨', key: 'artwork' },
|
||
{ label: 'Video (YouTube…)', icon: '▶', key: 'video' },
|
||
{ label: 'Gallery', icon: '⊞', key: 'gallery' },
|
||
{ label: 'Blockquote', icon: '❝', key: 'quote' },
|
||
{ label: 'Code block', icon: '⌨', key: 'code' },
|
||
{ label: 'Download link', icon: '↓', key: 'download' },
|
||
{ label: 'Add a new part', icon: '⋯', key: 'part' },
|
||
{ label: 'Divider', icon: '—', key: 'divider' },
|
||
] as Array<{ label: string; icon: string; key: keyof typeof insertActions }>).map((item) => (
|
||
<button
|
||
key={item.key}
|
||
type="button"
|
||
onMouseDown={(e) => {
|
||
e.preventDefault();
|
||
setPlusMenuOpen(false);
|
||
insertActions[item.key]();
|
||
}}
|
||
className="flex w-full items-center gap-3 px-4 py-2.5 text-left text-sm text-white/75 transition-colors hover:bg-white/[0.07] hover:text-white"
|
||
>
|
||
<span className="w-5 text-center text-base leading-none opacity-70">{item.icon}</span>
|
||
{item.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* ── Floating inline formatting toolbar ───────────────────────────── */}
|
||
{editor && inlineToolbar.visible && (
|
||
<div
|
||
className="fixed z-50 flex items-center overflow-hidden rounded-2xl border border-white/10 bg-[linear-gradient(135deg,rgba(12,18,28,0.98),rgba(8,12,20,0.98))] p-1.5 shadow-[0_8px_32px_rgba(3,7,18,0.5)] backdrop-blur-xl"
|
||
style={{ top: `${inlineToolbar.top}px`, left: `${inlineToolbar.left}px` }}
|
||
>
|
||
{([
|
||
[
|
||
{ label: 'B', title: 'Bold', action: () => editor.chain().focus().toggleBold().run(), active: editor.isActive('bold'), extra: 'font-bold' },
|
||
{ label: 'I', title: 'Italic', action: () => editor.chain().focus().toggleItalic().run(), active: editor.isActive('italic'), extra: 'italic' },
|
||
{ label: 'U', title: 'Underline', action: () => editor.chain().focus().toggleUnderline().run(), active: editor.isActive('underline'), extra: 'underline' },
|
||
],
|
||
[
|
||
{ label: 'H2', title: 'Heading 2', action: () => editor.chain().focus().toggleHeading({ level: 2 }).run(), active: editor.isActive('heading', { level: 2 }), extra: 'font-semibold text-xs' },
|
||
{ label: 'H3', title: 'Heading 3', action: () => editor.chain().focus().toggleHeading({ level: 3 }).run(), active: editor.isActive('heading', { level: 3 }), extra: 'font-semibold text-xs' },
|
||
{ label: '❝', title: 'Blockquote', action: () => editor.chain().focus().toggleBlockquote().run(), active: editor.isActive('blockquote'), extra: 'text-base font-serif' },
|
||
],
|
||
[
|
||
{ label: '⛓', title: 'Link', action: openLinkDialog, active: editor.isActive('link'), extra: '' },
|
||
{ label: '</>', title: 'Inline code', action: () => editor.chain().focus().toggleCode().run(), active: editor.isActive('code'), extra: 'font-mono text-[10px]' },
|
||
],
|
||
] as Array<Array<{ label: string; title: string; action: () => void; active: boolean; extra: string }>>).map((group, groupIndex) => (
|
||
<React.Fragment key={`inline-toolbar-group-${groupIndex}`}>
|
||
{groupIndex > 0 ? <span className="mx-1 h-6 w-px bg-white/10" aria-hidden="true" /> : null}
|
||
<div className="flex items-center gap-0.5 px-0.5">
|
||
{group.map((item) => (
|
||
<button
|
||
key={item.title}
|
||
type="button"
|
||
title={item.title}
|
||
onMouseDown={(e) => e.preventDefault()}
|
||
onClick={item.action}
|
||
className={`flex h-8 min-w-[2rem] items-center justify-center rounded-xl px-1.5 text-sm transition ${item.extra} ${item.active ? 'bg-sky-500/25 text-sky-200' : 'text-white/70 hover:bg-white/[0.07] hover:text-white'}`}
|
||
>
|
||
{item.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</React.Fragment>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* ── Settings slide-over panel ─────────────────────────────────────── */}
|
||
{settingsOpen && (
|
||
<>
|
||
<button
|
||
type="button"
|
||
className="fixed inset-0 z-40 bg-black/50 backdrop-blur-sm"
|
||
onClick={() => setSettingsOpen(false)}
|
||
aria-label="Close settings"
|
||
/>
|
||
<div className="fixed bottom-0 right-0 top-0 z-50 flex w-full max-w-sm flex-col overflow-hidden border-l border-white/10 bg-[linear-gradient(180deg,rgba(14,18,27,0.99),rgba(9,12,19,0.99))] shadow-[−20px_0_60px_rgba(3,7,18,0.5)]">
|
||
<div className="flex shrink-0 items-center justify-between border-b border-white/10 px-5 py-4">
|
||
<h2 className="font-semibold text-white">Story settings</h2>
|
||
<button type="button" onClick={() => setSettingsOpen(false)} className="rounded-full p-1.5 text-white/40 transition-colors hover:bg-white/[0.07] hover:text-white">
|
||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" /></svg>
|
||
</button>
|
||
</div>
|
||
<div className="flex-1 overflow-y-auto">
|
||
<div className="space-y-6 p-5">
|
||
{/* Readiness checklist */}
|
||
<div>
|
||
<p className="mb-3 text-xs font-semibold uppercase tracking-widest text-white/35">Ready to publish?</p>
|
||
<div className="space-y-2">
|
||
{readinessChecks.map((check) => (
|
||
<div key={check.label} className={`flex items-start gap-3 rounded-xl p-3 ${check.ok ? 'bg-emerald-500/10 border border-emerald-500/20' : 'bg-amber-500/10 border border-amber-500/20'}`}>
|
||
<span className={`mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-[11px] font-bold ${check.ok ? 'bg-emerald-500/25 text-emerald-300' : 'bg-amber-500/25 text-amber-300'}`}>
|
||
{check.ok ? '✓' : '!'}
|
||
</span>
|
||
<div>
|
||
<p className="text-sm font-medium text-white/85">{check.label}</p>
|
||
<p className="text-xs text-white/40">{check.hint}</p>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Publish actions */}
|
||
<div>
|
||
<p className="mb-3 text-xs font-semibold uppercase tracking-widest text-white/35">Publish</p>
|
||
<div className="space-y-2">
|
||
<button type="button" onClick={() => { void persistStory('publish_now'); setSettingsOpen(false); }} disabled={isSubmitting} className="w-full rounded-xl bg-sky-500 px-4 py-3 text-sm font-medium text-white shadow-[0_2px_12px_rgba(14,165,233,0.35)] transition hover:bg-sky-400 disabled:opacity-50">Publish now</button>
|
||
<button type="button" onClick={() => { void persistStory('save_draft'); setSettingsOpen(false); }} disabled={isSubmitting} className="w-full rounded-xl border border-white/10 bg-white/[0.05] px-4 py-3 text-sm text-white/75 transition hover:bg-white/[0.09] disabled:opacity-50">Save as draft</button>
|
||
<button type="button" onClick={() => { void persistStory('submit_review'); setSettingsOpen(false); }} disabled={isSubmitting} className="w-full rounded-xl border border-amber-400/30 bg-amber-400/10 px-4 py-3 text-sm text-amber-200 transition hover:bg-amber-400/20 disabled:opacity-50">Submit for review</button>
|
||
{scheduledFor && (
|
||
<button type="button" onClick={() => { void persistStory('schedule_publish'); setSettingsOpen(false); }} disabled={isSubmitting} className="w-full rounded-xl border border-sky-400/30 bg-sky-400/10 px-4 py-3 text-sm text-sky-200 transition hover:bg-sky-400/20 disabled:opacity-50">Schedule publish</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Cover image */}
|
||
<div>
|
||
<div className="mb-3 flex items-center justify-between">
|
||
<p className="text-xs font-semibold uppercase tracking-widest text-white/35">Cover image</p>
|
||
<div className="flex items-center gap-3">
|
||
<button type="button" onClick={() => coverImageInputRef.current?.click()} className="text-xs text-sky-400 underline-offset-2 transition-colors hover:underline">Upload file</button>
|
||
{coverImage && <button type="button" onClick={() => setCoverImage('')} className="text-xs text-rose-400 underline-offset-2 transition-colors hover:underline">Remove</button>}
|
||
</div>
|
||
</div>
|
||
<input value={coverImage} onChange={(e) => setCoverImage(e.target.value)} placeholder="Paste an image URL…" className="w-full rounded-xl border border-white/10 bg-slate-950/60 px-3 py-2.5 text-sm text-white placeholder:text-white/25 focus:border-white/20 focus:outline-none" />
|
||
{coverImage && <img src={coverImage} alt="Cover preview" className="mt-3 h-36 w-full rounded-xl object-cover" />}
|
||
</div>
|
||
|
||
{/* Format */}
|
||
<div>
|
||
<p className="mb-2 text-xs font-semibold uppercase tracking-widest text-white/35">Format</p>
|
||
<NovaSelect value={storyType} onChange={(val) => setStoryType(val)} options={storyTypes.map((t) => ({ value: t.slug, label: t.name }))} />
|
||
</div>
|
||
|
||
{/* Tags */}
|
||
<div>
|
||
<p className="mb-2 text-xs font-semibold uppercase tracking-widest text-white/35">Tags</p>
|
||
<input value={tagsCsv} onChange={(e) => setTagsCsv(e.target.value)} placeholder="art direction, tutorial, process" className="w-full rounded-xl border border-white/10 bg-slate-950/60 px-3 py-2.5 text-sm text-white placeholder:text-white/25 focus:border-white/20 focus:outline-none" />
|
||
{tagsError ? <p className="mt-1 text-xs text-rose-400">{tagsError}</p> : <p className="mt-1 text-xs text-white/30">Comma-separated. New tags created automatically.</p>}
|
||
</div>
|
||
|
||
{/* Status + schedule */}
|
||
<div>
|
||
<p className="mb-2 text-xs font-semibold uppercase tracking-widest text-white/35">Workflow</p>
|
||
<NovaSelect value={status} onChange={(val) => setStatus(val)} searchable={false} options={[{ value: 'draft', label: 'Draft' }, { value: 'pending_review', label: 'Pending Review' }, { value: 'published', label: 'Published' }, { value: 'scheduled', label: 'Scheduled' }, { value: 'archived', label: 'Archived' }]} />
|
||
<DateTimePicker
|
||
value={scheduledFor}
|
||
onChange={setScheduledFor}
|
||
placeholder="Pick a publish date"
|
||
clearable
|
||
className="bg-slate-950/60"
|
||
/>
|
||
</div>
|
||
|
||
{/* SEO */}
|
||
<div>
|
||
<p className="mb-3 text-xs font-semibold uppercase tracking-widest text-white/35">SEO & social</p>
|
||
<div className="space-y-2">
|
||
<input value={metaTitle} onChange={(e) => setMetaTitle(e.target.value)} placeholder="Meta title (defaults to story title)" className="w-full rounded-xl border border-white/10 bg-slate-950/60 px-3 py-2.5 text-sm text-white placeholder:text-white/25 focus:border-white/20 focus:outline-none" />
|
||
<textarea value={metaDescription} onChange={(e) => setMetaDescription(e.target.value)} rows={3} placeholder="Meta description (defaults to excerpt)" className="w-full resize-none rounded-xl border border-white/10 bg-slate-950/60 px-3 py-2.5 text-sm text-white placeholder:text-white/25 focus:border-white/20 focus:outline-none" />
|
||
<input value={ogImage} onChange={(e) => setOgImage(e.target.value)} placeholder="OG image URL (defaults to cover)" className="w-full rounded-xl border border-white/10 bg-slate-950/60 px-3 py-2.5 text-sm text-white placeholder:text-white/25 focus:border-white/20 focus:outline-none" />
|
||
</div>
|
||
</div>
|
||
|
||
{/* Quick links */}
|
||
{storyId && (
|
||
<div className="space-y-2">
|
||
<a href={`${endpoints.previewBase}/${storyId}/preview`} className="block rounded-xl border border-white/10 bg-white/[0.04] px-4 py-2.5 text-center text-sm text-white/65 transition hover:bg-white/[0.08] hover:text-white">Preview story</a>
|
||
<a href={`${endpoints.analyticsBase}/${storyId}/analytics`} className="block rounded-xl border border-white/10 bg-white/[0.04] px-4 py-2.5 text-center text-sm text-white/65 transition hover:bg-white/[0.08] hover:text-white">View analytics</a>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
{/* ── Artwork picker modal ──────────────────────────────────────────── */}
|
||
{artworkModalOpen && (
|
||
<div className="fixed inset-0 z-50 flex items-end justify-center bg-black/60 p-4 backdrop-blur-sm sm:items-center">
|
||
<div className="w-full max-w-2xl overflow-hidden rounded-[1.75rem] border border-white/10 bg-[linear-gradient(180deg,rgba(14,18,27,0.99),rgba(9,12,19,0.99))] shadow-[0_24px_80px_rgba(3,7,18,0.7)]">
|
||
<div className="flex items-center justify-between border-b border-white/10 px-6 py-4">
|
||
<h3 className="font-semibold text-white">Embed an artwork</h3>
|
||
<button type="button" onClick={() => setArtworkModalOpen(false)} className="rounded-full p-1.5 text-white/40 transition-colors hover:bg-white/[0.07] hover:text-white">
|
||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" /></svg>
|
||
</button>
|
||
</div>
|
||
<div className="p-6">
|
||
<input
|
||
value={artworkQuery}
|
||
onChange={(e) => setArtworkQuery(e.target.value)}
|
||
className="mb-4 w-full rounded-xl border border-white/10 bg-slate-950/60 px-3 py-2.5 text-sm text-white placeholder:text-white/30 focus:border-white/20 focus:outline-none"
|
||
placeholder="Search your artworks…"
|
||
autoFocus
|
||
/>
|
||
<div className="grid max-h-72 gap-3 overflow-y-auto sm:grid-cols-3">
|
||
{artworkResults.map((item) => (
|
||
<button key={item.id} type="button" onClick={() => insertArtwork(item)} className="overflow-hidden rounded-xl border border-white/10 bg-white/[0.04] text-left transition hover:border-sky-400/40 hover:shadow-lg">
|
||
{(item.thumbs?.sm || item.thumb) && <img src={item.thumbs?.sm || item.thumb || ''} alt={item.title} className="h-24 w-full object-cover" />}
|
||
<div className="p-2">
|
||
<p className="line-clamp-1 text-xs font-medium text-white/80">{item.title}</p>
|
||
</div>
|
||
</button>
|
||
))}
|
||
{artworkResults.length === 0 && artworkQuery.length > 0 && (
|
||
<p className="col-span-3 py-8 text-center text-sm text-white/35">No artworks found for “{artworkQuery}”</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<Modal
|
||
open={Boolean(insertDialog.kind)}
|
||
onClose={closeInsertDialog}
|
||
title={insertDialog.kind ? INSERT_DIALOG_CONTENT[insertDialog.kind].title : ''}
|
||
size="md"
|
||
footer={insertDialog.kind ? (
|
||
<div className="ml-auto flex items-center gap-2">
|
||
{insertDialog.kind === 'link' && (
|
||
<button
|
||
type="button"
|
||
onClick={removeSelectedLink}
|
||
className="rounded-xl border border-rose-400/20 bg-rose-500/10 px-4 py-2 text-sm text-rose-200 transition hover:bg-rose-500/20"
|
||
>
|
||
Remove link
|
||
</button>
|
||
)}
|
||
<button
|
||
type="button"
|
||
onClick={closeInsertDialog}
|
||
className="rounded-xl border border-white/10 bg-white/[0.04] px-4 py-2 text-sm text-white/70 transition hover:bg-white/[0.08] hover:text-white"
|
||
>
|
||
Cancel
|
||
</button>
|
||
<button
|
||
type="submit"
|
||
form="story-insert-dialog-form"
|
||
className="rounded-xl bg-sky-500 px-4 py-2 text-sm font-medium text-white shadow-[0_6px_20px_rgba(14,165,233,0.35)] transition hover:bg-sky-400"
|
||
>
|
||
{INSERT_DIALOG_CONTENT[insertDialog.kind].confirmLabel}
|
||
</button>
|
||
</div>
|
||
) : null}
|
||
>
|
||
{insertDialog.kind ? (
|
||
<form id="story-insert-dialog-form" onSubmit={submitInsertDialog} className="space-y-5">
|
||
<div className="space-y-2">
|
||
<p className="text-sm leading-6 text-slate-200">{INSERT_DIALOG_CONTENT[insertDialog.kind].description}</p>
|
||
<p className="text-xs leading-5 text-slate-400">{INSERT_DIALOG_CONTENT[insertDialog.kind].urlHint}</p>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<label className="block text-xs font-semibold uppercase tracking-[0.18em] text-white/40">
|
||
{INSERT_DIALOG_CONTENT[insertDialog.kind].urlLabel}
|
||
</label>
|
||
<input
|
||
value={insertDialog.url}
|
||
onChange={(event) => setInsertDialog((previous) => ({ ...previous, url: event.target.value, error: '' }))}
|
||
placeholder={INSERT_DIALOG_CONTENT[insertDialog.kind].urlPlaceholder}
|
||
className="w-full rounded-2xl border border-white/10 bg-slate-950/70 px-4 py-3 text-sm text-white placeholder:text-white/25 focus:border-sky-400/40 focus:outline-none"
|
||
/>
|
||
</div>
|
||
|
||
{insertDialog.kind === 'video' && (
|
||
<div className="space-y-2">
|
||
<label className="block text-xs font-semibold uppercase tracking-[0.18em] text-white/40">
|
||
Accessible title
|
||
</label>
|
||
<input
|
||
value={insertDialog.title}
|
||
onChange={(event) => setInsertDialog((previous) => ({ ...previous, title: event.target.value }))}
|
||
placeholder="Embedded video"
|
||
className="w-full rounded-2xl border border-white/10 bg-slate-950/70 px-4 py-3 text-sm text-white placeholder:text-white/25 focus:border-sky-400/40 focus:outline-none"
|
||
/>
|
||
<p className="text-xs leading-5 text-slate-400">This helps screen readers describe the embedded video block.</p>
|
||
</div>
|
||
)}
|
||
|
||
{insertDialog.kind === 'download' && (
|
||
<div className="space-y-2">
|
||
<label className="block text-xs font-semibold uppercase tracking-[0.18em] text-white/40">
|
||
Button label
|
||
</label>
|
||
<input
|
||
value={insertDialog.label}
|
||
onChange={(event) => setInsertDialog((previous) => ({ ...previous, label: event.target.value }))}
|
||
placeholder="Download asset"
|
||
className="w-full rounded-2xl border border-white/10 bg-slate-950/70 px-4 py-3 text-sm text-white placeholder:text-white/25 focus:border-sky-400/40 focus:outline-none"
|
||
/>
|
||
<p className="text-xs leading-5 text-slate-400">Readers will see this label on the download button inside the story.</p>
|
||
</div>
|
||
)}
|
||
|
||
{insertDialog.error ? (
|
||
<div className="rounded-2xl border border-rose-400/20 bg-rose-500/10 px-4 py-3 text-sm text-rose-200">
|
||
{insertDialog.error}
|
||
</div>
|
||
) : null}
|
||
</form>
|
||
) : null}
|
||
</Modal>
|
||
|
||
{/* Hidden file inputs */}
|
||
<input ref={bodyImageInputRef} type="file" accept="image/*" className="hidden" onChange={handleBodyImagePicked} />
|
||
<input ref={coverImageInputRef} type="file" accept="image/*" className="hidden" onChange={handleCoverImagePicked} />
|
||
</div>
|
||
);
|
||
}
|