// @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; }; 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 = 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 ``; }) .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(url: string, method: string, body: unknown, csrfToken: string): Promise { 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; } export default function StoryEditor({ mode, initialStory, storyTypes, endpoints, csrfToken }: Props) { const [storyId, setStoryId] = useState(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([]); 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 => { 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 (
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" />
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" /> 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" /> 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" /> setScheduledFor(event.target.value)} className="rounded-xl border border-gray-700 bg-gray-900 px-3 py-2 text-sm text-gray-200" /> 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" /> 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" /> 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" /> 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" />
{saveStatus}
{showInsertMenu && (
)} {editor && inlineToolbar.visible && (
)} {showLivePreview && (
Live Preview
)}
{storyId && ( Preview )} {storyId && ( Analytics )} {mode === 'edit' && storyId && (
{ if (!window.confirm('Delete this story?')) { event.preventDefault(); } }}>
)}
{artworkModalOpen && (

Embed Artwork

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" />
{artworkResults.map((item) => ( ))}
)}
); }