feat: add community activity feed and mentions
This commit is contained in:
220
resources/js/Pages/Community/CommunityActivityPage.jsx
Normal file
220
resources/js/Pages/Community/CommunityActivityPage.jsx
Normal file
@@ -0,0 +1,220 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import ActivityFeed from '../../components/community/ActivityFeed'
|
||||
|
||||
const FILTER_TABS = [
|
||||
{ key: 'all', label: 'All Activity' },
|
||||
{ key: 'comments', label: 'Comments' },
|
||||
{ key: 'replies', label: 'Replies' },
|
||||
{ key: 'following', label: 'Following', authRequired: true },
|
||||
{ key: 'my', label: 'My Activity', authRequired: true },
|
||||
]
|
||||
|
||||
function FilterPills({ activeFilter, isAuthenticated, onChange }) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{FILTER_TABS.map((tab) => {
|
||||
const disabled = tab.authRequired && !isAuthenticated
|
||||
const active = activeFilter === tab.key
|
||||
|
||||
return (
|
||||
<button
|
||||
key={tab.key}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => !disabled && onChange(tab.key)}
|
||||
className={[
|
||||
'rounded-full border px-4 py-2 text-sm font-medium transition-all',
|
||||
active
|
||||
? 'border-sky-400/30 bg-sky-500/14 text-sky-200 shadow-[0_0_0_1px_rgba(56,189,248,0.08)]'
|
||||
: 'border-white/[0.06] bg-white/[0.03] text-white/55 hover:border-white/15 hover:bg-white/[0.05] hover:text-white/85',
|
||||
disabled ? 'cursor-not-allowed opacity-35' : '',
|
||||
].join(' ')}
|
||||
title={disabled ? 'Log in to use this filter' : undefined}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function updateUrl(filter, userId) {
|
||||
const url = new URL(window.location.href)
|
||||
|
||||
if (filter && filter !== 'all') url.searchParams.set('filter', filter)
|
||||
else url.searchParams.delete('filter')
|
||||
|
||||
if (userId) url.searchParams.set('user_id', String(userId))
|
||||
else url.searchParams.delete('user_id')
|
||||
|
||||
window.history.replaceState({}, '', url.toString())
|
||||
}
|
||||
|
||||
function updateHeaderSummary(filter, userId) {
|
||||
const filterLabels = {
|
||||
all: 'All Activity',
|
||||
comments: 'Comments',
|
||||
replies: 'Replies',
|
||||
following: 'Following',
|
||||
my: 'My Activity',
|
||||
}
|
||||
|
||||
const filterNode = document.getElementById('community-activity-filter-summary')
|
||||
const scopeNode = document.getElementById('community-activity-scope-summary')
|
||||
|
||||
if (filterNode) {
|
||||
filterNode.innerHTML = `<i class="fa-solid fa-filter"></i> ${filterLabels[filter] || filterLabels.all}`
|
||||
}
|
||||
|
||||
if (scopeNode) {
|
||||
if (userId) {
|
||||
scopeNode.className = 'inline-flex items-center gap-1.5 rounded-full border border-white/[0.08] bg-white/[0.04] px-3 py-1 text-white/65'
|
||||
scopeNode.innerHTML = `<i class="fa-solid fa-user"></i> User #${userId}`
|
||||
} else {
|
||||
scopeNode.className = 'hidden'
|
||||
scopeNode.innerHTML = ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function CommunityActivityPage({
|
||||
initialActivities = [],
|
||||
initialMeta = {},
|
||||
initialFilter = 'all',
|
||||
initialUserId = null,
|
||||
isAuthenticated = false,
|
||||
}) {
|
||||
const [activeFilter, setActiveFilter] = useState(initialFilter)
|
||||
const [activities, setActivities] = useState(initialActivities)
|
||||
const [meta, setMeta] = useState(initialMeta)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [loadingMore, setLoadingMore] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
const sentinelRef = useRef(null)
|
||||
const requestIdRef = useRef(0)
|
||||
|
||||
const hasMore = Boolean(meta?.has_more)
|
||||
const nextPage = Number(meta?.current_page || 1) + 1
|
||||
|
||||
const fetchFeed = useCallback(async ({ filter, page, append }) => {
|
||||
const requestId = ++requestIdRef.current
|
||||
setError(null)
|
||||
if (append) setLoadingMore(true)
|
||||
else setLoading(true)
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({ filter, page: String(page) })
|
||||
if (initialUserId) params.set('user_id', String(initialUserId))
|
||||
|
||||
const response = await fetch(`/api/community/activity?${params.toString()}`, {
|
||||
headers: { Accept: 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
|
||||
credentials: 'same-origin',
|
||||
})
|
||||
|
||||
if (requestId !== requestIdRef.current) return
|
||||
|
||||
if (response.status === 401) {
|
||||
setError('Please log in to view this activity filter.')
|
||||
if (!append) {
|
||||
setActivities([])
|
||||
setMeta({ current_page: 1, last_page: 1, has_more: false, total: 0 })
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load community activity.')
|
||||
}
|
||||
|
||||
const payload = await response.json()
|
||||
setActivities((prev) => append ? [...prev, ...(payload.data || [])] : (payload.data || []))
|
||||
setMeta(payload.meta || {})
|
||||
} catch {
|
||||
if (requestId === requestIdRef.current) {
|
||||
setError('Failed to load community activity. Please try again.')
|
||||
}
|
||||
} finally {
|
||||
if (requestId === requestIdRef.current) {
|
||||
setLoading(false)
|
||||
setLoadingMore(false)
|
||||
}
|
||||
}
|
||||
}, [initialUserId])
|
||||
|
||||
const handleFilterChange = useCallback((nextFilter) => {
|
||||
if (nextFilter === activeFilter) return
|
||||
setActiveFilter(nextFilter)
|
||||
updateUrl(nextFilter, initialUserId)
|
||||
fetchFeed({ filter: nextFilter, page: 1, append: false })
|
||||
}, [activeFilter, fetchFeed, initialUserId])
|
||||
|
||||
useEffect(() => {
|
||||
updateHeaderSummary(activeFilter, initialUserId)
|
||||
}, [activeFilter, initialUserId])
|
||||
|
||||
useEffect(() => {
|
||||
const sentinel = sentinelRef.current
|
||||
if (!sentinel || loading || loadingMore || !hasMore) return undefined
|
||||
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
const [entry] = entries
|
||||
if (entry?.isIntersecting) {
|
||||
fetchFeed({ filter: activeFilter, page: nextPage, append: true })
|
||||
}
|
||||
}, { rootMargin: '220px 0px' })
|
||||
|
||||
observer.observe(sentinel)
|
||||
return () => observer.disconnect()
|
||||
}, [activeFilter, fetchFeed, hasMore, loading, loadingMore, nextPage])
|
||||
|
||||
const resultsLabel = useMemo(() => {
|
||||
const total = Number(meta?.total || activities.length || 0)
|
||||
if (!total) return 'No recent activity'
|
||||
return `${total.toLocaleString()} events`
|
||||
}, [activities.length, meta?.total])
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl px-6 pt-8 pb-20 md:px-10">
|
||||
<div className="mb-6 flex flex-col gap-4 rounded-[28px] border border-white/[0.06] bg-[linear-gradient(180deg,rgba(10,16,26,0.92),rgba(6,10,18,0.88))] p-5 shadow-[0_20px_60px_rgba(0,0,0,0.25)]">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-white/35">Live community pulse</p>
|
||||
<p className="mt-2 max-w-2xl text-sm leading-6 text-white/55">
|
||||
Comments, replies, reactions, and mentions from across Skinbase in one scrolling Nova feed.
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-sm font-medium text-white/45">{resultsLabel}</div>
|
||||
</div>
|
||||
|
||||
<FilterPills activeFilter={activeFilter} isAuthenticated={isAuthenticated} onChange={handleFilterChange} />
|
||||
</div>
|
||||
|
||||
<ActivityFeed
|
||||
activities={activities}
|
||||
isLoggedIn={isAuthenticated}
|
||||
loading={loading}
|
||||
loadingMore={loadingMore}
|
||||
error={error}
|
||||
sentinelRef={sentinelRef}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const mountEl = document.getElementById('community-activity-root')
|
||||
|
||||
if (mountEl) {
|
||||
let props = {}
|
||||
try {
|
||||
const propsEl = document.getElementById('community-activity-props')
|
||||
props = propsEl ? JSON.parse(propsEl.textContent || '{}') : {}
|
||||
} catch {
|
||||
props = {}
|
||||
}
|
||||
|
||||
createRoot(mountEl).render(<CommunityActivityPage {...props} />)
|
||||
}
|
||||
|
||||
export default CommunityActivityPage
|
||||
Reference in New Issue
Block a user