Files
SkinbaseNova/resources/js/components/Feed/PostComposer.jsx
Gregor Klevze dc51d65440 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
2026-03-03 09:48:31 +01:00

457 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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) }}
/>
</>
)
}