221 lines
7.4 KiB
JavaScript
221 lines
7.4 KiB
JavaScript
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/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
|