817 lines
32 KiB
TypeScript
817 lines
32 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 Image from '@tiptap/extension-image';
|
|
import Link from '@tiptap/extension-link';
|
|
import Placeholder from '@tiptap/extension-placeholder';
|
|
import Suggestion from '@tiptap/suggestion';
|
|
import { Node, mergeAttributes } from '@tiptap/core';
|
|
import tippy from 'tippy.js';
|
|
|
|
type StoryType = {
|
|
slug: string;
|
|
name: string;
|
|
};
|
|
|
|
type Artwork = {
|
|
id: number;
|
|
title: string;
|
|
url: string;
|
|
thumb: string | null;
|
|
thumbs?: {
|
|
xs?: string | null;
|
|
sm?: string | null;
|
|
md?: string | null;
|
|
lg?: string | null;
|
|
xl?: string | null;
|
|
};
|
|
};
|
|
|
|
type StoryPayload = {
|
|
id?: number;
|
|
title: string;
|
|
excerpt: string;
|
|
cover_image: string;
|
|
story_type: string;
|
|
tags_csv: string;
|
|
meta_title: string;
|
|
meta_description: string;
|
|
canonical_url: string;
|
|
og_image: string;
|
|
status: string;
|
|
scheduled_for: string;
|
|
content: Record<string, unknown>;
|
|
};
|
|
|
|
type Endpoints = {
|
|
create: string;
|
|
update: string;
|
|
autosave: string;
|
|
uploadImage: string;
|
|
artworks: string;
|
|
previewBase: string;
|
|
analyticsBase: string;
|
|
};
|
|
|
|
type Props = {
|
|
mode: 'create' | 'edit';
|
|
initialStory: StoryPayload;
|
|
storyTypes: StoryType[];
|
|
endpoints: Endpoints;
|
|
csrfToken: string;
|
|
};
|
|
|
|
const 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;
|
|
artwork: () => void;
|
|
code: () => void;
|
|
quote: () => void;
|
|
divider: () => void;
|
|
gallery: () => void;
|
|
}) {
|
|
return Extension.create({
|
|
name: 'slashCommands',
|
|
|
|
addOptions() {
|
|
return {
|
|
suggestion: {
|
|
char: '/',
|
|
startOfLine: true,
|
|
items: ({ query }: { query: string }) => {
|
|
const all = [
|
|
{ title: 'Image', key: 'image' },
|
|
{ title: 'Artwork', key: 'artwork' },
|
|
{ title: 'Code', key: 'code' },
|
|
{ title: 'Quote', key: 'quote' },
|
|
{ title: 'Divider', key: 'divider' },
|
|
{ title: 'Gallery', key: 'gallery' },
|
|
];
|
|
return all.filter((item) => item.key.startsWith(query.toLowerCase()));
|
|
},
|
|
command: ({ props }: { editor: any; props: { key: string } }) => {
|
|
if (props.key === 'image') insert.image();
|
|
if (props.key === 'artwork') insert.artwork();
|
|
if (props.key === 'code') insert.code();
|
|
if (props.key === 'quote') insert.quote();
|
|
if (props.key === 'divider') insert.divider();
|
|
if (props.key === 'gallery') insert.gallery();
|
|
},
|
|
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 requestJson<T>(url: string, method: string, body: unknown, csrfToken: string): Promise<T> {
|
|
const response = await fetch(url, {
|
|
method,
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRF-TOKEN': csrfToken,
|
|
'X-Requested-With': 'XMLHttpRequest',
|
|
},
|
|
body: JSON.stringify(body),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Request failed: ${response.status}`);
|
|
}
|
|
|
|
return response.json() as Promise<T>;
|
|
}
|
|
|
|
export default function StoryEditor({ mode, initialStory, storyTypes, endpoints, csrfToken }: Props) {
|
|
const [storyId, setStoryId] = useState<number | undefined>(initialStory.id);
|
|
const [title, setTitle] = useState(initialStory.title || '');
|
|
const [excerpt, setExcerpt] = useState(initialStory.excerpt || '');
|
|
const [coverImage, setCoverImage] = useState(initialStory.cover_image || '');
|
|
const [storyType, setStoryType] = useState(initialStory.story_type || 'creator_story');
|
|
const [tagsCsv, setTagsCsv] = useState(initialStory.tags_csv || '');
|
|
const [metaTitle, setMetaTitle] = useState(initialStory.meta_title || '');
|
|
const [metaDescription, setMetaDescription] = useState(initialStory.meta_description || '');
|
|
const [canonicalUrl, setCanonicalUrl] = useState(initialStory.canonical_url || '');
|
|
const [ogImage, setOgImage] = useState(initialStory.og_image || '');
|
|
const [status, setStatus] = useState(initialStory.status || 'draft');
|
|
const [scheduledFor, setScheduledFor] = useState(initialStory.scheduled_for || '');
|
|
const [saveStatus, setSaveStatus] = useState('Autosave idle');
|
|
const [artworkModalOpen, setArtworkModalOpen] = useState(false);
|
|
const [artworkResults, setArtworkResults] = useState<Artwork[]>([]);
|
|
const [artworkQuery, setArtworkQuery] = useState('');
|
|
const [showInsertMenu, setShowInsertMenu] = useState(false);
|
|
const [showLivePreview, setShowLivePreview] = useState(false);
|
|
const [livePreviewHtml, setLivePreviewHtml] = useState('');
|
|
const [inlineToolbar, setInlineToolbar] = useState({ visible: false, top: 0, left: 0 });
|
|
const lastSavedRef = useRef('');
|
|
|
|
const emitSaveEvent = useCallback((kind: 'autosave' | 'manual', id?: number) => {
|
|
window.dispatchEvent(new CustomEvent('story-editor:saved', {
|
|
detail: {
|
|
kind,
|
|
storyId: id,
|
|
savedAt: new Date().toISOString(),
|
|
},
|
|
}));
|
|
}, []);
|
|
|
|
const openLinkPrompt = useCallback((editor: any) => {
|
|
const prev = editor.getAttributes('link').href;
|
|
const url = window.prompt('Link URL', prev || 'https://');
|
|
if (url === null) return;
|
|
if (url.trim() === '') {
|
|
editor.chain().focus().unsetLink().run();
|
|
return;
|
|
}
|
|
editor.chain().focus().setLink({ href: url.trim() }).run();
|
|
}, []);
|
|
|
|
const fetchArtworks = useCallback(async (query: string) => {
|
|
const q = encodeURIComponent(query);
|
|
const response = await fetch(`${endpoints.artworks}?q=${q}`, {
|
|
headers: {
|
|
'X-Requested-With': 'XMLHttpRequest',
|
|
},
|
|
});
|
|
if (!response.ok) return;
|
|
const data = await response.json();
|
|
setArtworkResults(Array.isArray(data.artworks) ? data.artworks : []);
|
|
}, [endpoints.artworks]);
|
|
|
|
const uploadImageFile = useCallback(async (file: File): Promise<string | null> => {
|
|
const formData = new FormData();
|
|
formData.append('image', file);
|
|
|
|
const response = await fetch(endpoints.uploadImage, {
|
|
method: 'POST',
|
|
headers: {
|
|
'X-CSRF-TOKEN': csrfToken,
|
|
'X-Requested-With': 'XMLHttpRequest',
|
|
},
|
|
body: formData,
|
|
});
|
|
|
|
if (!response.ok) {
|
|
return null;
|
|
}
|
|
|
|
const data = await response.json();
|
|
return data.medium_url || data.original_url || data.thumbnail_url || null;
|
|
}, [endpoints.uploadImage, csrfToken]);
|
|
|
|
const insertActions = useMemo(() => ({
|
|
image: () => {
|
|
const url = window.prompt('Image URL', 'https://');
|
|
if (!url || !editor) return;
|
|
editor.chain().focus().setImage({ src: url }).run();
|
|
},
|
|
artwork: () => setArtworkModalOpen(true),
|
|
code: () => {
|
|
if (!editor) return;
|
|
editor.chain().focus().toggleCodeBlock().run();
|
|
},
|
|
quote: () => {
|
|
if (!editor) return;
|
|
editor.chain().focus().toggleBlockquote().run();
|
|
},
|
|
divider: () => {
|
|
if (!editor) return;
|
|
editor.chain().focus().setHorizontalRule().run();
|
|
},
|
|
gallery: () => {
|
|
if (!editor) return;
|
|
const raw = window.prompt('Gallery image URLs (comma separated)', '');
|
|
const images = (raw || '').split(',').map((value) => value.trim()).filter(Boolean);
|
|
editor.chain().focus().insertContent({ type: 'galleryBlock', attrs: { images } }).run();
|
|
},
|
|
video: () => {
|
|
if (!editor) return;
|
|
const src = window.prompt('Video embed URL (YouTube/Vimeo)', 'https://www.youtube.com/embed/');
|
|
if (!src) return;
|
|
editor.chain().focus().insertContent({ type: 'videoEmbed', attrs: { src, title: 'Embedded video' } }).run();
|
|
},
|
|
download: () => {
|
|
if (!editor) return;
|
|
const url = window.prompt('Download URL', 'https://');
|
|
if (!url) return;
|
|
const label = window.prompt('Button label', 'Download asset') || 'Download asset';
|
|
editor.chain().focus().insertContent({ type: 'downloadAsset', attrs: { url, label } }).run();
|
|
},
|
|
}), []);
|
|
|
|
const editor = useEditor({
|
|
extensions: [
|
|
StarterKit.configure({
|
|
heading: { levels: [1, 2, 3] },
|
|
}),
|
|
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),
|
|
],
|
|
content: initialStory.content || { type: 'doc', content: [{ type: 'paragraph' }] },
|
|
editorProps: {
|
|
attributes: {
|
|
class: 'tiptap prose prose-invert max-w-none min-h-[26rem] rounded-xl border border-gray-700 bg-gray-900/80 px-6 py-5 text-gray-200 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;
|
|
},
|
|
},
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (!editor) return;
|
|
|
|
const updatePreview = () => {
|
|
setLivePreviewHtml(editor.getHTML());
|
|
};
|
|
|
|
updatePreview();
|
|
editor.on('update', updatePreview);
|
|
|
|
return () => {
|
|
editor.off('update', updatePreview);
|
|
};
|
|
}, [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]);
|
|
|
|
const payload = useCallback(() => ({
|
|
story_id: storyId,
|
|
title,
|
|
excerpt,
|
|
cover_image: coverImage,
|
|
story_type: storyType,
|
|
tags_csv: tagsCsv,
|
|
tags: tagsCsv.split(',').map((tag) => tag.trim()).filter(Boolean),
|
|
meta_title: metaTitle || title,
|
|
meta_description: metaDescription || excerpt,
|
|
canonical_url: canonicalUrl,
|
|
og_image: ogImage || coverImage,
|
|
status,
|
|
scheduled_for: scheduledFor || null,
|
|
content: editor?.getJSON() || { type: 'doc', content: [{ type: 'paragraph' }] },
|
|
}), [storyId, title, excerpt, coverImage, storyType, tagsCsv, metaTitle, metaDescription, canonicalUrl, ogImage, status, scheduledFor, editor]);
|
|
|
|
useEffect(() => {
|
|
if (!editor) return;
|
|
|
|
const timer = window.setInterval(async () => {
|
|
const body = payload();
|
|
const snapshot = JSON.stringify(body);
|
|
if (snapshot === lastSavedRef.current) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
setSaveStatus('Saving...');
|
|
const data = await requestJson<{ story_id?: number; message?: string }>(endpoints.autosave, 'POST', body, csrfToken);
|
|
if (data.story_id && !storyId) {
|
|
setStoryId(data.story_id);
|
|
}
|
|
lastSavedRef.current = snapshot;
|
|
setSaveStatus(data.message || 'Saved just now');
|
|
emitSaveEvent('autosave', data.story_id || storyId);
|
|
} catch {
|
|
setSaveStatus('Autosave failed');
|
|
}
|
|
}, 10000);
|
|
|
|
return () => window.clearInterval(timer);
|
|
}, [editor, payload, endpoints.autosave, csrfToken, storyId, emitSaveEvent]);
|
|
|
|
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 {
|
|
setSaveStatus('Saving...');
|
|
const endpoint = storyId ? endpoints.update : endpoints.create;
|
|
const method = storyId ? 'PUT' : 'POST';
|
|
const data = await requestJson<{ story_id: number; message?: string }>(endpoint, method, body, csrfToken);
|
|
if (data.story_id) {
|
|
setStoryId(data.story_id);
|
|
}
|
|
setSaveStatus(data.message || 'Saved just now');
|
|
emitSaveEvent('manual', data.story_id || storyId);
|
|
} catch {
|
|
setSaveStatus('Save failed');
|
|
}
|
|
};
|
|
|
|
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="space-y-6">
|
|
<div className="rounded-xl border border-gray-700 bg-gray-800/60 p-4 shadow-lg">
|
|
<div className="space-y-4">
|
|
<input
|
|
value={title}
|
|
onChange={(event) => setTitle(event.target.value)}
|
|
placeholder="Title"
|
|
className="w-full rounded-xl border border-gray-700 bg-gray-900 px-4 py-3 text-2xl font-semibold text-gray-100"
|
|
/>
|
|
|
|
<div className="grid gap-3 md:grid-cols-2">
|
|
<input value={excerpt} onChange={(event) => setExcerpt(event.target.value)} placeholder="Excerpt" className="rounded-xl border border-gray-700 bg-gray-900 px-3 py-2 text-sm text-gray-200" />
|
|
<select value={storyType} onChange={(event) => setStoryType(event.target.value)} className="rounded-xl border border-gray-700 bg-gray-900 px-3 py-2 text-sm text-gray-200">
|
|
{storyTypes.map((type) => (
|
|
<option key={type.slug} value={type.slug}>{type.name}</option>
|
|
))}
|
|
</select>
|
|
<input value={tagsCsv} onChange={(event) => setTagsCsv(event.target.value)} placeholder="Tags (comma separated)" className="rounded-xl border border-gray-700 bg-gray-900 px-3 py-2 text-sm text-gray-200" />
|
|
<select value={status} onChange={(event) => setStatus(event.target.value)} className="rounded-xl border border-gray-700 bg-gray-900 px-3 py-2 text-sm text-gray-200">
|
|
<option value="draft">Draft</option>
|
|
<option value="pending_review">Pending Review</option>
|
|
<option value="published">Published</option>
|
|
<option value="scheduled">Scheduled</option>
|
|
<option value="archived">Archived</option>
|
|
</select>
|
|
<input value={coverImage} onChange={(event) => setCoverImage(event.target.value)} placeholder="Cover image URL" className="rounded-xl border border-gray-700 bg-gray-900 px-3 py-2 text-sm text-gray-200" />
|
|
<input type="datetime-local" value={scheduledFor} onChange={(event) => setScheduledFor(event.target.value)} className="rounded-xl border border-gray-700 bg-gray-900 px-3 py-2 text-sm text-gray-200" />
|
|
<input value={metaTitle} onChange={(event) => setMetaTitle(event.target.value)} placeholder="Meta title" className="rounded-xl border border-gray-700 bg-gray-900 px-3 py-2 text-sm text-gray-200" />
|
|
<input value={metaDescription} onChange={(event) => setMetaDescription(event.target.value)} placeholder="Meta description" className="rounded-xl border border-gray-700 bg-gray-900 px-3 py-2 text-sm text-gray-200" />
|
|
<input value={canonicalUrl} onChange={(event) => setCanonicalUrl(event.target.value)} placeholder="Canonical URL" className="rounded-xl border border-gray-700 bg-gray-900 px-3 py-2 text-sm text-gray-200" />
|
|
<input value={ogImage} onChange={(event) => setOgImage(event.target.value)} placeholder="OG image URL" className="rounded-xl border border-gray-700 bg-gray-900 px-3 py-2 text-sm text-gray-200" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="relative rounded-xl border border-gray-700 bg-gray-800/60 p-4 shadow-lg">
|
|
<div className="mb-3 flex flex-wrap items-center gap-2">
|
|
<button type="button" onClick={() => setShowInsertMenu((current) => !current)} className="rounded-lg border border-gray-600 px-3 py-1 text-xs text-gray-200">+ Insert</button>
|
|
<button type="button" onClick={() => setShowLivePreview((current) => !current)} className="rounded-lg border border-gray-600 px-3 py-1 text-xs text-gray-200">{showLivePreview ? 'Hide Preview' : 'Live Preview'}</button>
|
|
<button type="button" onClick={() => persistStory('save_draft')} className="rounded-lg border border-gray-600 bg-gray-700/40 px-3 py-1 text-xs text-gray-200">Save Draft</button>
|
|
<button type="button" onClick={() => persistStory('submit_review')} className="rounded-lg border border-amber-500/40 bg-amber-500/10 px-3 py-1 text-xs text-amber-200">Submit for Review</button>
|
|
<button type="button" onClick={() => persistStory('publish_now')} className="rounded-lg border border-emerald-500/40 bg-emerald-500/10 px-3 py-1 text-xs text-emerald-200">Publish Now</button>
|
|
<button type="button" onClick={() => persistStory('schedule_publish')} className="rounded-lg border border-sky-500/40 bg-sky-500/10 px-3 py-1 text-xs text-sky-200">Schedule Publish</button>
|
|
<span className="ml-auto text-xs text-emerald-300">{saveStatus}</span>
|
|
</div>
|
|
|
|
{showInsertMenu && (
|
|
<div className="mb-3 grid grid-cols-2 gap-2 rounded-xl border border-gray-700 bg-gray-900/90 p-2 sm:grid-cols-3">
|
|
<button type="button" className="rounded-md border border-gray-700 px-2 py-1 text-xs text-gray-200" onClick={insertActions.image}>Image</button>
|
|
<button type="button" className="rounded-md border border-gray-700 px-2 py-1 text-xs text-gray-200" onClick={insertActions.artwork}>Embed Artwork</button>
|
|
<button type="button" className="rounded-md border border-gray-700 px-2 py-1 text-xs text-gray-200" onClick={insertActions.code}>Code Block</button>
|
|
<button type="button" className="rounded-md border border-gray-700 px-2 py-1 text-xs text-gray-200" onClick={insertActions.quote}>Quote</button>
|
|
<button type="button" className="rounded-md border border-gray-700 px-2 py-1 text-xs text-gray-200" onClick={insertActions.divider}>Divider</button>
|
|
<button type="button" className="rounded-md border border-gray-700 px-2 py-1 text-xs text-gray-200" onClick={insertActions.gallery}>Gallery</button>
|
|
<button type="button" className="rounded-md border border-gray-700 px-2 py-1 text-xs text-gray-200" onClick={insertActions.video}>Video Embed</button>
|
|
<button type="button" className="rounded-md border border-gray-700 px-2 py-1 text-xs text-gray-200" onClick={insertActions.download}>Download Asset</button>
|
|
</div>
|
|
)}
|
|
|
|
{editor && inlineToolbar.visible && (
|
|
<div
|
|
className="fixed z-40 flex items-center gap-1 rounded-lg border border-gray-700 bg-gray-900 px-2 py-1 shadow-lg"
|
|
style={{ top: `${inlineToolbar.top}px`, left: `${inlineToolbar.left}px` }}
|
|
>
|
|
<button type="button" className={`rounded px-2 py-1 text-xs ${editor.isActive('bold') ? 'bg-sky-500/30 text-sky-100' : 'text-gray-200'}`} onMouseDown={(event) => event.preventDefault()} onClick={() => editor.chain().focus().toggleBold().run()}>B</button>
|
|
<button type="button" className={`rounded px-2 py-1 text-xs ${editor.isActive('italic') ? 'bg-sky-500/30 text-sky-100' : 'text-gray-200'}`} onMouseDown={(event) => event.preventDefault()} onClick={() => editor.chain().focus().toggleItalic().run()}>I</button>
|
|
<button type="button" className={`rounded px-2 py-1 text-xs ${editor.isActive('code') ? 'bg-sky-500/30 text-sky-100' : 'text-gray-200'}`} onMouseDown={(event) => event.preventDefault()} onClick={() => editor.chain().focus().toggleCode().run()}>{'</>'}</button>
|
|
<button type="button" className="rounded px-2 py-1 text-xs text-gray-200" onMouseDown={(event) => event.preventDefault()} onClick={() => openLinkPrompt(editor)}>Link</button>
|
|
<button type="button" className="rounded px-2 py-1 text-xs text-gray-200" onMouseDown={(event) => event.preventDefault()} onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}>H2</button>
|
|
<button type="button" className="rounded px-2 py-1 text-xs text-gray-200" onMouseDown={(event) => event.preventDefault()} onClick={() => editor.chain().focus().toggleBlockquote().run()}>Quote</button>
|
|
</div>
|
|
)}
|
|
|
|
<EditorContent editor={editor} />
|
|
|
|
{showLivePreview && (
|
|
<div className="mt-4 rounded-xl border border-gray-700 bg-gray-900/60 p-4">
|
|
<div className="mb-2 text-xs font-semibold uppercase tracking-wide text-gray-400">Live Preview</div>
|
|
<div className="prose prose-invert max-w-none prose-pre:bg-gray-900" dangerouslySetInnerHTML={{ __html: livePreviewHtml }} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex flex-wrap gap-3">
|
|
{storyId && (
|
|
<a href={`${endpoints.previewBase}/${storyId}/preview`} className="rounded-xl border border-sky-500/40 bg-sky-500/10 px-3 py-2 text-sm text-sky-200">Preview</a>
|
|
)}
|
|
{storyId && (
|
|
<a href={`${endpoints.analyticsBase}/${storyId}/analytics`} className="rounded-xl border border-violet-500/40 bg-violet-500/10 px-3 py-2 text-sm text-violet-200">Analytics</a>
|
|
)}
|
|
{mode === 'edit' && storyId && (
|
|
<form method="POST" action={`/creator/stories/${storyId}`} onSubmit={(event) => {
|
|
if (!window.confirm('Delete this story?')) {
|
|
event.preventDefault();
|
|
}
|
|
}}>
|
|
<input type="hidden" name="_token" value={csrfToken} />
|
|
<input type="hidden" name="_method" value="DELETE" />
|
|
<button type="submit" className="rounded-xl border border-rose-500/40 bg-rose-500/20 px-3 py-2 text-sm text-rose-200">Delete</button>
|
|
</form>
|
|
)}
|
|
</div>
|
|
|
|
{artworkModalOpen && (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4">
|
|
<div className="w-full max-w-3xl rounded-xl border border-gray-700 bg-gray-900 p-4 shadow-lg">
|
|
<div className="mb-3 flex items-center justify-between">
|
|
<h3 className="text-sm font-semibold text-white">Embed Artwork</h3>
|
|
<button type="button" onClick={() => setArtworkModalOpen(false)} className="rounded border border-gray-600 px-2 py-1 text-xs text-gray-200">Close</button>
|
|
</div>
|
|
<input value={artworkQuery} onChange={(event) => setArtworkQuery(event.target.value)} className="mb-3 w-full rounded-xl border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-200" placeholder="Search artworks" />
|
|
<div className="grid max-h-80 gap-3 overflow-y-auto sm:grid-cols-2">
|
|
{artworkResults.map((item) => (
|
|
<button key={item.id} type="button" onClick={() => insertArtwork(item)} className="rounded-xl border border-gray-700 bg-gray-800 p-3 text-left hover:border-sky-400">
|
|
{(item.thumbs?.sm || item.thumb) && <img src={item.thumbs?.sm || item.thumb || ''} alt={item.title} className="h-28 w-full rounded-lg object-cover" />}
|
|
<div className="mt-2 text-sm font-semibold text-white">{item.title}</div>
|
|
<div className="text-xs text-gray-400">#{item.id}</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|