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,76 @@
import React, { forwardRef, useEffect, useImperativeHandle, useState } from 'react'
/**
* Dropdown list rendered by TipTap's mention suggestion.
* Receives `items` (user objects) and keyboard nav commands via ref.
*/
const MentionList = forwardRef(function MentionList({ items, command }, ref) {
const [selectedIndex, setSelectedIndex] = useState(0)
// Reset selection when items change
useEffect(() => setSelectedIndex(0), [items])
// Expose keyboard handler to TipTap suggestion plugin
useImperativeHandle(ref, () => ({
onKeyDown: ({ event }) => {
if (event.key === 'ArrowUp') {
setSelectedIndex((i) => (i + items.length - 1) % items.length)
return true
}
if (event.key === 'ArrowDown') {
setSelectedIndex((i) => (i + 1) % items.length)
return true
}
if (event.key === 'Enter') {
selectItem(selectedIndex)
return true
}
return false
},
}))
const selectItem = (index) => {
const item = items[index]
if (item) {
command({ id: item.username, label: item.username })
}
}
if (!items.length) {
return (
<div className="rounded-xl border border-white/[0.08] bg-nova-800 p-3 shadow-xl backdrop-blur">
<p className="text-xs text-zinc-500">No users found</p>
</div>
)
}
return (
<div className="min-w-[200px] overflow-hidden rounded-xl border border-white/[0.08] bg-nova-800 shadow-xl backdrop-blur">
{items.map((item, index) => (
<button
key={item.id}
type="button"
onClick={() => selectItem(index)}
className={[
'flex w-full items-center gap-2.5 px-3 py-2 text-left text-sm transition-colors',
index === selectedIndex
? 'bg-sky-600/20 text-white'
: 'text-zinc-300 hover:bg-white/[0.04]',
].join(' ')}
>
<img
src={item.avatar_url}
alt=""
className="h-6 w-6 rounded-full border border-white/10 object-cover"
/>
<span className="truncate font-medium">@{item.username}</span>
{item.name && item.name !== item.username && (
<span className="truncate text-xs text-zinc-500">{item.name}</span>
)}
</button>
))}
</div>
)
})
export default MentionList