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:
154
resources/js/Pages/Feed/FollowingFeed.jsx
Normal file
154
resources/js/Pages/Feed/FollowingFeed.jsx
Normal file
@@ -0,0 +1,154 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import { usePage } from '@inertiajs/react'
|
||||
import axios from 'axios'
|
||||
import PostCard from '../../Components/Feed/PostCard'
|
||||
import PostCardSkeleton from '../../Components/Feed/PostCardSkeleton'
|
||||
|
||||
const FILTER_OPTIONS = [
|
||||
{ value: 'all', label: 'All' },
|
||||
{ value: 'shares', label: 'Artwork Shares' },
|
||||
{ value: 'uploads', label: 'New Uploads' },
|
||||
{ value: 'text', label: 'Text Posts' },
|
||||
]
|
||||
|
||||
function EmptyFollowingState() {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-24 text-center">
|
||||
<div className="w-20 h-20 rounded-2xl bg-white/5 flex items-center justify-center mb-5 text-slate-600">
|
||||
<i className="fa-solid fa-users text-3xl" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-white/80 mb-2">Nothing here yet</h2>
|
||||
<p className="text-slate-500 text-sm max-w-sm leading-relaxed">
|
||||
Follow some creators to see their posts here. Discover amazing artwork on{' '}
|
||||
<a href="/discover/trending" className="text-sky-400 hover:underline">Trending</a>.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function FollowingFeed() {
|
||||
const { props } = usePage()
|
||||
const { auth } = props
|
||||
const authUser = auth?.user ?? null
|
||||
|
||||
const [posts, setPosts] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [page, setPage] = useState(1)
|
||||
const [hasMore, setHasMore] = useState(false)
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
const [filter, setFilter] = useState('all')
|
||||
|
||||
const fetchFeed = useCallback(async (p = 1, f = filter) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const { data } = await axios.get('/api/posts/following', {
|
||||
params: { page: p, filter: f },
|
||||
})
|
||||
setPosts((prev) => p === 1 ? data.data : [...prev, ...data.data])
|
||||
setHasMore(data.meta.current_page < data.meta.last_page)
|
||||
setPage(p)
|
||||
} catch {
|
||||
//
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setLoaded(true)
|
||||
}
|
||||
}, [filter])
|
||||
|
||||
useEffect(() => {
|
||||
fetchFeed(1, filter)
|
||||
}, [filter])
|
||||
|
||||
const handleFilterChange = (f) => {
|
||||
if (f === filter) return
|
||||
setFilter(f)
|
||||
setPosts([])
|
||||
setLoaded(false)
|
||||
setPage(1)
|
||||
}
|
||||
|
||||
const handleDeleted = useCallback((postId) => {
|
||||
setPosts((prev) => prev.filter((p) => p.id !== postId))
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#080f1e]">
|
||||
{/* ── Page header ────────────────────────────────────────────────────── */}
|
||||
<div className="max-w-2xl mx-auto px-4 pt-8 pb-4">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-white">
|
||||
<i className="fa-solid fa-users-rays mr-2 text-sky-400 opacity-80" />
|
||||
Following Feed
|
||||
</h1>
|
||||
<p className="text-sm text-slate-500 mt-0.5">Posts from creators you follow</p>
|
||||
</div>
|
||||
<a
|
||||
href="/discover/trending"
|
||||
className="text-xs text-sky-400 hover:text-sky-300 transition-colors"
|
||||
>
|
||||
Discover creators →
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Filter chips */}
|
||||
<div className="flex gap-2 overflow-x-auto pb-1 scrollbar-hide">
|
||||
{FILTER_OPTIONS.map((f) => (
|
||||
<button
|
||||
key={f.value}
|
||||
onClick={() => handleFilterChange(f.value)}
|
||||
className={`px-3.5 py-1.5 rounded-full text-xs font-medium whitespace-nowrap transition-all border ${
|
||||
filter === f.value
|
||||
? 'bg-sky-600/20 border-sky-500/40 text-sky-300'
|
||||
: 'bg-white/[0.03] border-white/[0.06] text-slate-400 hover:text-white hover:border-white/10'
|
||||
}`}
|
||||
>
|
||||
{f.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Feed ────────────────────────────────────────────────────────────── */}
|
||||
<div className="max-w-2xl mx-auto px-4 pb-16 space-y-4">
|
||||
{/* Loading skeletons */}
|
||||
{!loaded && loading && (
|
||||
<>
|
||||
<PostCardSkeleton />
|
||||
<PostCardSkeleton />
|
||||
<PostCardSkeleton />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Empty */}
|
||||
{loaded && !loading && posts.length === 0 && <EmptyFollowingState />}
|
||||
|
||||
{/* Posts */}
|
||||
{posts.map((post) => (
|
||||
<PostCard
|
||||
key={post.id}
|
||||
post={post}
|
||||
isLoggedIn={!!authUser}
|
||||
viewerUsername={authUser?.username ?? null}
|
||||
onDelete={handleDeleted}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Load more */}
|
||||
{loaded && hasMore && (
|
||||
<div className="flex justify-center py-4">
|
||||
<button
|
||||
onClick={() => fetchFeed(page + 1)}
|
||||
disabled={loading}
|
||||
className="px-6 py-2.5 rounded-xl bg-white/5 hover:bg-white/10 text-slate-300 text-sm transition-colors disabled:opacity-50"
|
||||
>
|
||||
{loading
|
||||
? <><i className="fa-solid fa-spinner fa-spin mr-2" />Loading…</>
|
||||
: 'Load more'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
114
resources/js/Pages/Feed/HashtagFeed.jsx
Normal file
114
resources/js/Pages/Feed/HashtagFeed.jsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import { usePage } from '@inertiajs/react'
|
||||
import axios from 'axios'
|
||||
import PostCard from '../../Components/Feed/PostCard'
|
||||
import PostCardSkeleton from '../../Components/Feed/PostCardSkeleton'
|
||||
|
||||
export default function HashtagFeed() {
|
||||
const { props } = usePage()
|
||||
const { auth, tag } = props
|
||||
const authUser = auth?.user ?? null
|
||||
|
||||
const [posts, setPosts] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [page, setPage] = useState(1)
|
||||
const [hasMore, setHasMore] = useState(false)
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
const [totalPosts, setTotalPosts] = useState(null)
|
||||
|
||||
const fetchFeed = useCallback(async (p = 1) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const { data } = await axios.get(`/api/feed/hashtag/${encodeURIComponent(tag)}`, {
|
||||
params: { page: p },
|
||||
})
|
||||
setPosts((prev) => p === 1 ? data.data : [...prev, ...data.data])
|
||||
setHasMore(data.meta.current_page < data.meta.last_page)
|
||||
setTotalPosts(data.meta.total ?? null)
|
||||
setPage(p)
|
||||
} catch {
|
||||
//
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setLoaded(true)
|
||||
}
|
||||
}, [tag])
|
||||
|
||||
useEffect(() => { fetchFeed(1) }, [tag])
|
||||
|
||||
const handleDeleted = useCallback((id) => setPosts((prev) => prev.filter((p) => p.id !== id)), [])
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#080f1e]">
|
||||
<div className="max-w-2xl mx-auto px-4 pt-8 pb-16">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<span className="inline-flex items-center justify-center w-10 h-10 rounded-xl bg-sky-500/15 text-sky-400 text-lg font-bold">
|
||||
#
|
||||
</span>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-white">#{tag}</h1>
|
||||
{totalPosts !== null && (
|
||||
<p className="text-sm text-slate-500 mt-0.5">
|
||||
{totalPosts.toLocaleString()} post{totalPosts !== 1 ? 's' : ''}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex gap-2">
|
||||
<a
|
||||
href="/feed/trending"
|
||||
className="text-xs text-slate-500 hover:text-sky-400 transition-colors"
|
||||
>
|
||||
← Trending
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Feed */}
|
||||
<div className="space-y-4">
|
||||
{!loaded && loading && (
|
||||
<>{Array.from({ length: 3 }).map((_, i) => <PostCardSkeleton key={i} />)}</>
|
||||
)}
|
||||
|
||||
{loaded && !loading && posts.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-24 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-hashtag text-2xl" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-white/80 mb-2">No posts yet</h2>
|
||||
<p className="text-slate-500 text-sm max-w-xs">
|
||||
No posts tagged <span className="text-sky-400">#{tag}</span> yet. Be the first!
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{posts.map((post) => (
|
||||
<PostCard
|
||||
key={post.id}
|
||||
post={post}
|
||||
isLoggedIn={!!authUser}
|
||||
viewerUsername={authUser?.username ?? null}
|
||||
onDelete={handleDeleted}
|
||||
/>
|
||||
))}
|
||||
|
||||
{loaded && hasMore && (
|
||||
<div className="flex justify-center py-4">
|
||||
<button
|
||||
onClick={() => fetchFeed(page + 1)}
|
||||
disabled={loading}
|
||||
className="px-6 py-2.5 rounded-xl bg-white/5 hover:bg-white/10 text-slate-300 text-sm transition-colors disabled:opacity-50"
|
||||
>
|
||||
{loading
|
||||
? <><i className="fa-solid fa-spinner fa-spin mr-2" />Loading…</>
|
||||
: 'Load more'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
105
resources/js/Pages/Feed/SavedFeed.jsx
Normal file
105
resources/js/Pages/Feed/SavedFeed.jsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import { usePage } from '@inertiajs/react'
|
||||
import axios from 'axios'
|
||||
import PostCard from '../../Components/Feed/PostCard'
|
||||
import PostCardSkeleton from '../../Components/Feed/PostCardSkeleton'
|
||||
|
||||
export default function SavedFeed() {
|
||||
const { props } = usePage()
|
||||
const { auth } = props
|
||||
const authUser = auth?.user ?? null
|
||||
|
||||
const [posts, setPosts] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [page, setPage] = useState(1)
|
||||
const [hasMore, setHasMore] = useState(false)
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
|
||||
const fetchFeed = useCallback(async (p = 1) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const { data } = await axios.get('/api/posts/saved', { params: { page: p } })
|
||||
setPosts((prev) => p === 1 ? data.data : [...prev, ...data.data])
|
||||
setHasMore(data.meta.current_page < data.meta.last_page)
|
||||
setPage(p)
|
||||
} catch {
|
||||
//
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setLoaded(true)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { fetchFeed(1) }, [])
|
||||
|
||||
const handleDeleted = useCallback((id) => setPosts((prev) => prev.filter((p) => p.id !== id)), [])
|
||||
|
||||
// When a post is unsaved, remove it from the list too
|
||||
const handleUnsaved = useCallback((id) => setPosts((prev) => prev.filter((p) => p.id !== id)), [])
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#080f1e]">
|
||||
<div className="max-w-2xl mx-auto px-4 pt-8 pb-16">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<h1 className="text-xl font-bold text-white">
|
||||
<i className="fa-solid fa-bookmark mr-2 text-amber-400 opacity-80" />
|
||||
Saved Posts
|
||||
</h1>
|
||||
<p className="text-sm text-slate-500 mt-0.5">Posts you've bookmarked</p>
|
||||
</div>
|
||||
|
||||
{/* Feed */}
|
||||
<div className="space-y-4">
|
||||
{!loaded && loading && (
|
||||
<>{Array.from({ length: 3 }).map((_, i) => <PostCardSkeleton key={i} />)}</>
|
||||
)}
|
||||
|
||||
{loaded && !loading && posts.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-24 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-bookmark text-2xl" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-white/80 mb-2">Nothing saved yet</h2>
|
||||
<p className="text-slate-500 text-sm max-w-xs leading-relaxed">
|
||||
Bookmark posts to read later. Look for the{' '}
|
||||
<i className="fa-regular fa-bookmark text-amber-400" /> icon on any post.
|
||||
</p>
|
||||
<a
|
||||
href="/feed/trending"
|
||||
className="mt-4 text-sm text-sky-400 hover:text-sky-300 transition-colors"
|
||||
>
|
||||
Browse trending posts →
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{posts.map((post) => (
|
||||
<PostCard
|
||||
key={post.id}
|
||||
post={post}
|
||||
isLoggedIn={!!authUser}
|
||||
viewerUsername={authUser?.username ?? null}
|
||||
onDelete={handleDeleted}
|
||||
onUnsaved={handleUnsaved}
|
||||
/>
|
||||
))}
|
||||
|
||||
{loaded && hasMore && (
|
||||
<div className="flex justify-center py-4">
|
||||
<button
|
||||
onClick={() => fetchFeed(page + 1)}
|
||||
disabled={loading}
|
||||
className="px-6 py-2.5 rounded-xl bg-white/5 hover:bg-white/10 text-slate-300 text-sm transition-colors disabled:opacity-50"
|
||||
>
|
||||
{loading
|
||||
? <><i className="fa-solid fa-spinner fa-spin mr-2" />Loading…</>
|
||||
: 'Load more'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
255
resources/js/Pages/Feed/SearchFeed.jsx
Normal file
255
resources/js/Pages/Feed/SearchFeed.jsx
Normal 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">“{query}”</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">“{query}”</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>
|
||||
)
|
||||
}
|
||||
133
resources/js/Pages/Feed/TrendingFeed.jsx
Normal file
133
resources/js/Pages/Feed/TrendingFeed.jsx
Normal file
@@ -0,0 +1,133 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import { usePage } from '@inertiajs/react'
|
||||
import axios from 'axios'
|
||||
import PostCard from '../../Components/Feed/PostCard'
|
||||
import PostCardSkeleton from '../../Components/Feed/PostCardSkeleton'
|
||||
|
||||
function TrendingHashtagsSidebar({ hashtags, activeTag = null }) {
|
||||
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.5">
|
||||
{hashtags.map((h) => (
|
||||
<a
|
||||
key={h.tag}
|
||||
href={`/tags/${h.tag}`}
|
||||
className={`flex items-center justify-between group px-2 py-1.5 rounded-lg transition-colors ${
|
||||
activeTag === h.tag
|
||||
? 'bg-sky-500/15 text-sky-400'
|
||||
: '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} posts
|
||||
</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function TrendingFeed() {
|
||||
const { props } = usePage()
|
||||
const { auth, trendingHashtags } = props
|
||||
const authUser = auth?.user ?? null
|
||||
|
||||
const [posts, setPosts] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [page, setPage] = useState(1)
|
||||
const [hasMore, setHasMore] = useState(false)
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
|
||||
const fetchFeed = useCallback(async (p = 1) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const { data } = await axios.get('/api/feed/trending', { params: { page: p } })
|
||||
setPosts((prev) => p === 1 ? data.data : [...prev, ...data.data])
|
||||
setHasMore(data.meta.current_page < data.meta.last_page)
|
||||
setPage(p)
|
||||
} catch {
|
||||
//
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setLoaded(true)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { fetchFeed(1) }, [])
|
||||
|
||||
const handleDeleted = useCallback((id) => setPosts((prev) => prev.filter((p) => p.id !== id)), [])
|
||||
|
||||
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 feed ──────────────────────────────────────────────── */}
|
||||
<div className="flex-1 min-w-0 space-y-4">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-xl font-bold text-white">
|
||||
<i className="fa-solid fa-fire mr-2 text-orange-400 opacity-80" />
|
||||
Trending
|
||||
</h1>
|
||||
<p className="text-sm text-slate-500 mt-0.5">Most engaging posts right now</p>
|
||||
</div>
|
||||
|
||||
{!loaded && loading && (
|
||||
<>{Array.from({ length: 3 }).map((_, i) => <PostCardSkeleton key={i} />)}</>
|
||||
)}
|
||||
|
||||
{loaded && !loading && posts.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-24 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-fire text-2xl" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-white/80 mb-2">Nothing trending yet</h2>
|
||||
<p className="text-slate-500 text-sm">Check back soon — posts are ranked by engagement.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{posts.map((post) => (
|
||||
<PostCard
|
||||
key={post.id}
|
||||
post={post}
|
||||
isLoggedIn={!!authUser}
|
||||
viewerUsername={authUser?.username ?? null}
|
||||
onDelete={handleDeleted}
|
||||
/>
|
||||
))}
|
||||
|
||||
{loaded && hasMore && (
|
||||
<div className="flex justify-center py-4">
|
||||
<button
|
||||
onClick={() => fetchFeed(page + 1)}
|
||||
disabled={loading}
|
||||
className="px-6 py-2.5 rounded-xl bg-white/5 hover:bg-white/10 text-slate-300 text-sm transition-colors disabled:opacity-50"
|
||||
>
|
||||
{loading
|
||||
? <><i className="fa-solid fa-spinner fa-spin mr-2" />Loading…</>
|
||||
: '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">
|
||||
Posts are ranked by likes, comments & engagement over the last 7 days.
|
||||
</p>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user