Forum: - TipTap WYSIWYG editor with full toolbar - @emoji-mart/react emoji picker (consistent with tweets) - @mention autocomplete with user search API - Fix PHP 8.4 parse errors in Blade templates - Fix thread data display (paginator items) - Align forum page widths to max-w-5xl Discover: - Extract shared _nav.blade.php partial - Add missing nav links to for-you page - Add Following link for authenticated users Feed/Posts: - Post model, controllers, policies, migrations - Feed page components (PostComposer, FeedCard, etc) - Post reactions, comments, saves, reports, sharing - Scheduled publishing support - Link preview controller Profile: - Profile page components (ProfileHero, ProfileTabs) - Profile API controller Uploads: - Upload wizard enhancements - Scheduled publish picker - Studio status bar and readiness checklist
317 lines
12 KiB
JavaScript
317 lines
12 KiB
JavaScript
import React, { useCallback, useEffect, useState } from 'react'
|
||
import { useEditor, EditorContent } from '@tiptap/react'
|
||
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'
|
||
|
||
/* ─── Toolbar button ─── */
|
||
function ToolbarBtn({ onClick, active, disabled, title, children }) {
|
||
return (
|
||
<button
|
||
type="button"
|
||
onClick={onClick}
|
||
disabled={disabled}
|
||
title={title}
|
||
className={[
|
||
'inline-flex h-8 w-8 items-center justify-center rounded-lg text-sm transition-colors',
|
||
'focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-400',
|
||
active
|
||
? 'bg-sky-600/25 text-sky-300'
|
||
: 'text-zinc-400 hover:bg-white/[0.06] hover:text-zinc-200',
|
||
disabled && 'opacity-30 pointer-events-none',
|
||
].filter(Boolean).join(' ')}
|
||
>
|
||
{children}
|
||
</button>
|
||
)
|
||
}
|
||
|
||
function Divider() {
|
||
return <div className="mx-1 h-5 w-px bg-white/10" />
|
||
}
|
||
|
||
/* ─── Toolbar ─── */
|
||
function Toolbar({ editor }) {
|
||
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 (
|
||
<div className="flex flex-wrap items-center gap-0.5 border-b border-white/[0.06] px-2.5 py-2">
|
||
{/* Text formatting */}
|
||
<ToolbarBtn
|
||
onClick={() => editor.chain().focus().toggleBold().run()}
|
||
active={editor.isActive('bold')}
|
||
title="Bold (Ctrl+B)"
|
||
>
|
||
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor"><path d="M6 4h8a4 4 0 014 4 4 4 0 01-4 4H6zm0 8h9a4 4 0 014 4 4 4 0 01-4 4H6z"/></svg>
|
||
</ToolbarBtn>
|
||
|
||
<ToolbarBtn
|
||
onClick={() => editor.chain().focus().toggleItalic().run()}
|
||
active={editor.isActive('italic')}
|
||
title="Italic (Ctrl+I)"
|
||
>
|
||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="19" y1="4" x2="10" y2="4"/><line x1="14" y1="20" x2="5" y2="20"/><line x1="15" y1="4" x2="9" y2="20"/></svg>
|
||
</ToolbarBtn>
|
||
|
||
<ToolbarBtn
|
||
onClick={() => editor.chain().focus().toggleUnderline().run()}
|
||
active={editor.isActive('underline')}
|
||
title="Underline (Ctrl+U)"
|
||
>
|
||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M6 3v7a6 6 0 006 6 6 6 0 006-6V3"/><line x1="4" y1="21" x2="20" y2="21"/></svg>
|
||
</ToolbarBtn>
|
||
|
||
<ToolbarBtn
|
||
onClick={() => editor.chain().focus().toggleStrike().run()}
|
||
active={editor.isActive('strike')}
|
||
title="Strikethrough"
|
||
>
|
||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="4" y1="12" x2="20" y2="12"/><path d="M17.5 7.5c0-2-1.5-3.5-5.5-3.5S6.5 5.5 6.5 7.5c0 4 11 4 11 8 0 2-1.5 3.5-5.5 3.5s-5.5-1.5-5.5-3.5"/></svg>
|
||
</ToolbarBtn>
|
||
|
||
<Divider />
|
||
|
||
{/* Headings */}
|
||
<ToolbarBtn
|
||
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
|
||
active={editor.isActive('heading', { level: 2 })}
|
||
title="Heading 2"
|
||
>
|
||
<span className="text-xs font-bold">H2</span>
|
||
</ToolbarBtn>
|
||
|
||
<ToolbarBtn
|
||
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
|
||
active={editor.isActive('heading', { level: 3 })}
|
||
title="Heading 3"
|
||
>
|
||
<span className="text-xs font-bold">H3</span>
|
||
</ToolbarBtn>
|
||
|
||
<Divider />
|
||
|
||
{/* Lists */}
|
||
<ToolbarBtn
|
||
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
||
active={editor.isActive('bulletList')}
|
||
title="Bullet list"
|
||
>
|
||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="9" y1="6" x2="20" y2="6"/><line x1="9" y1="12" x2="20" y2="12"/><line x1="9" y1="18" x2="20" y2="18"/><circle cx="4.5" cy="6" r="1" fill="currentColor"/><circle cx="4.5" cy="12" r="1" fill="currentColor"/><circle cx="4.5" cy="18" r="1" fill="currentColor"/></svg>
|
||
</ToolbarBtn>
|
||
|
||
<ToolbarBtn
|
||
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
||
active={editor.isActive('orderedList')}
|
||
title="Numbered list"
|
||
>
|
||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="10" y1="6" x2="21" y2="6"/><line x1="10" y1="12" x2="21" y2="12"/><line x1="10" y1="18" x2="21" y2="18"/><text x="3" y="8" fontSize="7" fill="currentColor" stroke="none" fontFamily="sans-serif">1</text><text x="3" y="14" fontSize="7" fill="currentColor" stroke="none" fontFamily="sans-serif">2</text><text x="3" y="20" fontSize="7" fill="currentColor" stroke="none" fontFamily="sans-serif">3</text></svg>
|
||
</ToolbarBtn>
|
||
|
||
<Divider />
|
||
|
||
{/* Block elements */}
|
||
<ToolbarBtn
|
||
onClick={() => editor.chain().focus().toggleBlockquote().run()}
|
||
active={editor.isActive('blockquote')}
|
||
title="Quote"
|
||
>
|
||
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor"><path d="M4.583 17.321C3.553 16.227 3 15 3 13.011c0-3.5 2.457-6.637 6.03-8.188l.893 1.378c-3.335 1.804-3.987 4.145-4.247 5.621.537-.278 1.24-.375 1.929-.311C9.591 11.68 11 13.24 11 15.14c0 .94-.36 1.84-1.001 2.503A3.34 3.34 0 017.559 18.6a3.77 3.77 0 01-2.976-.879zm10.4 0C13.953 16.227 13.4 15 13.4 13.011c0-3.5 2.457-6.637 6.03-8.188l.893 1.378c-3.335 1.804-3.987 4.145-4.247 5.621.537-.278 1.24-.375 1.929-.311 1.986.169 3.395 1.729 3.395 3.629 0 .94-.36 1.84-1.001 2.503a3.34 3.34 0 01-2.44.957 3.77 3.77 0 01-2.976-.879z"/></svg>
|
||
</ToolbarBtn>
|
||
|
||
<ToolbarBtn
|
||
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
|
||
active={editor.isActive('codeBlock')}
|
||
title="Code block"
|
||
>
|
||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>
|
||
</ToolbarBtn>
|
||
|
||
<ToolbarBtn
|
||
onClick={() => editor.chain().focus().toggleCode().run()}
|
||
active={editor.isActive('code')}
|
||
title="Inline code"
|
||
>
|
||
<span className="font-mono text-[11px] font-bold">{'{}'}</span>
|
||
</ToolbarBtn>
|
||
|
||
<Divider />
|
||
|
||
{/* Link & Image */}
|
||
<ToolbarBtn
|
||
onClick={addLink}
|
||
active={editor.isActive('link')}
|
||
title="Link"
|
||
>
|
||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M10 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71"/></svg>
|
||
</ToolbarBtn>
|
||
|
||
<ToolbarBtn onClick={addImage} title="Insert image">
|
||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>
|
||
</ToolbarBtn>
|
||
|
||
<Divider />
|
||
|
||
{/* Horizontal rule */}
|
||
<ToolbarBtn
|
||
onClick={() => editor.chain().focus().setHorizontalRule().run()}
|
||
title="Horizontal rule"
|
||
>
|
||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="3" y1="12" x2="21" y2="12"/></svg>
|
||
</ToolbarBtn>
|
||
|
||
{/* Emoji picker */}
|
||
<EmojiPicker editor={editor} />
|
||
|
||
{/* Mention hint */}
|
||
<ToolbarBtn
|
||
onClick={() => editor.chain().focus().insertContent('@').run()}
|
||
title="Mention a user (type @username)"
|
||
>
|
||
<span className="text-xs font-bold">@</span>
|
||
</ToolbarBtn>
|
||
|
||
{/* Undo / Redo */}
|
||
<div className="ml-auto flex items-center gap-0.5">
|
||
<ToolbarBtn
|
||
onClick={() => editor.chain().focus().undo().run()}
|
||
disabled={!editor.can().undo()}
|
||
title="Undo (Ctrl+Z)"
|
||
>
|
||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 102.13-9.36L1 10"/></svg>
|
||
</ToolbarBtn>
|
||
|
||
<ToolbarBtn
|
||
onClick={() => editor.chain().focus().redo().run()}
|
||
disabled={!editor.can().redo()}
|
||
title="Redo (Ctrl+Shift+Z)"
|
||
>
|
||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 11-2.13-9.36L23 10"/></svg>
|
||
</ToolbarBtn>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/* ─── Main editor component ─── */
|
||
|
||
/**
|
||
* Rich text editor for forum posts & replies.
|
||
*
|
||
* @prop {string} content – initial HTML content
|
||
* @prop {function} onChange – called with HTML string on every change
|
||
* @prop {string} placeholder – placeholder text
|
||
* @prop {string} error – validation error message
|
||
* @prop {number} minHeight – min height in rem (default 12)
|
||
* @prop {boolean} autofocus – focus on mount
|
||
*/
|
||
export default function RichTextEditor({
|
||
content = '',
|
||
onChange,
|
||
placeholder = 'Write something…',
|
||
error,
|
||
minHeight = 12,
|
||
autofocus = false,
|
||
}) {
|
||
const editor = useEditor({
|
||
extensions: [
|
||
StarterKit.configure({
|
||
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,
|
||
}),
|
||
],
|
||
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: e }) => {
|
||
onChange?.(e.getHTML())
|
||
},
|
||
})
|
||
|
||
// Sync content from outside (e.g. prefill / quote)
|
||
useEffect(() => {
|
||
if (editor && content && !editor.getHTML().includes(content.slice(0, 30))) {
|
||
editor.commands.setContent(content, false)
|
||
}
|
||
}, [content]) // eslint-disable-line react-hooks/exhaustive-deps
|
||
|
||
return (
|
||
<div className="flex flex-col gap-1.5">
|
||
<div
|
||
className={[
|
||
'overflow-hidden rounded-xl border bg-white/[0.04] transition-colors',
|
||
error
|
||
? 'border-red-500/60 focus-within:border-red-500/70 focus-within:ring-2 focus-within:ring-red-500/30'
|
||
: 'border-white/12 hover:border-white/20 focus-within:border-sky-500/50 focus-within:ring-2 focus-within:ring-sky-500/20',
|
||
].join(' ')}
|
||
>
|
||
<Toolbar editor={editor} />
|
||
<EditorContent editor={editor} />
|
||
</div>
|
||
|
||
{error && (
|
||
<p role="alert" className="text-xs text-red-400">{error}</p>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|