optimizations
This commit is contained in:
138
resources/js/components/profile/activity/ActivityTab.jsx
Normal file
138
resources/js/components/profile/activity/ActivityTab.jsx
Normal file
@@ -0,0 +1,138 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import ActivityFeed from './ActivityFeed'
|
||||
import ActivityFilters from './ActivityFilters'
|
||||
|
||||
function endpointForUser(user) {
|
||||
return `/api/profile/${encodeURIComponent(user.username || user.name || '')}/activity`
|
||||
}
|
||||
|
||||
export default function ActivityTab({ user }) {
|
||||
const [activeFilter, setActiveFilter] = useState('all')
|
||||
const [activities, setActivities] = useState([])
|
||||
const [meta, setMeta] = useState({ current_page: 1, has_more: false, total: 0 })
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [loadingMore, setLoadingMore] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const requestIdRef = useRef(0)
|
||||
const sentinelRef = useRef(null)
|
||||
|
||||
const fetchFeed = useCallback(async ({ filter, page, append }) => {
|
||||
const requestId = requestIdRef.current + 1
|
||||
requestIdRef.current = requestId
|
||||
|
||||
if (append) {
|
||||
setLoadingMore(true)
|
||||
} else {
|
||||
setLoading(true)
|
||||
}
|
||||
|
||||
try {
|
||||
setError('')
|
||||
const params = new URLSearchParams({
|
||||
filter,
|
||||
page: String(page),
|
||||
per_page: '20',
|
||||
})
|
||||
|
||||
const response = await fetch(`${endpointForUser(user)}?${params.toString()}`, {
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load profile activity.')
|
||||
}
|
||||
|
||||
const payload = await response.json()
|
||||
if (requestId !== requestIdRef.current) return
|
||||
|
||||
setActivities((current) => append ? [...current, ...(payload.data || [])] : (payload.data || []))
|
||||
setMeta(payload.meta || { current_page: page, has_more: false, total: 0 })
|
||||
} catch {
|
||||
if (requestId === requestIdRef.current) {
|
||||
setError('Could not load this activity timeline right now.')
|
||||
}
|
||||
} finally {
|
||||
if (requestId === requestIdRef.current) {
|
||||
setLoading(false)
|
||||
setLoadingMore(false)
|
||||
}
|
||||
}
|
||||
}, [user])
|
||||
|
||||
useEffect(() => {
|
||||
fetchFeed({ filter: activeFilter, page: 1, append: false })
|
||||
}, [activeFilter, fetchFeed])
|
||||
|
||||
const hasMore = Boolean(meta?.has_more)
|
||||
const nextPage = Number(meta?.current_page || 1) + 1
|
||||
|
||||
useEffect(() => {
|
||||
const sentinel = sentinelRef.current
|
||||
if (!sentinel || loading || loadingMore || !hasMore || !('IntersectionObserver' in window)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
const [entry] = entries
|
||||
if (entry?.isIntersecting) {
|
||||
fetchFeed({ filter: activeFilter, page: nextPage, append: true })
|
||||
}
|
||||
}, { rootMargin: '240px 0px' })
|
||||
|
||||
observer.observe(sentinel)
|
||||
return () => observer.disconnect()
|
||||
}, [activeFilter, fetchFeed, hasMore, loading, loadingMore, nextPage])
|
||||
|
||||
const summary = useMemo(() => {
|
||||
const total = Number(meta?.total || activities.length || 0)
|
||||
return total ? `${total.toLocaleString()} recent actions` : 'No recent actions'
|
||||
}, [activities.length, meta?.total])
|
||||
|
||||
return (
|
||||
<div
|
||||
id="tabpanel-activity"
|
||||
role="tabpanel"
|
||||
aria-labelledby="tab-activity"
|
||||
className="mx-auto max-w-7xl px-4 pt-4 pb-10 md:px-6"
|
||||
>
|
||||
<div className="rounded-[32px] border border-white/[0.06] bg-[linear-gradient(135deg,rgba(56,189,248,0.14),rgba(10,16,26,0.94),rgba(249,115,22,0.08))] p-5 shadow-[0_22px_70px_rgba(0,0,0,0.26)] md:p-6">
|
||||
<div className="flex flex-col gap-5 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div className="max-w-3xl">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-sky-200/75">Activity</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold tracking-[-0.03em] text-white md:text-3xl">Recent actions and contributions</h2>
|
||||
<p className="mt-3 text-sm leading-7 text-slate-300 md:text-[15px]">
|
||||
A living timeline of uploads, discussions, follows, achievements, and forum participation from {user.username || user.name}.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="self-start rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-300">
|
||||
{summary}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<ActivityFilters activeFilter={activeFilter} onChange={setActiveFilter} />
|
||||
<div className="flex flex-wrap gap-2 text-xs text-slate-400">
|
||||
<span className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-3 py-2">
|
||||
<i className="fa-solid fa-bolt text-sky-300" />
|
||||
Timeline updates automatically as new actions are logged
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<ActivityFeed
|
||||
activities={activities}
|
||||
loading={loading}
|
||||
loadingMore={loadingMore}
|
||||
error={error}
|
||||
sentinelRef={sentinelRef}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user