diff --git a/app/Http/Controllers/CategoryController.php b/app/Http/Controllers/CategoryController.php new file mode 100644 index 00000000..39452256 --- /dev/null +++ b/app/Http/Controllers/CategoryController.php @@ -0,0 +1,201 @@ +query('q', '')); + $sort = (string) $request->query('sort', 'popular'); + $page = max(1, (int) $request->query('page', 1)); + $perPage = min(60, max(12, (int) $request->query('per_page', 24))); + + $categories = collect(Cache::remember('categories.directory.v1', 3600, function (): array { + $publishedArtworkScope = DB::table('artwork_category as artwork_category') + ->join('artworks as artworks', 'artworks.id', '=', 'artwork_category.artwork_id') + ->leftJoin('artwork_stats as artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id') + ->whereColumn('artwork_category.category_id', 'categories.id') + ->where('artworks.is_public', true) + ->where('artworks.is_approved', true) + ->whereNull('artworks.deleted_at'); + + $categories = Category::query() + ->select([ + 'categories.id', + 'categories.content_type_id', + 'categories.parent_id', + 'categories.name', + 'categories.slug', + ]) + ->selectSub( + (clone $publishedArtworkScope)->selectRaw('COUNT(DISTINCT artworks.id)'), + 'artwork_count' + ) + ->selectSub( + (clone $publishedArtworkScope) + ->whereNotNull('artworks.hash') + ->whereNotNull('artworks.thumb_ext') + ->orderByDesc(DB::raw('COALESCE(artwork_stats.views, 0)')) + ->orderByDesc(DB::raw('COALESCE(artwork_stats.favorites, 0)')) + ->orderByDesc(DB::raw('COALESCE(artwork_stats.downloads, 0)')) + ->orderByDesc(DB::raw('COALESCE(artworks.published_at, artworks.created_at)')) + ->orderByDesc('artworks.id') + ->limit(1) + ->select('artworks.hash'), + 'cover_hash' + ) + ->selectSub( + (clone $publishedArtworkScope) + ->whereNotNull('artworks.hash') + ->whereNotNull('artworks.thumb_ext') + ->orderByDesc(DB::raw('COALESCE(artwork_stats.views, 0)')) + ->orderByDesc(DB::raw('COALESCE(artwork_stats.favorites, 0)')) + ->orderByDesc(DB::raw('COALESCE(artwork_stats.downloads, 0)')) + ->orderByDesc(DB::raw('COALESCE(artworks.published_at, artworks.created_at)')) + ->orderByDesc('artworks.id') + ->limit(1) + ->select('artworks.thumb_ext'), + 'cover_ext' + ) + ->selectSub( + (clone $publishedArtworkScope) + ->selectRaw('COALESCE(SUM(COALESCE(artwork_stats.views, 0) + (COALESCE(artwork_stats.favorites, 0) * 3) + (COALESCE(artwork_stats.downloads, 0) * 2)), 0)'), + 'popular_score' + ) + ->with(['contentType:id,name,slug']) + ->active() + ->orderBy('categories.name') + ->get(); + + return $this->transformCategories($categories); + })); + + $filtered = $this->filterAndSortCategories($categories, $search, $sort); + $total = $filtered->count(); + $lastPage = max(1, (int) ceil($total / $perPage)); + $currentPage = min($page, $lastPage); + $offset = ($currentPage - 1) * $perPage; + $pageItems = $filtered->slice($offset, $perPage)->values(); + $popularCategories = $this->filterAndSortCategories($categories, '', 'popular')->take(4)->values(); + + return response()->json([ + 'data' => $pageItems, + 'meta' => [ + 'current_page' => $currentPage, + 'last_page' => $lastPage, + 'per_page' => $perPage, + 'total' => $total, + ], + 'summary' => [ + 'total_categories' => $categories->count(), + 'total_artworks' => $categories->sum(fn (array $category): int => (int) ($category['artwork_count'] ?? 0)), + ], + 'popular_categories' => $search === '' ? $popularCategories : [], + ]); + } + + /** + * @param Collection> $categories + * @return Collection> + */ + private function filterAndSortCategories(Collection $categories, string $search, string $sort): Collection + { + $filtered = $categories; + + if ($search !== '') { + $needle = mb_strtolower($search); + + $filtered = $filtered->filter(function (array $category) use ($needle): bool { + return str_contains(mb_strtolower((string) ($category['name'] ?? '')), $needle); + }); + } + + return $filtered->sort(function (array $left, array $right) use ($sort): int { + if ($sort === 'az') { + return strcasecmp((string) ($left['name'] ?? ''), (string) ($right['name'] ?? '')); + } + + if ($sort === 'artworks') { + $countCompare = ((int) ($right['artwork_count'] ?? 0)) <=> ((int) ($left['artwork_count'] ?? 0)); + + return $countCompare !== 0 + ? $countCompare + : strcasecmp((string) ($left['name'] ?? ''), (string) ($right['name'] ?? '')); + } + + $scoreCompare = ((int) ($right['popular_score'] ?? 0)) <=> ((int) ($left['popular_score'] ?? 0)); + if ($scoreCompare !== 0) { + return $scoreCompare; + } + + $countCompare = ((int) ($right['artwork_count'] ?? 0)) <=> ((int) ($left['artwork_count'] ?? 0)); + if ($countCompare !== 0) { + return $countCompare; + } + + return strcasecmp((string) ($left['name'] ?? ''), (string) ($right['name'] ?? '')); + })->values(); + } + + /** + * @param Collection $categories + * @return array> + */ + private function transformCategories(Collection $categories): array + { + $categoryMap = $categories->keyBy('id'); + $pathCache = []; + + $buildPath = function (Category $category) use (&$buildPath, &$pathCache, $categoryMap): string { + if (isset($pathCache[$category->id])) { + return $pathCache[$category->id]; + } + + if ($category->parent_id && $categoryMap->has($category->parent_id)) { + $pathCache[$category->id] = $buildPath($categoryMap->get($category->parent_id)) . '/' . $category->slug; + + return $pathCache[$category->id]; + } + + $pathCache[$category->id] = $category->slug; + + return $pathCache[$category->id]; + }; + + return $categories + ->map(function (Category $category) use ($buildPath): array { + $contentTypeSlug = strtolower((string) ($category->contentType?->slug ?? 'categories')); + $path = $buildPath($category); + $coverImage = null; + + if (! empty($category->cover_hash) && ! empty($category->cover_ext)) { + $coverImage = ThumbnailService::fromHash((string) $category->cover_hash, (string) $category->cover_ext, 'md'); + } + + return [ + 'id' => (int) $category->id, + 'name' => (string) $category->name, + 'slug' => (string) $category->slug, + 'url' => '/' . $contentTypeSlug . '/' . $path, + 'content_type' => [ + 'name' => (string) ($category->contentType?->name ?? 'Categories'), + 'slug' => $contentTypeSlug, + ], + 'cover_image' => $coverImage ?: 'https://files.skinbase.org/default/missing_md.webp', + 'artwork_count' => (int) ($category->artwork_count ?? 0), + 'popular_score' => (int) ($category->popular_score ?? 0), + ]; + }) + ->values() + ->all(); + } +} diff --git a/app/Http/Controllers/Web/CategoryController.php b/app/Http/Controllers/Web/CategoryController.php index e75f2b58..ce5c8808 100644 --- a/app/Http/Controllers/Web/CategoryController.php +++ b/app/Http/Controllers/Web/CategoryController.php @@ -99,7 +99,25 @@ class CategoryController extends Controller public function browseCategories() { - $data = app(\App\Services\LegacyService::class)->browseCategories(); - return view('web.categories', $data); + $pageTitle = 'All Categories – Wallpapers, Skins & Digital Art | Skinbase'; + $pageDescription = 'Browse all categories on Skinbase including wallpapers, skins, themes, and digital art collections.'; + + return view('web.categories', [ + 'page_title' => $pageTitle, + 'page_meta_description' => $pageDescription, + 'page_canonical' => url('/categories'), + 'structured_data' => [ + '@context' => 'https://schema.org', + '@type' => 'CollectionPage', + 'name' => 'Categories', + 'description' => $pageDescription, + 'url' => url('/categories'), + 'isPartOf' => [ + '@type' => 'WebSite', + 'name' => 'Skinbase', + 'url' => url('/'), + ], + ], + ]); } } diff --git a/resources/js/Pages/CategoriesPage.jsx b/resources/js/Pages/CategoriesPage.jsx new file mode 100644 index 00000000..de56f1d8 --- /dev/null +++ b/resources/js/Pages/CategoriesPage.jsx @@ -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 ( +
+ {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)}

+
+
+

View

+

Grid

+
+
+
+ +
+
+ + + +
+
+
+
+ +
+ {!loading && !error && deferredQuery.trim() === '' && popularCategories.length > 0 && ( +
+
+
+

Popular categories

+

Start with the busiest destinations

+
+
+ {popularCategories.map((category) => ( + + {category.name} + {numberFormatter.format(category.artwork_count)} + + ))} +
+
+
+ )} + +
+
+

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) => ( + + ))} +
+ +
+
+ ) +} + +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 diff --git a/resources/js/components/category/CategoryCard.jsx b/resources/js/components/category/CategoryCard.jsx new file mode 100644 index 00000000..e27b9eca --- /dev/null +++ b/resources/js/components/category/CategoryCard.jsx @@ -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 ( + +
+ {`Cover + +
+
+
+ + {contentTypeName} + + + {formatArtworkCount(category?.artwork_count)} + +
+ +
+
+
+

+ {category?.name} +

+

+ Explore {category?.name} across wallpapers, skins, themes, and digital art collections. +

+
+
+
+
+ ) +} diff --git a/resources/views/layouts/nova/toolbar.blade.php b/resources/views/layouts/nova/toolbar.blade.php index 7fb9e1d8..6c5e12ee 100644 --- a/resources/views/layouts/nova/toolbar.blade.php +++ b/resources/views/layouts/nova/toolbar.blade.php @@ -94,6 +94,9 @@ Other + + Categories + Tags diff --git a/resources/views/web/categories.blade.php b/resources/views/web/categories.blade.php index d6b9db59..12cfd1a9 100644 --- a/resources/views/web/categories.blade.php +++ b/resources/views/web/categories.blade.php @@ -1,62 +1,36 @@ @extends('layouts.nova') +@push('head') + + + + + + + @if(!empty($structured_data ?? null)) + + @endif +@endpush + +@section('main-class', '') + @section('content') -@php - $contentTypes = $contentTypes ?? collect([ - (object) [ - 'name' => 'Categories', - 'description' => null, - 'roots' => $categories ?? collect(), - ], - ]); - $subgroups = $subgroups ?? collect(); -@endphp -
- + @vite(['resources/js/Pages/CategoriesPage.jsx']) @endsection diff --git a/routes/api.php b/routes/api.php index 12925d5e..7c29c622 100644 --- a/routes/api.php +++ b/routes/api.php @@ -3,6 +3,10 @@ use Illuminate\Support\Facades\Route; use App\Http\Controllers\DashboardController; +Route::middleware(['web', 'throttle:60,1']) + ->get('categories', [\App\Http\Controllers\CategoryController::class, 'index']) + ->name('api.categories.index'); + Route::middleware(['web', 'auth'])->prefix('dashboard')->name('api.dashboard.')->group(function () { Route::get('activity', [DashboardController::class, 'activity'])->name('activity'); Route::get('analytics', [DashboardController::class, 'analytics'])->name('analytics'); diff --git a/routes/legacy.php b/routes/legacy.php index 17b965fc..6051f64f 100644 --- a/routes/legacy.php +++ b/routes/legacy.php @@ -14,7 +14,6 @@ use Illuminate\Support\Facades\Route; //use App\Http\Controllers\Web\ArtController; use App\Http\Controllers\Legacy\AvatarController; -use App\Http\Controllers\Web\CategoryController; use App\Http\Controllers\Web\FeaturedArtworksController; use App\Http\Controllers\Web\DailyUploadsController; use App\Http\Controllers\Community\ChatController; @@ -28,7 +27,6 @@ use App\Http\Controllers\User\MonthlyCommentatorsController; use App\Http\Controllers\User\MembersController; use App\Http\Controllers\User\StatisticsController; use App\Http\Controllers\User\ProfileController; -use App\Http\Controllers\Web\BrowseCategoriesController; use App\Http\Controllers\Web\BrowseGalleryController; use App\Http\Controllers\Web\GalleryController; use App\Http\Controllers\Web\RssFeedController; @@ -44,9 +42,8 @@ Route::get('/avatar/{id}/{name?}', [AvatarController::class, 'show']) //Route::match(['get','post'], '/art/{id}/comment', [ArtController::class, 'show'])->where('id', '\d+'); // ── CATEGORIES / SECTIONS ───────────────────────────────────────────────────── -Route::get('/categories', [CategoryController::class, 'index'])->name('legacy.categories'); -Route::get('/sections', [\App\Http\Controllers\Web\SectionsController::class, 'index'])->name('sections'); -Route::get('/browse-categories', [BrowseCategoriesController::class, 'index'])->name('browse.categories'); +Route::redirect('/sections', '/categories', 301)->name('sections'); +Route::redirect('/browse-categories', '/categories', 301)->name('browse.categories'); // Legacy category URL pattern: /category/group/slug/id Route::get('/category/{group}/{slug?}/{id?}', [BrowseGalleryController::class, 'legacyCategory']) diff --git a/routes/web.php b/routes/web.php index 6b23cb7e..ed8f48de 100644 --- a/routes/web.php +++ b/routes/web.php @@ -18,6 +18,7 @@ use App\Http\Controllers\Web\FooterController; use App\Http\Controllers\Web\StaffController; use App\Http\Controllers\Web\RssFeedController; use App\Http\Controllers\Web\ApplicationController; +use App\Http\Controllers\Web\CategoryController; use App\Http\Controllers\News\NewsController as FrontendNewsController; use App\Http\Controllers\News\NewsRssController; use App\Http\Controllers\RSS\GlobalFeedController; @@ -183,6 +184,9 @@ Route::get('/tags/{tag}', [\App\Http\Controllers\Web\Posts\HashtagFeedController ->where('tag', '[A-Za-z][A-Za-z0-9_]{1,63}') ->name('feed.hashtag'); +// ── CATEGORIES DIRECTORY ───────────────────────────────────────────────────── +Route::get('/categories', [CategoryController::class, 'index'])->name('categories.index'); + // ── FOLLOWING (shortcut) ────────────────────────────────────────────────────── Route::middleware('auth')->get('/following', function () { return redirect()->route('dashboard.following'); diff --git a/vite.config.mjs b/vite.config.mjs index 4b93aac9..d0190fd9 100644 --- a/vite.config.mjs +++ b/vite.config.mjs @@ -18,6 +18,7 @@ export default defineConfig({ 'resources/js/studio.jsx', 'resources/js/dashboard/index.jsx', 'resources/js/Pages/ArtworkPage.jsx', + 'resources/js/Pages/CategoriesPage.jsx', 'resources/js/Pages/Home/HomePage.jsx', 'resources/js/Pages/Community/LatestCommentsPage.jsx', 'resources/js/Pages/Community/CommunityActivityPage.jsx',