import React, { startTransition, useDeferredValue, useEffect, useRef, useState } from 'react'
import { createRoot } from 'react-dom/client'
import CategoryCard from '../components/category/CategoryCard'
import Pagination from '../components/forum/Pagination'
const SORT_OPTIONS = [
{ value: 'popular', label: 'Popular' },
{ value: 'az', label: 'A-Z' },
{ value: 'artworks', label: 'Most artworks' },
]
const PAGE_SIZE = 24
const numberFormatter = new Intl.NumberFormat()
function LoadingGrid() {
return (
{Array.from({ length: 8 }).map((_, index) => (
))}
)
}
function EmptyState({ query }) {
return (
No matching categories
Nothing matched "{query}"
Try a shorter term or switch sorting to browse the full category directory again.
)
}
function ErrorState({ onRetry }) {
return (
Unable to load categories
The directory API did not respond cleanly.
Refresh the list and try again. If this persists, the API route or cache payload needs inspection.
)
}
function getInitialPage() {
if (typeof window === 'undefined') {
return 1
}
const rawPage = Number(new URL(window.location.href).searchParams.get('page') || 1)
if (!Number.isFinite(rawPage) || rawPage < 1) {
return 1
}
return Math.floor(rawPage)
}
function getInitialSort() {
if (typeof window === 'undefined') {
return 'popular'
}
const sort = new URL(window.location.href).searchParams.get('sort') || 'popular'
return SORT_OPTIONS.some((option) => option.value === sort) ? sort : 'popular'
}
function getInitialSearchQuery() {
if (typeof window === 'undefined') {
return ''
}
return new URL(window.location.href).searchParams.get('q') || ''
}
function syncQueryState({ page, sort, query }) {
if (typeof window === 'undefined') {
return
}
const url = new URL(window.location.href)
if (page <= 1) {
url.searchParams.delete('page')
} else {
url.searchParams.set('page', String(page))
}
if (sort === 'popular') {
url.searchParams.delete('sort')
} else {
url.searchParams.set('sort', sort)
}
if (query.trim() === '') {
url.searchParams.delete('q')
} else {
url.searchParams.set('q', query)
}
window.history.replaceState({}, '', url.toString())
}
function CategoriesPage({ apiUrl = '/api/categories', pageTitle = 'Categories', pageDescription = '' }) {
const [categories, setCategories] = useState([])
const [popularCategories, setPopularCategories] = useState([])
const [meta, setMeta] = useState({ current_page: 1, last_page: 1, per_page: PAGE_SIZE, total: 0 })
const [summary, setSummary] = useState({ total_categories: 0, total_artworks: 0 })
const [loading, setLoading] = useState(true)
const [loadingMore, setLoadingMore] = useState(false)
const [error, setError] = useState(false)
const [searchQuery, setSearchQuery] = useState(() => getInitialSearchQuery())
const [sort, setSort] = useState(() => getInitialSort())
const [currentPage, setCurrentPage] = useState(() => getInitialPage())
const deferredQuery = useDeferredValue(searchQuery)
const sentinelRef = useRef(null)
const loadCategories = async ({ signal, page, query, activeSort, append = false }) => {
if (append) {
setLoadingMore(true)
} else {
setLoading(true)
}
setError(false)
try {
const params = new URLSearchParams({
page: String(page),
per_page: String(PAGE_SIZE),
sort: activeSort,
})
if (query.trim() !== '') {
params.set('q', query.trim())
}
const response = await fetch(`${apiUrl}?${params.toString()}`, {
headers: {
Accept: 'application/json',
'X-Requested-With': 'XMLHttpRequest',
},
credentials: 'same-origin',
signal,
})
if (!response.ok) {
throw new Error('Failed to load categories')
}
const payload = await response.json()
const nextCategories = Array.isArray(payload?.data) ? payload.data : []
setCategories((previous) => {
if (!append) {
return nextCategories
}
const seenIds = new Set(previous.map((category) => category.id))
const merged = [...previous]
nextCategories.forEach((category) => {
if (!seenIds.has(category.id)) {
merged.push(category)
}
})
return merged
})
setPopularCategories(Array.isArray(payload?.popular_categories) ? payload.popular_categories : [])
setMeta(payload?.meta || { current_page: 1, last_page: 1, per_page: PAGE_SIZE, total: 0 })
setSummary(payload?.summary || { total_categories: 0, total_artworks: 0 })
if ((payload?.meta?.current_page ?? page) !== currentPage) {
setCurrentPage(payload?.meta?.current_page ?? page)
}
} catch (requestError) {
if (requestError?.name !== 'AbortError') {
setError(true)
}
} finally {
if (!signal?.aborted || signal === undefined) {
setLoading(false)
setLoadingMore(false)
}
}
}
useEffect(() => {
const controller = new AbortController()
void loadCategories({
signal: controller.signal,
page: currentPage,
query: deferredQuery,
activeSort: sort,
append: false,
})
return () => controller.abort()
}, [apiUrl, deferredQuery, sort])
useEffect(() => {
syncQueryState({ page: currentPage, sort, query: deferredQuery })
}, [currentPage, deferredQuery, sort])
const handlePageChange = (page) => {
setCategories([])
setCurrentPage(page)
void loadCategories({
page,
query: deferredQuery,
activeSort: sort,
append: false,
})
if (typeof window !== 'undefined') {
window.scrollTo({ top: 0, behavior: 'smooth' })
}
}
useEffect(() => {
const sentinel = sentinelRef.current
const hasMore = meta.current_page < meta.last_page
if (!sentinel || loading || loadingMore || error || !hasMore) {
return undefined
}
const observer = new IntersectionObserver((entries) => {
const firstEntry = entries[0]
if (!firstEntry?.isIntersecting) {
return
}
const nextPage = meta.current_page + 1
setCurrentPage(nextPage)
void loadCategories({
page: nextPage,
query: deferredQuery,
activeSort: sort,
append: true,
})
}, { rootMargin: '320px 0px' })
observer.observe(sentinel)
return () => observer.disconnect()
}, [deferredQuery, error, loading, loadingMore, meta.current_page, meta.last_page, sort])
const handleRetry = () => {
void loadCategories({
page: currentPage,
query: deferredQuery,
activeSort: sort,
append: false,
})
}
const loadedCount = categories.length
const showingStart = loadedCount > 0 ? 1 : 0
const showingEnd = loadedCount
const hasMorePages = meta.current_page < meta.last_page
return (
Category directory
{pageTitle}
{pageDescription || 'Browse all wallpapers, skins, themes and digital art categories'}
Categories
{numberFormatter.format(summary.total_categories)}
Artworks indexed
{numberFormatter.format(summary.total_artworks)}
{!loading && !error && deferredQuery.trim() === '' && popularCategories.length > 0 && (
Popular categories
Start with the busiest destinations
)}
Directory results
{numberFormatter.format(meta.total)} categories visible
{!loading && !error && meta.total > 0 ? (
Showing {numberFormatter.format(showingStart)} to {numberFormatter.format(showingEnd)} of {numberFormatter.format(meta.total)} categories.
) : (
Browse all wallpapers, skins, themes and digital art categories.
)}
{loading && }
{!loading && error && }
{!loading && !error && meta.total === 0 && }
{!loading && !error && meta.total > 0 && (
<>
{categories.map((category, index) => (
))}
{loadingMore && (
Loading more categories
)}
Loaded through page {numberFormatter.format(meta.current_page)} of {numberFormatter.format(meta.last_page)}
{hasMorePages && (
Scroll to load the next page automatically
)}
>
)}
)
}
const mountElement = document.getElementById('categories-page-root')
if (mountElement) {
let props = {}
try {
const propsElement = document.getElementById('categories-page-props')
props = propsElement ? JSON.parse(propsElement.textContent || '{}') : {}
} catch {
props = {}
}
createRoot(mountElement).render()
}
export default CategoriesPage