import React, { useCallback, useEffect, useMemo, useState } from 'react' import { createPortal } from 'react-dom' import { useEditor, EditorContent } from '@tiptap/react' import { Node, mergeAttributes } from '@tiptap/core' import StarterKit from '@tiptap/starter-kit' import Link from '@tiptap/extension-link' import Image from '@tiptap/extension-image' import Placeholder from '@tiptap/extension-placeholder' import Underline from '@tiptap/extension-underline' import Mention from '@tiptap/extension-mention' import mentionSuggestion from './mentionSuggestion' import EmojiPicker from './EmojiPicker' function ToolbarBtn({ onClick, active, disabled, title, children, className = '' }) { return ( ) } function Divider() { return
} function normalizeHttpUrl(rawValue) { const trimmed = String(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) { 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]}` } } return normalized } function detectSocialPlatform(rawUrl) { const normalized = normalizeHttpUrl(rawUrl) if (!normalized) { return { platform: 'social', label: 'Social post', url: null } } const host = new URL(normalized).hostname.replace(/^www\./i, '').toLowerCase() if (host.includes('instagram.')) return { platform: 'instagram', label: 'Instagram post', url: normalized } if (host.includes('facebook.')) return { platform: 'facebook', label: 'Facebook post', url: normalized } if (host.includes('tiktok.')) return { platform: 'tiktok', label: 'TikTok post', url: normalized } if (host.includes('twitter.') || host.includes('x.com')) return { platform: 'x', label: 'X post', url: normalized } if (host.includes('linkedin.')) return { platform: 'linkedin', label: 'LinkedIn post', url: normalized } if (host.includes('threads.')) return { platform: 'threads', label: 'Threads post', url: normalized } if (host.includes('pinterest.')) return { platform: 'pinterest', label: 'Pinterest pin', url: normalized } return { platform: 'social', label: 'Social post', url: normalized } } const ArtworkEmbed = Node.create({ name: 'artworkEmbed', group: 'block', atom: true, addAttributes() { return { title: { default: 'Artwork' }, url: { default: '' }, thumb: { default: '' }, } }, parseHTML() { return [{ tag: 'figure[data-artwork-embed]' }] }, renderHTML({ HTMLAttributes }) { const preview = [] if (HTMLAttributes.thumb) { preview.push([ 'img', { src: HTMLAttributes.thumb, alt: HTMLAttributes.title || 'Artwork', class: 'block h-auto w-full object-cover', loading: 'lazy', }, ]) } preview.push([ 'figcaption', { class: 'news-embed-caption' }, HTMLAttributes.title || 'Artwork', ]) return [ 'figure', mergeAttributes(HTMLAttributes, { 'data-artwork-embed': 'true', class: 'news-embed news-embed-artwork', }), [ 'a', { href: HTMLAttributes.url || '#', class: 'news-embed-link', rel: 'noopener noreferrer nofollow', target: '_blank', }, ...preview, ], ] }, }) const VideoEmbed = Node.create({ name: 'videoEmbed', group: 'block', atom: true, addAttributes() { return { src: { default: '' }, url: { default: '' }, title: { default: 'Embedded video' }, } }, parseHTML() { return [{ tag: 'figure[data-video-embed]' }] }, renderHTML({ HTMLAttributes }) { return [ 'figure', mergeAttributes(HTMLAttributes, { 'data-video-embed': 'true', class: 'news-embed news-embed-video', }), [ 'iframe', { src: HTMLAttributes.src || '', title: HTMLAttributes.title || 'Embedded video', loading: 'lazy', frameborder: '0', allow: 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share', allowfullscreen: 'true', referrerpolicy: 'strict-origin-when-cross-origin', }, ], [ 'figcaption', { class: 'news-embed-caption' }, [ 'a', { href: HTMLAttributes.url || HTMLAttributes.src || '#', rel: 'noopener noreferrer nofollow', target: '_blank', }, HTMLAttributes.title || 'Watch video', ], ], ] }, }) const SocialEmbed = Node.create({ name: 'socialEmbed', group: 'block', atom: true, addAttributes() { return { url: { default: '' }, platform: { default: 'social' }, label: { default: 'Social post' }, } }, parseHTML() { return [{ tag: 'figure[data-social-embed]' }] }, renderHTML({ HTMLAttributes }) { const platform = String(HTMLAttributes.platform || 'social') const url = HTMLAttributes.url || '#' const label = HTMLAttributes.label || 'Social post' if (platform === 'instagram') { return [ 'figure', mergeAttributes(HTMLAttributes, { 'data-social-embed': 'true', 'data-platform': platform, class: 'news-embed news-embed-social', }), [ 'blockquote', { class: 'instagram-media', 'data-instgrm-captioned': 'true', 'data-instgrm-permalink': url, 'data-instgrm-version': '14', }, [ 'a', { href: url, rel: 'noopener noreferrer nofollow', target: '_blank' }, label, ], ], ] } if (platform === 'facebook') { return [ 'figure', mergeAttributes(HTMLAttributes, { 'data-social-embed': 'true', 'data-platform': platform, class: 'news-embed news-embed-social', }), [ 'div', { class: 'fb-post', 'data-href': url, 'data-show-text': 'true' }, [ 'blockquote', { cite: url, class: 'fb-xfbml-parse-ignore' }, [ 'a', { href: url, rel: 'noopener noreferrer nofollow', target: '_blank' }, label, ], ], ], ] } if (platform === 'tiktok') { return [ 'figure', mergeAttributes(HTMLAttributes, { 'data-social-embed': 'true', 'data-platform': platform, class: 'news-embed news-embed-social', }), [ 'blockquote', { class: 'tiktok-embed', cite: url }, [ 'section', null, [ 'a', { href: url, rel: 'noopener noreferrer nofollow', target: '_blank' }, label, ], ], ], ] } if (platform === 'x') { return [ 'figure', mergeAttributes(HTMLAttributes, { 'data-social-embed': 'true', 'data-platform': platform, class: 'news-embed news-embed-social', }), [ 'blockquote', { class: 'twitter-tweet' }, [ 'a', { href: url, rel: 'noopener noreferrer nofollow', target: '_blank' }, label, ], ], ] } return [ 'figure', mergeAttributes(HTMLAttributes, { 'data-social-embed': 'true', 'data-platform': platform, class: 'news-embed news-embed-social', }), [ 'a', { href: url, class: 'news-embed-link', rel: 'noopener noreferrer nofollow', target: '_blank', }, ['span', { class: 'news-embed-badge' }, label], ['span', { class: 'news-embed-url' }, url], ], ] }, }) function ArtworkPickerDialog({ open, query, items, loading, onQueryChange, onClose, onSearch, onSelect, }) { if (!open) return null return createPortal(
{ if (event.target === event.currentTarget) { onClose?.() } }} role="presentation" >
Artwork embed

Choose artwork

Search existing artworks and insert a linked artwork card into the News article body.

onQueryChange?.(event.target.value)} onKeyDown={(event) => { if (event.key === 'Enter') { event.preventDefault() onSearch?.() } }} placeholder="Search by title, slug, or creator" className="min-w-0 flex-1 rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
{loading ?
Searching artworks…
: null} {!loading && (!Array.isArray(items) || items.length === 0) ?
No artworks found yet. Try a broader title or creator search.
: null} {!loading && Array.isArray(items) && items.length > 0 ? (
{items.map((item) => { const previewImage = item.image || item.avatar || '' return ( ) })}
) : null}
, document.body, ) } function Toolbar({ editor, advancedNews = false, sourceMode = false, showStructureOutlines = false, onToggleSourceMode, onToggleStructureOutlines, onInsertArtwork, onInsertSocialEmbed, onInsertVideoEmbed, onInsertHashtag, }) { if (!editor) return null const addLink = useCallback(() => { const prev = editor.getAttributes('link').href const url = window.prompt('URL', prev ?? 'https://') if (url === null) return if (url === '') { editor.chain().focus().extendMarkRange('link').unsetLink().run() } else { editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run() } }, [editor]) const addImage = useCallback(() => { const url = window.prompt('Image URL', 'https://') if (url) { editor.chain().focus().setImage({ src: url }).run() } }, [editor]) return (
editor.chain().focus().toggleBold().run()} active={editor.isActive('bold')} title="Bold (Ctrl+B)"> editor.chain().focus().toggleItalic().run()} active={editor.isActive('italic')} title="Italic (Ctrl+I)"> editor.chain().focus().toggleUnderline().run()} active={editor.isActive('underline')} title="Underline (Ctrl+U)"> editor.chain().focus().toggleStrike().run()} active={editor.isActive('strike')} title="Strikethrough"> editor.chain().focus().toggleHeading({ level: 2 }).run()} active={editor.isActive('heading', { level: 2 })} title="Heading 2"> H2 editor.chain().focus().toggleHeading({ level: 3 }).run()} active={editor.isActive('heading', { level: 3 })} title="Heading 3"> H3 editor.chain().focus().toggleBulletList().run()} active={editor.isActive('bulletList')} title="Bullet list"> editor.chain().focus().toggleOrderedList().run()} active={editor.isActive('orderedList')} title="Numbered list"> 123 editor.chain().focus().toggleBlockquote().run()} active={editor.isActive('blockquote')} title="Quote"> editor.chain().focus().toggleCodeBlock().run()} active={editor.isActive('codeBlock')} title="Code block"> editor.chain().focus().toggleCode().run()} active={editor.isActive('code')} title="Inline code"> {'{}'} editor.chain().focus().setHorizontalRule().run()} title="Horizontal rule"> editor.chain().focus().insertContent('@').run()} title="Mention a user (type @username)"> @ {advancedNews ? ( <> HTML DOM Art Social YT # ) : null}
editor.chain().focus().undo().run()} disabled={!editor.can().undo()} title="Undo (Ctrl+Z)"> editor.chain().focus().redo().run()} disabled={!editor.can().redo()} title="Redo (Ctrl+Shift+Z)">
) } export default function RichTextEditor({ content = '', onChange, placeholder = 'Write something…', error, minHeight = 12, autofocus = false, advancedNews = false, searchEntities = null, }) { const [sourceMode, setSourceMode] = useState(false) const [sourceValue, setSourceValue] = useState(String(content || '')) const [showStructureOutlines, setShowStructureOutlines] = useState(false) const [helperMessage, setHelperMessage] = useState('') const [artworkPickerOpen, setArtworkPickerOpen] = useState(false) const [artworkQuery, setArtworkQuery] = useState('') const [artworkResults, setArtworkResults] = useState([]) const [artworkLoading, setArtworkLoading] = useState(false) const extensions = useMemo(() => { const base = [ StarterKit.configure({ link: false, underline: false, heading: { levels: [2, 3] }, codeBlock: { HTMLAttributes: { class: 'forum-code-block' }, }, }), Underline, Link.configure({ openOnClick: false, HTMLAttributes: { class: 'text-sky-300 underline hover:text-sky-200', rel: 'noopener noreferrer nofollow', }, }), Image.configure({ HTMLAttributes: { class: 'rounded-lg max-w-full' }, }), Placeholder.configure({ placeholder }), Mention.configure({ HTMLAttributes: { class: 'mention', }, suggestion: mentionSuggestion, }), ] if (advancedNews) { base.push(ArtworkEmbed, VideoEmbed, SocialEmbed) } return base }, [advancedNews, placeholder]) const editor = useEditor({ extensions, immediatelyRender: false, content, autofocus, editorProps: { attributes: { class: [ 'prose prose-invert prose-sm max-w-none', 'focus:outline-none', 'px-4 py-3', 'prose-headings:text-white prose-headings:font-bold', 'prose-p:text-zinc-200 prose-p:leading-relaxed', 'prose-a:text-sky-300 prose-a:no-underline hover:prose-a:text-sky-200', 'prose-blockquote:border-l-sky-500/50 prose-blockquote:text-zinc-400', 'prose-code:text-amber-300 prose-code:bg-white/[0.06] prose-code:rounded prose-code:px-1.5 prose-code:py-0.5 prose-code:text-xs', 'prose-pre:bg-white/[0.04] prose-pre:border prose-pre:border-white/[0.06] prose-pre:rounded-xl', 'prose-img:rounded-xl', 'prose-hr:border-white/10', ].join(' '), style: `min-height: ${minHeight}rem`, }, }, onUpdate: ({ editor: currentEditor }) => { if (!sourceMode) { onChange?.(currentEditor.getHTML()) } }, }) useEffect(() => { if (!helperMessage) { return undefined } const timeout = window.setTimeout(() => setHelperMessage(''), 2200) return () => window.clearTimeout(timeout) }, [helperMessage]) useEffect(() => { if (!editor) return if (sourceMode) return if ((content || '') === editor.getHTML()) return editor.commands.setContent(content || '', false) const normalizedHtml = editor.getHTML() if (normalizedHtml !== (content || '')) { onChange?.(normalizedHtml) } }, [content, editor, onChange, sourceMode]) useEffect(() => { if (sourceMode) { setSourceValue(String(content || editor?.getHTML() || '')) } }, [content, editor, sourceMode]) const pushHelperMessage = useCallback((message) => { setHelperMessage(message) }, []) const handleToggleSourceMode = useCallback(() => { if (sourceMode) { setSourceMode(false) if (editor) { editor.commands.setContent(sourceValue || '', false) } pushHelperMessage('Returned to visual editor.') return } setSourceValue(editor?.getHTML() || String(content || '')) setSourceMode(true) }, [content, editor, pushHelperMessage, sourceMode, sourceValue]) const insertArtworkEmbed = useCallback((item) => { if (!editor || !item) return editor.chain().focus().insertContent({ type: 'artworkEmbed', attrs: { title: item.title || 'Embedded artwork', url: item.url || '#', thumb: item.image || item.avatar || '', }, }).run() }, [editor]) const runArtworkSearch = useCallback(async () => { if (typeof searchEntities !== 'function') { return } setArtworkLoading(true) try { const items = await searchEntities('artwork', artworkQuery) setArtworkResults(Array.isArray(items) ? items : []) } finally { setArtworkLoading(false) } }, [artworkQuery, searchEntities]) useEffect(() => { if (!artworkPickerOpen || typeof searchEntities !== 'function') { return } runArtworkSearch() }, [artworkPickerOpen, runArtworkSearch, searchEntities]) const handleInsertArtwork = useCallback(() => { if (!editor) return if (typeof searchEntities === 'function') { setArtworkPickerOpen(true) return } const url = normalizeHttpUrl(window.prompt('Artwork URL', 'https://skinbase.org/art/') || '') if (!url) { pushHelperMessage('Artwork URL is required.') return } const title = window.prompt('Artwork title', 'Embedded artwork') if (title === null) return const thumb = normalizeHttpUrl(window.prompt('Artwork thumbnail URL (optional)', '') || '') || '' insertArtworkEmbed({ title: title || 'Embedded artwork', url, image: thumb, }) }, [editor, insertArtworkEmbed, pushHelperMessage, searchEntities]) const handleInsertSocialEmbed = useCallback(() => { if (!editor) return const detected = detectSocialPlatform(window.prompt('Social post URL', 'https://') || '') if (!detected.url) { pushHelperMessage('Social post URL is required.') return } const label = window.prompt('Label (optional)', detected.label) if (label === null) return editor.chain().focus().insertContent({ type: 'socialEmbed', attrs: { url: detected.url, platform: detected.platform, label: label || detected.label, }, }).run() }, [editor, pushHelperMessage]) const handleInsertVideoEmbed = useCallback(() => { if (!editor) return const rawUrl = window.prompt('YouTube URL', 'https://www.youtube.com/watch?v=') || '' const embedUrl = normalizeVideoEmbedUrl(rawUrl) const sourceUrl = normalizeHttpUrl(rawUrl) if (!embedUrl || !sourceUrl) { pushHelperMessage('A valid YouTube URL is required.') return } const title = window.prompt('Video title (optional)', 'Embedded YouTube video') if (title === null) return editor.chain().focus().insertContent({ type: 'videoEmbed', attrs: { src: embedUrl, url: sourceUrl, title: title || 'Embedded YouTube video', }, }).run() }, [editor, pushHelperMessage]) const handleInsertHashtag = useCallback(() => { if (!editor) return const value = String(window.prompt('Hashtag', 'release') || '').trim().replace(/^#+/, '').replace(/\s+/g, '-') if (!value) return editor.chain().focus().insertContent(`#${value}`).run() }, [editor]) return (
setShowStructureOutlines((current) => !current)} onInsertArtwork={handleInsertArtwork} onInsertSocialEmbed={handleInsertSocialEmbed} onInsertVideoEmbed={handleInsertVideoEmbed} onInsertHashtag={handleInsertHashtag} /> {advancedNews && sourceMode ? (
Edit the stored article HTML directly. Saving while in this mode keeps the HTML exactly as written here.