feat: forum rich-text editor, emoji picker, mentions, discover nav, feed, uploads, profile

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
This commit is contained in:
2026-03-03 09:48:31 +01:00
parent 1266f81d35
commit dc51d65440
178 changed files with 14308 additions and 665 deletions

View File

@@ -0,0 +1,316 @@
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>
)
}