categories v1 finished

This commit is contained in:
2026-03-17 20:13:33 +01:00
parent 7da0fd39f7
commit 1a62fcb81d
10 changed files with 807 additions and 63 deletions

View File

@@ -0,0 +1,456 @@
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 (
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
{Array.from({ length: 8 }).map((_, index) => (
<div key={index} className="aspect-[4/5] animate-pulse rounded-2xl border border-white/8 bg-white/[0.04]" />
))}
</div>
)
}
function EmptyState({ query }) {
return (
<div className="rounded-[28px] border border-dashed border-white/14 bg-black/20 px-6 py-14 text-center backdrop-blur-sm">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-white/35">No matching categories</p>
<h2 className="mt-3 text-2xl font-semibold tracking-[-0.03em] text-white">Nothing matched "{query}"</h2>
<p className="mx-auto mt-3 max-w-xl text-sm leading-7 text-white/58">
Try a shorter term or switch sorting to browse the full category directory again.
</p>
</div>
)
}
function ErrorState({ onRetry }) {
return (
<div className="rounded-[28px] border border-rose-400/20 bg-rose-500/8 px-6 py-14 text-center shadow-[0_30px_70px_rgba(0,0,0,0.2)]">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-rose-200/70">Unable to load categories</p>
<h2 className="mt-3 text-2xl font-semibold tracking-[-0.03em] text-white">The directory API did not respond cleanly.</h2>
<p className="mx-auto mt-3 max-w-xl text-sm leading-7 text-white/58">
Refresh the list and try again. If this persists, the API route or cache payload needs inspection.
</p>
<button
type="button"
onClick={onRetry}
className="mt-6 inline-flex items-center justify-center rounded-full border border-rose-300/35 bg-rose-400/12 px-5 py-3 text-sm font-semibold text-rose-100 transition hover:border-rose-200/55 hover:bg-rose-400/20"
>
Retry request
</button>
</div>
)
}
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 (
<div className="pb-24 text-white">
<section className="relative overflow-hidden">
<div className="absolute inset-x-0 top-0 h-[28rem] bg-[radial-gradient(circle_at_top_left,rgba(34,211,238,0.12),transparent_38%),radial-gradient(circle_at_top_right,rgba(249,115,22,0.14),transparent_34%)]" />
<div className="relative w-full px-6 pb-8 pt-14 sm:px-8 sm:pt-20 xl:px-10 2xl:px-14 lg:pt-24">
<div className="grid gap-8 lg:grid-cols-[minmax(0,1.2fr)_20rem] lg:items-end">
<div>
<div className="inline-flex rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-xs font-semibold uppercase tracking-[0.24em] text-white/50 backdrop-blur-sm">
Category directory
</div>
<h1 className="mt-5 max-w-4xl text-4xl font-semibold tracking-[-0.05em] text-white sm:text-5xl lg:text-6xl">
{pageTitle}
</h1>
<p className="mt-5 max-w-2xl text-base leading-8 text-white/62 sm:text-lg">
{pageDescription || 'Browse all wallpapers, skins, themes and digital art categories'}
</p>
</div>
<div className="grid gap-4 sm:grid-cols-3 lg:grid-cols-1">
<div className="rounded-[24px] border border-white/10 bg-black/22 p-5 backdrop-blur-md shadow-[0_24px_60px_rgba(0,0,0,0.24)]">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-white/40">Categories</p>
<p className="mt-3 text-3xl font-semibold tracking-[-0.04em] text-white">{numberFormatter.format(summary.total_categories)}</p>
</div>
<div className="rounded-[24px] border border-white/10 bg-black/22 p-5 backdrop-blur-md shadow-[0_24px_60px_rgba(0,0,0,0.24)]">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-white/40">Artworks indexed</p>
<p className="mt-3 text-3xl font-semibold tracking-[-0.04em] text-white">{numberFormatter.format(summary.total_artworks)}</p>
</div>
<div className="rounded-[24px] border border-white/10 bg-black/22 p-5 backdrop-blur-md shadow-[0_24px_60px_rgba(0,0,0,0.24)]">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-white/40">View</p>
<p className="mt-3 text-3xl font-semibold tracking-[-0.04em] text-white">Grid</p>
</div>
</div>
</div>
<div className="mt-10 rounded-[30px] border border-white/10 bg-black/25 p-4 shadow-[0_30px_80px_rgba(0,0,0,0.25)] backdrop-blur-xl sm:p-5">
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_16rem] lg:items-center">
<label className="relative block">
<span className="pointer-events-none absolute left-4 top-1/2 -translate-y-1/2 text-white/35">
<svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" className="h-5 w-5">
<path fillRule="evenodd" d="M8.5 3a5.5 5.5 0 1 0 3.473 9.765l3.63 3.63a.75.75 0 1 0 1.06-1.06l-3.63-3.63A5.5 5.5 0 0 0 8.5 3Zm-4 5.5a4 4 0 1 1 8 0 4 4 0 0 1-8 0Z" clipRule="evenodd" />
</svg>
</span>
<input
type="search"
value={searchQuery}
onChange={(event) => {
const value = event.target.value
startTransition(() => {
setSearchQuery(value)
setCurrentPage(1)
})
}}
placeholder="Search categories"
aria-label="Search categories"
className="h-14 w-full rounded-2xl border border-white/10 bg-white/[0.04] pl-12 pr-4 text-sm text-white placeholder:text-white/28 focus:border-cyan-300/45 focus:outline-none focus:ring-2 focus:ring-cyan-300/15"
/>
</label>
<label className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.2em] text-white/38">Sort by</span>
<select
value={sort}
onChange={(event) => {
setSort(event.target.value)
setCurrentPage(1)
}}
aria-label="Sort categories"
className="h-14 w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 text-sm text-white focus:border-orange-300/45 focus:outline-none focus:ring-2 focus:ring-orange-300/12"
>
{SORT_OPTIONS.map((option) => (
<option key={option.value} value={option.value} className="bg-slate-950 text-white">
{option.label}
</option>
))}
</select>
</label>
</div>
</div>
</div>
</section>
<section className="w-full px-6 sm:px-8 xl:px-10 2xl:px-14">
{!loading && !error && deferredQuery.trim() === '' && popularCategories.length > 0 && (
<div className="mb-10 rounded-[28px] border border-white/10 bg-white/[0.03] p-5 shadow-[0_24px_60px_rgba(0,0,0,0.18)] backdrop-blur-sm">
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-white/38">Popular categories</p>
<h2 className="mt-2 text-2xl font-semibold tracking-[-0.04em] text-white">Start with the busiest destinations</h2>
</div>
<div className="flex flex-wrap gap-3">
{popularCategories.map((category) => (
<a
key={category.id}
href={category.url}
className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-black/20 px-4 py-2 text-sm text-white/72 transition hover:border-white/20 hover:bg-white/[0.05] hover:text-white"
>
<span>{category.name}</span>
<span className="text-white/38">{numberFormatter.format(category.artwork_count)}</span>
</a>
))}
</div>
</div>
</div>
)}
<div className="mb-6 flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-white/38">Directory results</p>
<h2 className="mt-2 text-2xl font-semibold tracking-[-0.04em] text-white">
{numberFormatter.format(meta.total)} categories visible
</h2>
</div>
{!loading && !error && meta.total > 0 ? (
<p className="text-sm text-white/52">
Showing {numberFormatter.format(showingStart)} to {numberFormatter.format(showingEnd)} of {numberFormatter.format(meta.total)} categories.
</p>
) : (
<p className="text-sm text-white/52">
Browse all wallpapers, skins, themes and digital art categories.
</p>
)}
</div>
{loading && <LoadingGrid />}
{!loading && error && <ErrorState onRetry={handleRetry} />}
{!loading && !error && meta.total === 0 && <EmptyState query={deferredQuery} />}
{!loading && !error && meta.total > 0 && (
<>
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5">
{categories.map((category, index) => (
<CategoryCard key={category.id} category={category} index={index} />
))}
</div>
<div ref={sentinelRef} className="h-6 w-full" aria-hidden="true" />
{loadingMore && (
<div className="mt-6 flex items-center justify-center gap-3 rounded-2xl border border-white/8 bg-black/18 px-4 py-4 text-sm text-white/56 backdrop-blur-sm">
<span className="h-2.5 w-2.5 animate-pulse rounded-full bg-cyan-300" />
Loading more categories
</div>
)}
<div className="mt-10 flex flex-col items-center justify-center gap-3 rounded-[24px] border border-white/8 bg-black/18 px-4 py-5 backdrop-blur-sm">
<p className="text-sm text-white/46">
Loaded through page {numberFormatter.format(meta.current_page)} of {numberFormatter.format(meta.last_page)}
</p>
<Pagination meta={meta} onPageChange={handlePageChange} />
{hasMorePages && (
<p className="text-xs uppercase tracking-[0.2em] text-white/28">
Scroll to load the next page automatically
</p>
)}
</div>
</>
)}
</section>
</div>
)
}
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(<CategoriesPage {...props} />)
}
export default CategoriesPage

View File

@@ -0,0 +1,86 @@
import React from 'react'
const CONTENT_TYPE_STYLES = {
wallpapers: {
badge: 'from-cyan-400/90 to-sky-500/90',
overlay: 'from-sky-950/10 via-slate-950/12 to-slate-950/92',
glow: 'group-hover:shadow-[0_0_28px_rgba(34,211,238,0.18)]',
},
skins: {
badge: 'from-orange-400/90 to-amber-500/90',
overlay: 'from-orange-950/10 via-slate-950/12 to-slate-950/92',
glow: 'group-hover:shadow-[0_0_28px_rgba(251,146,60,0.18)]',
},
photography: {
badge: 'from-emerald-400/90 to-teal-500/90',
overlay: 'from-emerald-950/10 via-slate-950/12 to-slate-950/92',
glow: 'group-hover:shadow-[0_0_28px_rgba(16,185,129,0.18)]',
},
other: {
badge: 'from-fuchsia-400/90 to-rose-500/90',
overlay: 'from-rose-950/10 via-slate-950/12 to-slate-950/92',
glow: 'group-hover:shadow-[0_0_28px_rgba(244,114,182,0.18)]',
},
default: {
badge: 'from-cyan-400/90 to-orange-400/90',
overlay: 'from-slate-900/10 via-slate-950/12 to-slate-950/92',
glow: 'group-hover:shadow-[0_0_28px_rgba(125,211,252,0.16)]',
},
}
const countFormatter = new Intl.NumberFormat()
function formatArtworkCount(count) {
return `${countFormatter.format(Number(count || 0))} artworks`
}
export default function CategoryCard({ category, index = 0 }) {
const contentTypeSlug = category?.content_type?.slug || 'default'
const contentTypeName = category?.content_type?.name || 'Category'
const styles = CONTENT_TYPE_STYLES[contentTypeSlug] || CONTENT_TYPE_STYLES.default
return (
<a
href={category?.url || '/categories'}
aria-label={`Browse ${category?.name || 'category'} category`}
className={[
'group relative block cursor-pointer rounded-2xl overflow-hidden',
'transition duration-300 ease-out hover:-translate-y-1 hover:scale-[1.01]',
styles.glow,
].join(' ')}
style={{ animationDelay: `${Math.min(index, 8) * 60}ms` }}
>
<div className="relative aspect-[4/5] overflow-hidden rounded-2xl border border-white/10 bg-slate-950/80">
<img
src={category?.cover_image}
alt={`Cover artwork for ${category?.name || 'category'}`}
loading="lazy"
className="h-full w-full object-cover transition duration-500 group-hover:scale-110"
/>
<div className={`absolute inset-0 bg-gradient-to-b ${styles.overlay} transition duration-500 group-hover:from-black/20 group-hover:to-black/90`} />
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top,rgba(255,255,255,0.22),transparent_42%)] opacity-0 transition duration-500 group-hover:opacity-100" />
<div className="absolute inset-x-0 top-0 flex items-center justify-between gap-3 p-4">
<span className={`inline-flex rounded-full bg-gradient-to-r ${styles.badge} px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-950 shadow-[0_10px_24px_rgba(0,0,0,0.24)]`}>
{contentTypeName}
</span>
<span className="rounded-full border border-white/15 bg-black/25 px-3 py-1 text-[11px] font-medium uppercase tracking-[0.18em] text-white/78 backdrop-blur">
{formatArtworkCount(category?.artwork_count)}
</span>
</div>
<div className="absolute inset-x-0 bottom-0 p-4 sm:p-5">
<div className="rounded-[22px] border border-white/10 bg-black/30 p-4 backdrop-blur-md transition duration-300 group-hover:border-white/20 group-hover:bg-black/42">
<div className="mb-3 h-px w-14 bg-gradient-to-r from-white/70 to-transparent transition duration-300 group-hover:w-24" />
<h3 className="text-lg font-semibold tracking-[-0.02em] text-white sm:text-xl">
{category?.name}
</h3>
<p className="mt-2 text-sm leading-6 text-white/65">
Explore {category?.name} across wallpapers, skins, themes, and digital art collections.
</p>
</div>
</div>
</div>
</a>
)
}