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,456 @@
import React, { useState, useRef, useCallback, useEffect, lazy, Suspense } from 'react'
import axios from 'axios'
import ShareArtworkModal from './ShareArtworkModal'
import LinkPreviewCard from './LinkPreviewCard'
import TagPeopleModal from './TagPeopleModal'
// Lazy-load the heavy emoji picker only when first opened
// @emoji-mart/react only has a default export (the Picker); m.Picker is undefined
const EmojiPicker = lazy(() => import('@emoji-mart/react'))
const VISIBILITY_OPTIONS = [
{ value: 'public', icon: 'fa-globe', label: 'Public' },
{ value: 'followers', icon: 'fa-user-friends', label: 'Followers' },
{ value: 'private', icon: 'fa-lock', label: 'Private' },
]
const URL_RE = /https?:\/\/[^\s\])"'>]{4,}/gi
function extractFirstUrl(text) {
const m = text.match(URL_RE)
return m ? m[0].replace(/[.,;:!?)]+$/, '') : null
}
/**
* PostComposer
*
* Props:
* user object { id, username, name, avatar }
* onPosted function(newPost)
*/
export default function PostComposer({ user, onPosted }) {
const [expanded, setExpanded] = useState(false)
const [body, setBody] = useState('')
const [visibility, setVisibility] = useState('public')
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState(null)
const [shareModal, setShareModal] = useState(false)
const [linkPreview, setLinkPreview] = useState(null)
const [previewLoading, setPreviewLoading] = useState(false)
const [previewDismissed, setPreviewDismissed] = useState(false)
const [lastPreviewUrl, setLastPreviewUrl] = useState(null)
const [emojiOpen, setEmojiOpen] = useState(false)
const [emojiData, setEmojiData] = useState(null) // loaded lazily
const [tagModal, setTagModal] = useState(false)
const [taggedUsers, setTaggedUsers] = useState([]) // [{ id, username, name, avatar_url }]
const [scheduleOpen, setScheduleOpen] = useState(false)
const [scheduledAt, setScheduledAt] = useState('') // ISO datetime-local string
const textareaRef = useRef(null)
const debounceTimer = useRef(null)
const emojiWrapRef = useRef(null) // wraps button + popover for outside-click
// Load emoji-mart data lazily the first time the picker opens
const openEmojiPicker = useCallback(async () => {
if (!emojiData) {
const { default: data } = await import('@emoji-mart/data')
setEmojiData(data)
}
setEmojiOpen((v) => !v)
}, [emojiData])
// Close picker on outside click
useEffect(() => {
if (!emojiOpen) return
const handler = (e) => {
if (emojiWrapRef.current && !emojiWrapRef.current.contains(e.target)) {
setEmojiOpen(false)
}
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [emojiOpen])
// Insert emoji at current cursor position
const insertEmoji = useCallback((emoji) => {
const native = emoji.native ?? emoji.shortcodes ?? ''
const ta = textareaRef.current
if (!ta) {
setBody((b) => b + native)
return
}
const start = ta.selectionStart ?? body.length
const end = ta.selectionEnd ?? body.length
const next = body.slice(0, start) + native + body.slice(end)
setBody(next)
// Restore cursor after the inserted emoji
requestAnimationFrame(() => {
ta.focus()
const pos = start + native.length
ta.setSelectionRange(pos, pos)
})
setEmojiOpen(false)
}, [body])
const handleFocus = () => {
setExpanded(true)
setTimeout(() => textareaRef.current?.focus(), 50)
}
const fetchLinkPreview = useCallback(async (url) => {
setPreviewLoading(true)
try {
const { data } = await axios.get('/api/link-preview', { params: { url } })
if (data?.url) {
setLinkPreview(data)
}
} catch {
// silently ignore preview is optional
} finally {
setPreviewLoading(false)
}
}, [])
const handleBodyChange = (e) => {
const val = e.target.value
setBody(val)
// Detect URLs and auto-fetch preview (debounced)
clearTimeout(debounceTimer.current)
debounceTimer.current = setTimeout(() => {
const url = extractFirstUrl(val)
if (!url || previewDismissed) return
if (url === lastPreviewUrl) return
setLastPreviewUrl(url)
setLinkPreview(null)
fetchLinkPreview(url)
}, 700)
}
const handleDismissPreview = () => {
setLinkPreview(null)
setPreviewDismissed(true)
}
const resetComposer = () => {
setBody('')
setExpanded(false)
setLinkPreview(null)
setPreviewLoading(false)
setPreviewDismissed(false)
setLastPreviewUrl(null)
setEmojiOpen(false)
setTaggedUsers([])
setTagModal(false)
setScheduleOpen(false)
setScheduledAt('')
}
const handleSubmit = async (e) => {
e?.preventDefault()
if (!body.trim()) return
setSubmitting(true)
setError(null)
try {
const { data } = await axios.post('/api/posts', {
type: 'text',
visibility,
body,
link_preview: linkPreview ?? undefined,
tagged_users: taggedUsers.length > 0 ? taggedUsers.map(({ id, username, name }) => ({ id, username, name })) : undefined,
publish_at: scheduledAt || undefined,
})
onPosted?.(data.post)
resetComposer()
} catch (err) {
setError(err.response?.data?.message ?? 'Failed to post.')
} finally {
setSubmitting(false)
}
}
const handleShared = (newPost) => {
onPosted?.(newPost)
setShareModal(false)
}
const showPreview = (linkPreview || previewLoading) && !previewDismissed
return (
<>
<div className="rounded-2xl border border-white/[0.08] bg-white/[0.025] px-5 py-4">
{/* Collapsed: click-to-expand placeholder */}
{!expanded ? (
<div
onClick={handleFocus}
className="flex items-center gap-3 cursor-text"
role="button"
tabIndex={0}
onKeyDown={(e) => e.key === 'Enter' && handleFocus()}
aria-label="Create a post"
>
<img
src={user.avatar ?? '/images/avatar_default.webp'}
alt={user.name}
className="w-9 h-9 rounded-full object-cover ring-1 ring-white/10 shrink-0"
loading="lazy"
/>
<span className="text-sm text-slate-500 flex-1 bg-white/[0.04] rounded-xl px-4 py-2.5 hover:bg-white/[0.07] transition-colors">
What's on your mind, {user.name?.split(' ')[0] ?? user.username}?
</span>
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-3">
{/* Textarea */}
<div className="flex gap-3">
<a href={`/@${user.username}`} className="shrink-0" tabIndex={-1}>
<img
src={user.avatar ?? '/images/avatar_default.webp'}
alt={user.name}
className="w-9 h-9 rounded-full object-cover ring-1 ring-white/10 hover:ring-sky-500/40 transition-all mt-0.5"
loading="lazy"
/>
</a>
<div className="flex-1 min-w-0 flex flex-col gap-1.5">
{/* User identity byline */}
<div className="flex items-center gap-1.5">
<a
href={`/@${user.username}`}
className="text-sm font-semibold text-white/90 hover:text-sky-400 transition-colors leading-tight"
tabIndex={-1}
>
{user.name || `@${user.username}`}
</a>
<span className="text-xs text-slate-500 leading-tight">@{user.username}</span>
</div>
<textarea
ref={textareaRef}
value={body}
onChange={handleBodyChange}
maxLength={2000}
rows={3}
placeholder="What's on your mind?"
autoFocus
className="w-full bg-white/5 border border-white/10 rounded-xl px-3 py-2.5 text-sm text-white resize-none placeholder-slate-600 focus:outline-none focus:border-sky-500/50 transition-colors"
/>
</div>
</div>
{/* Tagged people pills */}
{taggedUsers.length > 0 && (
<div className="pl-12 flex flex-wrap gap-1.5 items-center">
<span className="text-xs text-slate-500">With:</span>
{taggedUsers.map((u) => (
<span key={u.id} className="flex items-center gap-1 px-2 py-0.5 bg-sky-500/10 border border-sky-500/20 rounded-full text-xs text-sky-400">
<img src={u.avatar_url ?? '/images/avatar_default.webp'} alt="" className="w-3.5 h-3.5 rounded-full object-cover" />
@{u.username}
<button
type="button"
onClick={() => setTaggedUsers((prev) => prev.filter((x) => x.id !== u.id))}
className="opacity-60 hover:opacity-100 ml-0.5"
>
<i className="fa-solid fa-xmark fa-xs" />
</button>
</span>
))}
</div>
)}
{/* Link preview */}
{showPreview && (
<div className="pl-12">
<LinkPreviewCard
preview={linkPreview}
loading={previewLoading && !linkPreview}
onDismiss={handleDismissPreview}
/>
</div>
)}
{/* Schedule date picker */}
{scheduleOpen && (
<div className="pl-12">
<div className="flex items-center gap-2.5 p-3 rounded-xl bg-violet-500/10 border border-violet-500/20">
<i className="fa-regular fa-calendar-plus text-violet-400 text-sm fa-fw shrink-0" />
<div className="flex-1">
<label className="block text-[11px] text-slate-400 mb-1">Publish on</label>
<input
type="datetime-local"
value={scheduledAt}
onChange={(e) => setScheduledAt(e.target.value)}
min={new Date(Date.now() + 60_000).toISOString().slice(0, 16)}
className="bg-transparent text-sm text-white border-none outline-none w-full [color-scheme:dark]"
/>
<p className="text-[10px] text-slate-500 mt-1">
{Intl.DateTimeFormat().resolvedOptions().timeZone}
</p>
</div>
{scheduledAt && (
<button
type="button"
onClick={() => setScheduledAt('')}
className="text-slate-500 hover:text-slate-300 transition-colors"
title="Clear"
>
<i className="fa-solid fa-xmark fa-sm" />
</button>
)}
</div>
</div>
)}
{/* Footer row */}
<div className="flex items-center gap-2 pl-12">
{/* Visibility selector */}
<div className="flex gap-1">
{VISIBILITY_OPTIONS.map((v) => (
<button
key={v.value}
type="button"
onClick={() => setVisibility(v.value)}
title={v.label}
className={`flex items-center gap-1 px-2.5 py-1.5 rounded-lg text-xs transition-all ${
visibility === v.value
? 'bg-sky-500/15 text-sky-400 border border-sky-500/30'
: 'text-slate-500 hover:text-white hover:bg-white/5'
}`}
>
<i className={`fa-solid ${v.icon} fa-fw`} />
{visibility === v.value && <span>{v.label}</span>}
</button>
))}
</div>
{/* Emoji picker trigger */}
<div ref={emojiWrapRef} className="relative">
<button
type="button"
onClick={openEmojiPicker}
title="Add emoji"
className={`flex items-center gap-1 px-2.5 py-1.5 rounded-lg text-xs transition-all ${
emojiOpen
? 'bg-amber-500/15 text-amber-400 border border-amber-500/30'
: 'text-slate-500 hover:text-white hover:bg-white/5'
}`}
>
<i className="fa-regular fa-face-smile fa-fw" />
</button>
{emojiOpen && (
<div className="absolute bottom-full mb-2 left-0 z-50 shadow-2xl">
<Suspense fallback={
<div className="w-[352px] h-[400px] rounded-2xl bg-[#10192e] border border-white/10 flex items-center justify-center text-slate-600">
<i className="fa-solid fa-spinner fa-spin text-xl" />
</div>
}>
{emojiData && (
<EmojiPicker
data={emojiData}
onEmojiSelect={insertEmoji}
theme="dark"
set="native"
previewPosition="none"
skinTonePosition="search"
navPosition="bottom"
perLine={9}
maxFrequentRows={2}
/>
)}
</Suspense>
</div>
)}
</div>
{/* Tag people button */}
<button
type="button"
onClick={() => setTagModal(true)}
title="Tag people"
className={`flex items-center gap-1 px-2.5 py-1.5 rounded-lg text-xs transition-all ${
taggedUsers.length > 0
? 'bg-sky-500/15 text-sky-400 border border-sky-500/30'
: 'text-slate-500 hover:text-white hover:bg-white/5'
}`}
>
<i className="fa-solid fa-user-tag fa-fw" />
{taggedUsers.length > 0 && <span>{taggedUsers.length}</span>}
</button>
{/* Schedule button */}
<button
type="button"
onClick={() => setScheduleOpen((v) => !v)}
title="Schedule post"
className={`flex items-center gap-1 px-2.5 py-1.5 rounded-lg text-xs transition-all ${
scheduleOpen || scheduledAt
? 'bg-violet-500/15 text-violet-400 border border-violet-500/30'
: 'text-slate-500 hover:text-white hover:bg-white/5'
}`}
>
<i className="fa-regular fa-clock fa-fw" />
{scheduledAt && <span className="max-w-[80px] truncate">{new Date(scheduledAt).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}</span>}
</button>
<div className="ml-auto flex items-center gap-2">
{/* Share artwork button */}
<button
type="button"
onClick={() => setShareModal(true)}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs text-slate-400 hover:text-sky-400 hover:bg-sky-500/10 transition-colors"
title="Share an artwork"
>
<i className="fa-solid fa-share-nodes fa-fw" />
Share artwork
</button>
{/* Cancel */}
<button
type="button"
onClick={resetComposer}
className="px-3 py-1.5 rounded-lg text-xs text-slate-400 hover:text-white hover:bg-white/5 transition-colors"
>
Cancel
</button>
{/* Post */}
<button
type="submit"
disabled={submitting || !body.trim()}
className={`px-4 py-1.5 rounded-xl disabled:opacity-40 disabled:cursor-not-allowed text-white text-xs font-medium transition-colors ${
scheduledAt ? 'bg-violet-600 hover:bg-violet-500' : 'bg-sky-600 hover:bg-sky-500'
}`}
>
{submitting ? 'Posting…' : scheduledAt ? 'Schedule' : 'Post'}
</button>
</div>
</div>
{/* Char count */}
{body.length > 1800 && (
<p className="text-right text-[10px] text-amber-400/70 pr-1">{body.length}/2000</p>
)}
{error && (
<p className="text-xs text-rose-400">{error}</p>
)}
</form>
)}
</div>
{/* Share artwork modal */}
<ShareArtworkModal
isOpen={shareModal}
onClose={() => setShareModal(false)}
onShared={handleShared}
/>
{/* Tag people modal */}
<TagPeopleModal
isOpen={tagModal}
onClose={() => setTagModal(false)}
selected={taggedUsers}
onConfirm={(users) => { setTaggedUsers(users); setTagModal(false) }}
/>
</>
)
}