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:
456
resources/js/components/Feed/PostComposer.jsx
Normal file
456
resources/js/components/Feed/PostComposer.jsx
Normal 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) }}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user