categories v1 finished
This commit is contained in:
201
app/Http/Controllers/CategoryController.php
Normal file
201
app/Http/Controllers/CategoryController.php
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\Category;
|
||||||
|
use App\Services\ThumbnailService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
class CategoryController extends Controller
|
||||||
|
{
|
||||||
|
public function index(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$search = trim((string) $request->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<int, array<string, mixed>> $categories
|
||||||
|
* @return Collection<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
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<int, Category> $categories
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -99,7 +99,25 @@ class CategoryController extends Controller
|
|||||||
|
|
||||||
public function browseCategories()
|
public function browseCategories()
|
||||||
{
|
{
|
||||||
$data = app(\App\Services\LegacyService::class)->browseCategories();
|
$pageTitle = 'All Categories – Wallpapers, Skins & Digital Art | Skinbase';
|
||||||
return view('web.categories', $data);
|
$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('/'),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
456
resources/js/Pages/CategoriesPage.jsx
Normal file
456
resources/js/Pages/CategoriesPage.jsx
Normal 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
|
||||||
86
resources/js/components/category/CategoryCard.jsx
Normal file
86
resources/js/components/category/CategoryCard.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -94,6 +94,9 @@
|
|||||||
<a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="/other">
|
<a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="/other">
|
||||||
<i class="fa-solid fa-folder-open w-4 text-center text-sb-muted"></i>Other
|
<i class="fa-solid fa-folder-open w-4 text-center text-sb-muted"></i>Other
|
||||||
</a>
|
</a>
|
||||||
|
<a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="{{ route('categories.index') }}">
|
||||||
|
<i class="fa-solid fa-folder-open w-4 text-center text-sb-muted"></i>Categories
|
||||||
|
</a>
|
||||||
<a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="/tags">
|
<a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="/tags">
|
||||||
<i class="fa-solid fa-tags w-4 text-center text-sb-muted"></i>Tags
|
<i class="fa-solid fa-tags w-4 text-center text-sb-muted"></i>Tags
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -1,62 +1,36 @@
|
|||||||
@extends('layouts.nova')
|
@extends('layouts.nova')
|
||||||
|
|
||||||
|
@push('head')
|
||||||
|
<link rel="canonical" href="{{ $page_canonical ?? url('/categories') }}">
|
||||||
|
<meta property="og:type" content="website">
|
||||||
|
<meta property="og:site_name" content="Skinbase">
|
||||||
|
<meta property="og:title" content="{{ $page_title ?? 'Categories' }}">
|
||||||
|
<meta property="og:description" content="{{ $page_meta_description ?? '' }}">
|
||||||
|
<meta property="og:url" content="{{ $page_canonical ?? url('/categories') }}">
|
||||||
|
@if(!empty($structured_data ?? null))
|
||||||
|
<script type="application/ld+json">{!! json_encode($structured_data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_HEX_TAG) !!}</script>
|
||||||
|
@endif
|
||||||
|
@endpush
|
||||||
|
|
||||||
|
@section('main-class', '')
|
||||||
|
|
||||||
@section('content')
|
@section('content')
|
||||||
@php
|
<script id="categories-page-props" type="application/json">
|
||||||
$contentTypes = $contentTypes ?? collect([
|
{!! json_encode([
|
||||||
(object) [
|
'apiUrl' => route('api.categories.index'),
|
||||||
'name' => 'Categories',
|
'pageTitle' => $page_title ?? 'Categories',
|
||||||
'description' => null,
|
'pageDescription' => $page_meta_description ?? null,
|
||||||
'roots' => $categories ?? collect(),
|
], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP) !!}
|
||||||
],
|
</script>
|
||||||
]);
|
|
||||||
$subgroups = $subgroups ?? collect();
|
<div id="categories-page-root" class="min-h-screen bg-[radial-gradient(circle_at_top,rgba(34,211,238,0.14),transparent_28%),radial-gradient(circle_at_80%_20%,rgba(249,115,22,0.16),transparent_30%),linear-gradient(180deg,#050b13_0%,#09111c_42%,#050913_100%)]">
|
||||||
@endphp
|
<div class="mx-auto flex min-h-[60vh] max-w-7xl items-center justify-center px-6 py-20">
|
||||||
<div class="container-fluid legacy-page">
|
<div class="flex items-center gap-3 rounded-full border border-white/10 bg-white/[0.04] px-5 py-3 text-sm text-white/70 shadow-[0_18px_60px_rgba(0,0,0,0.28)] backdrop-blur">
|
||||||
<div class="effect2 page-header-wrap">
|
<span class="h-2.5 w-2.5 animate-pulse rounded-full bg-cyan-300"></span>
|
||||||
<header class="page-heading">
|
Loading categories
|
||||||
<h1 class="page-header">Browse Categories</h1>
|
</div>
|
||||||
<p>Select a category to view its artworks.</p>
|
</div>
|
||||||
</header>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row">
|
@vite(['resources/js/Pages/CategoriesPage.jsx'])
|
||||||
@forelse ($contentTypes as $ct)
|
|
||||||
<div class="col-sm-12">
|
|
||||||
<div class="panel panel-default effect2">
|
|
||||||
<div class="panel-heading"><strong>{{ $ct->name }}</strong></div>
|
|
||||||
<div class="panel-body">
|
|
||||||
<p>{!! $ct->description ?? 'Browse artworks by content type.' !!}</p>
|
|
||||||
|
|
||||||
@forelse ($ct->roots as $cat)
|
|
||||||
@php
|
|
||||||
$name = $cat->category_name ?? '';
|
|
||||||
$subs = $subgroups[$cat->category_id] ?? collect();
|
|
||||||
@endphp
|
|
||||||
<div class="legacy-root-category">
|
|
||||||
<h4>{{ $name }}</h4>
|
|
||||||
<ul class="browseList">
|
|
||||||
@forelse ($subs as $sub)
|
|
||||||
@php $picture = $sub->picture ?? 'cfolder15.gif'; @endphp
|
|
||||||
<li style="width:19%">
|
|
||||||
<img src="/gfx/icons/{{ $picture }}" width="15" height="15" border="0" alt="{{ $sub->category_name }}" />
|
|
||||||
<a href="/{{ $name }}/{{ Str::slug($sub->category_name) }}/{{ $sub->category_id }}" title="{{ $sub->category_name }}">{{ $sub->category_name }}</a>
|
|
||||||
</li>
|
|
||||||
@empty
|
|
||||||
<li class="text-muted">No subcategories</li>
|
|
||||||
@endforelse
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
@empty
|
|
||||||
<div class="alert alert-info">No categories for this content type.</div>
|
|
||||||
@endforelse
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@empty
|
|
||||||
<div class="col-xs-12">
|
|
||||||
<div class="alert alert-info">No content types available.</div>
|
|
||||||
</div>
|
|
||||||
@endforelse
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@endsection
|
@endsection
|
||||||
|
|||||||
@@ -3,6 +3,10 @@
|
|||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
use App\Http\Controllers\DashboardController;
|
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::middleware(['web', 'auth'])->prefix('dashboard')->name('api.dashboard.')->group(function () {
|
||||||
Route::get('activity', [DashboardController::class, 'activity'])->name('activity');
|
Route::get('activity', [DashboardController::class, 'activity'])->name('activity');
|
||||||
Route::get('analytics', [DashboardController::class, 'analytics'])->name('analytics');
|
Route::get('analytics', [DashboardController::class, 'analytics'])->name('analytics');
|
||||||
|
|||||||
@@ -14,7 +14,6 @@
|
|||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
//use App\Http\Controllers\Web\ArtController;
|
//use App\Http\Controllers\Web\ArtController;
|
||||||
use App\Http\Controllers\Legacy\AvatarController;
|
use App\Http\Controllers\Legacy\AvatarController;
|
||||||
use App\Http\Controllers\Web\CategoryController;
|
|
||||||
use App\Http\Controllers\Web\FeaturedArtworksController;
|
use App\Http\Controllers\Web\FeaturedArtworksController;
|
||||||
use App\Http\Controllers\Web\DailyUploadsController;
|
use App\Http\Controllers\Web\DailyUploadsController;
|
||||||
use App\Http\Controllers\Community\ChatController;
|
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\MembersController;
|
||||||
use App\Http\Controllers\User\StatisticsController;
|
use App\Http\Controllers\User\StatisticsController;
|
||||||
use App\Http\Controllers\User\ProfileController;
|
use App\Http\Controllers\User\ProfileController;
|
||||||
use App\Http\Controllers\Web\BrowseCategoriesController;
|
|
||||||
use App\Http\Controllers\Web\BrowseGalleryController;
|
use App\Http\Controllers\Web\BrowseGalleryController;
|
||||||
use App\Http\Controllers\Web\GalleryController;
|
use App\Http\Controllers\Web\GalleryController;
|
||||||
use App\Http\Controllers\Web\RssFeedController;
|
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+');
|
//Route::match(['get','post'], '/art/{id}/comment', [ArtController::class, 'show'])->where('id', '\d+');
|
||||||
|
|
||||||
// ── CATEGORIES / SECTIONS ─────────────────────────────────────────────────────
|
// ── CATEGORIES / SECTIONS ─────────────────────────────────────────────────────
|
||||||
Route::get('/categories', [CategoryController::class, 'index'])->name('legacy.categories');
|
Route::redirect('/sections', '/categories', 301)->name('sections');
|
||||||
Route::get('/sections', [\App\Http\Controllers\Web\SectionsController::class, 'index'])->name('sections');
|
Route::redirect('/browse-categories', '/categories', 301)->name('browse.categories');
|
||||||
Route::get('/browse-categories', [BrowseCategoriesController::class, 'index'])->name('browse.categories');
|
|
||||||
|
|
||||||
// Legacy category URL pattern: /category/group/slug/id
|
// Legacy category URL pattern: /category/group/slug/id
|
||||||
Route::get('/category/{group}/{slug?}/{id?}', [BrowseGalleryController::class, 'legacyCategory'])
|
Route::get('/category/{group}/{slug?}/{id?}', [BrowseGalleryController::class, 'legacyCategory'])
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ use App\Http\Controllers\Web\FooterController;
|
|||||||
use App\Http\Controllers\Web\StaffController;
|
use App\Http\Controllers\Web\StaffController;
|
||||||
use App\Http\Controllers\Web\RssFeedController;
|
use App\Http\Controllers\Web\RssFeedController;
|
||||||
use App\Http\Controllers\Web\ApplicationController;
|
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\NewsController as FrontendNewsController;
|
||||||
use App\Http\Controllers\News\NewsRssController;
|
use App\Http\Controllers\News\NewsRssController;
|
||||||
use App\Http\Controllers\RSS\GlobalFeedController;
|
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}')
|
->where('tag', '[A-Za-z][A-Za-z0-9_]{1,63}')
|
||||||
->name('feed.hashtag');
|
->name('feed.hashtag');
|
||||||
|
|
||||||
|
// ── CATEGORIES DIRECTORY ─────────────────────────────────────────────────────
|
||||||
|
Route::get('/categories', [CategoryController::class, 'index'])->name('categories.index');
|
||||||
|
|
||||||
// ── FOLLOWING (shortcut) ──────────────────────────────────────────────────────
|
// ── FOLLOWING (shortcut) ──────────────────────────────────────────────────────
|
||||||
Route::middleware('auth')->get('/following', function () {
|
Route::middleware('auth')->get('/following', function () {
|
||||||
return redirect()->route('dashboard.following');
|
return redirect()->route('dashboard.following');
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export default defineConfig({
|
|||||||
'resources/js/studio.jsx',
|
'resources/js/studio.jsx',
|
||||||
'resources/js/dashboard/index.jsx',
|
'resources/js/dashboard/index.jsx',
|
||||||
'resources/js/Pages/ArtworkPage.jsx',
|
'resources/js/Pages/ArtworkPage.jsx',
|
||||||
|
'resources/js/Pages/CategoriesPage.jsx',
|
||||||
'resources/js/Pages/Home/HomePage.jsx',
|
'resources/js/Pages/Home/HomePage.jsx',
|
||||||
'resources/js/Pages/Community/LatestCommentsPage.jsx',
|
'resources/js/Pages/Community/LatestCommentsPage.jsx',
|
||||||
'resources/js/Pages/Community/CommunityActivityPage.jsx',
|
'resources/js/Pages/Community/CommunityActivityPage.jsx',
|
||||||
|
|||||||
Reference in New Issue
Block a user