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,255 @@
import React, { useState, useEffect, useCallback, useRef } from 'react'
import { usePage } from '@inertiajs/react'
import axios from 'axios'
import PostCard from '../../Components/Feed/PostCard'
import PostCardSkeleton from '../../Components/Feed/PostCardSkeleton'
/* ── Trending hashtags sidebar ─────────────────────────────────────────────── */
function TrendingHashtagsSidebar({ hashtags }) {
if (!hashtags || hashtags.length === 0) return null
return (
<div className="rounded-2xl border border-white/[0.07] bg-white/[0.03] overflow-hidden">
<div className="flex items-center gap-2 px-4 py-3 border-b border-white/[0.05]">
<i className="fa-solid fa-hashtag text-slate-500 fa-fw text-[13px]" />
<span className="text-[11px] font-semibold uppercase tracking-widest text-slate-500">
Trending Tags
</span>
</div>
<div className="px-4 py-3 space-y-1">
{hashtags.map((h) => (
<a
key={h.tag}
href={`/feed/search?q=%23${h.tag}`}
className="flex items-center justify-between group px-2 py-1.5 rounded-lg transition-colors hover:bg-white/5 text-slate-400 hover:text-white"
>
<span className="text-sm font-medium">#{h.tag}</span>
<span className="text-[11px] text-slate-600 group-hover:text-slate-500 tabular-nums">
{h.post_count}
</span>
</a>
))}
</div>
</div>
)
}
/* ── Main page ─────────────────────────────────────────────────────────────── */
export default function SearchFeed() {
const { props } = usePage()
const { auth, initialQuery, trendingHashtags } = props
const authUser = auth?.user ?? null
const [query, setQuery] = useState(initialQuery ?? '')
const [results, setResults] = useState([])
const [loading, setLoading] = useState(false)
const [searched, setSearched] = useState(false)
const [meta, setMeta] = useState(null)
const [page, setPage] = useState(1)
const debounceRef = useRef(null)
const inputRef = useRef(null)
/* ── Push query into URL without reload ──────────────────────────────────── */
const pushUrl = useCallback((q) => {
const url = q.trim()
? `/feed/search?q=${encodeURIComponent(q.trim())}`
: '/feed/search'
window.history.replaceState({}, '', url)
}, [])
/* ── Fetch results ───────────────────────────────────────────────────────── */
const fetchResults = useCallback(async (q, p = 1) => {
if (!q.trim() || q.trim().length < 2) {
setResults([])
setMeta(null)
setSearched(false)
return
}
setLoading(true)
try {
const { data } = await axios.get('/api/feed/search', {
params: { q: q.trim(), page: p },
})
setResults((prev) => p === 1 ? data.data : [...prev, ...data.data])
setMeta(data.meta)
setPage(p)
setSearched(true)
} catch {
//
} finally {
setLoading(false)
}
}, [])
/* ── Debounce typing ─────────────────────────────────────────────────────── */
const handleChange = useCallback((e) => {
const q = e.target.value
setQuery(q)
pushUrl(q)
clearTimeout(debounceRef.current)
debounceRef.current = setTimeout(() => {
fetchResults(q, 1)
}, 350)
}, [fetchResults, pushUrl])
const handleSubmit = useCallback((e) => {
e.preventDefault()
clearTimeout(debounceRef.current)
fetchResults(query, 1)
}, [fetchResults, query])
const handleClear = useCallback(() => {
setQuery('')
setResults([])
setMeta(null)
setSearched(false)
pushUrl('')
inputRef.current?.focus()
}, [pushUrl])
/* ── Run initial query if pre-filled from URL ────────────────────────────── */
useEffect(() => {
if (initialQuery?.trim().length >= 2) {
fetchResults(initialQuery.trim(), 1)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const handleDeleted = useCallback((id) => {
setResults((prev) => prev.filter((p) => p.id !== id))
}, [])
const hasMore = meta ? meta.current_page < meta.last_page : false
const noResults = searched && !loading && results.length === 0
const hasResults = results.length > 0
return (
<div className="min-h-screen bg-[#080f1e]">
<div className="max-w-5xl mx-auto px-4 pt-8 pb-16">
<div className="flex gap-8">
{/* ── Main ─────────────────────────────────────────────────────── */}
<div className="flex-1 min-w-0 space-y-4">
{/* Header */}
<div className="mb-5">
<h1 className="text-xl font-bold text-white">
<i className="fa-solid fa-magnifying-glass mr-2 text-slate-400/80" />
Search Posts
</h1>
<p className="text-sm text-slate-500 mt-0.5">
Search by keywords, hashtags, or phrases
</p>
</div>
{/* Search box */}
<form onSubmit={handleSubmit}>
<div className="relative">
<i className="fa-solid fa-magnifying-glass absolute left-4 top-1/2 -translate-y-1/2 text-slate-500 text-sm pointer-events-none" />
<input
ref={inputRef}
type="text"
autoFocus
value={query}
onChange={handleChange}
placeholder="Search posts…"
className="w-full bg-white/[0.05] border border-white/[0.08] rounded-xl pl-10 pr-10 py-3 text-sm text-white placeholder-slate-500 focus:outline-none focus:border-sky-500/40 focus:ring-1 focus:ring-sky-500/30 transition-colors"
/>
{query && (
<button
type="button"
onClick={handleClear}
className="absolute right-3 top-1/2 -translate-y-1/2 p-1 text-slate-500 hover:text-white transition-colors"
aria-label="Clear search"
>
<i className="fa-solid fa-xmark text-sm" />
</button>
)}
</div>
</form>
{/* Skeletons while first load */}
{loading && !hasResults && (
<>{Array.from({ length: 3 }).map((_, i) => <PostCardSkeleton key={i} />)}</>
)}
{/* Idle / too short */}
{!searched && !loading && (
<div className="flex flex-col items-center justify-center py-20 text-center">
<div className="w-16 h-16 rounded-2xl bg-white/5 flex items-center justify-center mb-4 text-slate-600">
<i className="fa-solid fa-magnifying-glass text-2xl" />
</div>
<p className="text-slate-500 text-sm">
Type at least 2 characters to search posts
</p>
</div>
)}
{/* No results */}
{noResults && (
<div className="flex flex-col items-center justify-center py-20 text-center">
<div className="w-16 h-16 rounded-2xl bg-white/5 flex items-center justify-center mb-4 text-slate-600">
<i className="fa-solid fa-face-rolling-eyes text-2xl" />
</div>
<h2 className="text-lg font-semibold text-white/80 mb-1">No results</h2>
<p className="text-slate-500 text-sm">
Nothing matched <span className="text-slate-300">&ldquo;{query}&rdquo;</span>.
Try different keywords or a hashtag.
</p>
</div>
)}
{/* Results meta */}
{hasResults && meta && (
<p className="text-[11px] text-slate-600 px-1">
{meta.total.toLocaleString()} result{meta.total !== 1 ? 's' : ''} for{' '}
<span className="text-slate-400">&ldquo;{query}&rdquo;</span>
</p>
)}
{/* Post cards */}
{results.map((post) => (
<PostCard
key={post.id}
post={post}
isLoggedIn={!!authUser}
viewerUsername={authUser?.username ?? null}
onDelete={handleDeleted}
/>
))}
{/* Loading more indicator */}
{loading && hasResults && (
<div className="flex justify-center py-4">
<i className="fa-solid fa-spinner fa-spin text-slate-500" />
</div>
)}
{/* Load more */}
{!loading && hasMore && (
<div className="flex justify-center py-4">
<button
onClick={() => fetchResults(query, page + 1)}
className="px-6 py-2.5 rounded-xl bg-white/5 hover:bg-white/10 text-slate-300 text-sm transition-colors"
>
Load more
</button>
</div>
)}
</div>
{/* ── Sidebar ──────────────────────────────────────────────────── */}
<aside className="hidden lg:block w-64 shrink-0 space-y-4 pt-14">
<TrendingHashtagsSidebar hashtags={trendingHashtags} />
<div className="rounded-2xl border border-white/[0.07] bg-white/[0.03] px-4 py-4 text-center">
<p className="text-xs text-slate-500 leading-relaxed">
Tip: search <span className="text-sky-400/80">#hashtag</span> to find
posts by topic.
</p>
</div>
</aside>
</div>
</div>
</div>
)
}