Wire admin studio SSR and search infrastructure
This commit is contained in:
@@ -4,10 +4,12 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Web;
|
||||
|
||||
use App\Enums\ReactionType;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Resources\ArtworkResource;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\ArtworkComment;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use App\Services\ContentSanitizer;
|
||||
use App\Services\ThumbnailPresenter;
|
||||
use App\Services\ErrorSuggestionService;
|
||||
@@ -21,6 +23,8 @@ use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\View\View;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response as InertiaResponse;
|
||||
|
||||
final class ArtworkPageController extends Controller
|
||||
{
|
||||
@@ -29,7 +33,7 @@ final class ArtworkPageController extends Controller
|
||||
private readonly ArtworkMaturityService $maturity,
|
||||
) {}
|
||||
|
||||
public function show(Request $request, int $id, ?string $slug = null): View|RedirectResponse|Response
|
||||
public function show(Request $request, int $id, ?string $slug = null): View|RedirectResponse|Response|InertiaResponse
|
||||
{
|
||||
// ── Step 1: check existence including soft-deleted ─────────────────
|
||||
$raw = Artwork::withTrashed()->where('id', $id)->first();
|
||||
@@ -181,8 +185,8 @@ final class ArtworkPageController extends Controller
|
||||
$itemSlug = (string) $item->id;
|
||||
}
|
||||
|
||||
$sm = ThumbnailPresenter::present($item, 'sm');
|
||||
$md = ThumbnailPresenter::present($item, 'md');
|
||||
$lg = ThumbnailPresenter::present($item, 'lg');
|
||||
|
||||
return $this->maturity->decoratePayload([
|
||||
'id' => (int) $item->id,
|
||||
@@ -192,8 +196,8 @@ final class ArtworkPageController extends Controller
|
||||
'publisher_type' => $item->group ? 'group' : 'user',
|
||||
'publisher_id' => $item->group ? (int) $item->group->id : (int) ($item->user?->id ?? 0),
|
||||
'url' => route('art.show', ['id' => $item->id, 'slug' => $itemSlug]),
|
||||
'thumb' => $md['url'] ?? null,
|
||||
'thumb_srcset' => ($md['url'] ?? '') . ' 640w, ' . ($lg['url'] ?? '') . ' 1280w',
|
||||
'thumb' => $sm['url'] ?? null,
|
||||
'thumb_srcset' => ($sm['url'] ?? '') . ' 320w, ' . ($md['url'] ?? '') . ' 640w',
|
||||
], $item, request()->user());
|
||||
})
|
||||
->values()
|
||||
@@ -249,20 +253,65 @@ final class ArtworkPageController extends Controller
|
||||
->values()
|
||||
->all();
|
||||
|
||||
return view('artworks.show', [
|
||||
'artwork' => $artwork,
|
||||
'artworkData' => $artworkData,
|
||||
'presentMd' => $thumbMd,
|
||||
'presentLg' => $thumbLg,
|
||||
'presentXl' => $thumbXl,
|
||||
'presentSq' => $thumbSq,
|
||||
'meta' => $meta,
|
||||
'seo' => $seo,
|
||||
'useUnifiedSeo' => true,
|
||||
'relatedItems' => $related,
|
||||
'comments' => $comments,
|
||||
'groupSummary' => $groupSummary,
|
||||
]);
|
||||
$canReadSession = $request->hasSession() && ! $request->attributes->get('skinbase.session_skipped');
|
||||
|
||||
$userId = ($canReadSession && $request->user() !== null) ? (int) $request->user()->id : null;
|
||||
|
||||
return Inertia::render('ArtworkPage', [
|
||||
'artwork' => $artworkData,
|
||||
'presentMd' => $thumbMd,
|
||||
'presentLg' => $thumbLg,
|
||||
'presentXl' => $thumbXl,
|
||||
'presentSq' => $thumbSq,
|
||||
'related' => $related,
|
||||
'canonicalUrl' => $canonical,
|
||||
'comments' => $comments,
|
||||
'groupSummary' => $groupSummary,
|
||||
'isAuthenticated' => $userId !== null,
|
||||
'reactionTotals' => $this->artworkReactionTotals((int) $artwork->id, $userId),
|
||||
'seo' => $seo,
|
||||
])->rootView('artworks.show');
|
||||
}
|
||||
|
||||
/**
|
||||
* Build per-slug reaction totals for the given artwork, including
|
||||
* whether the given user has each reaction (mine=true).
|
||||
*
|
||||
* Mirrors ReactionController::getTotals() so the page can render
|
||||
* the correct state without a separate client-side fetch on first load.
|
||||
*/
|
||||
private function artworkReactionTotals(int $artworkId, ?int $userId): array
|
||||
{
|
||||
$rows = DB::table('artwork_reactions')
|
||||
->where('artwork_id', $artworkId)
|
||||
->selectRaw('reaction, COUNT(*) as total')
|
||||
->groupBy('reaction')
|
||||
->get()
|
||||
->keyBy('reaction');
|
||||
|
||||
$totals = [];
|
||||
foreach (ReactionType::cases() as $type) {
|
||||
$slug = $type->value;
|
||||
$count = (int) ($rows[$slug]->total ?? 0);
|
||||
|
||||
$mine = false;
|
||||
if ($userId !== null && $count > 0) {
|
||||
$mine = DB::table('artwork_reactions')
|
||||
->where('artwork_id', $artworkId)
|
||||
->where('reaction', $slug)
|
||||
->where('user_id', $userId)
|
||||
->exists();
|
||||
}
|
||||
|
||||
$totals[$slug] = [
|
||||
'emoji' => $type->emoji(),
|
||||
'label' => $type->label(),
|
||||
'count' => $count,
|
||||
'mine' => $mine,
|
||||
];
|
||||
}
|
||||
|
||||
return $totals;
|
||||
}
|
||||
|
||||
/** Silently catch suggestion query failures so error page never crashes. */
|
||||
|
||||
@@ -20,6 +20,8 @@ use Illuminate\Pagination\AbstractCursorPaginator;
|
||||
|
||||
class BrowseGalleryController extends \App\Http\Controllers\Controller
|
||||
{
|
||||
private const CACHE_VERSION = 'v4';
|
||||
|
||||
/**
|
||||
* Meilisearch sort-field arrays per sort alias.
|
||||
* First element is primary sort; subsequent elements are tie-breakers.
|
||||
@@ -28,18 +30,18 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
|
||||
// ── Nova sort aliases ─────────────────────────────────────────────────
|
||||
// trending_score_24h only covers artworks ≤ 7 days old; use 7d score
|
||||
// and favorites_count as fallbacks so older artworks don't all tie at 0.
|
||||
'trending' => ['trending_score_24h:desc', 'trending_score_7d:desc', 'favorites_count:desc', 'created_at:desc'],
|
||||
'trending' => ['trending_score_24h:desc', 'trending_score_7d:desc', 'favorites_count:desc', 'published_at_ts:desc'],
|
||||
// "New & Hot": 30-day trending window surfaces recently-active artworks.
|
||||
'fresh' => ['trending_score_7d:desc', 'favorites_count:desc', 'created_at:desc'],
|
||||
'fresh' => ['published_at_ts:desc', 'trending_score_7d:desc', 'favorites_count:desc'],
|
||||
'top-rated' => ['awards_received_count:desc', 'favorites_count:desc'],
|
||||
'favorited' => ['favorites_count:desc', 'trending_score_24h:desc'],
|
||||
'downloaded' => ['downloads_count:desc', 'trending_score_24h:desc'],
|
||||
'oldest' => ['created_at:asc'],
|
||||
'favorited' => ['favorites_count:desc', 'trending_score_24h:desc', 'published_at_ts:desc'],
|
||||
'downloaded' => ['downloads_count:desc', 'trending_score_24h:desc', 'published_at_ts:desc'],
|
||||
'oldest' => ['published_at_ts:asc'],
|
||||
// ── Legacy aliases (backward compat) ──────────────────────────────────
|
||||
'latest' => ['created_at:desc'],
|
||||
'popular' => ['views:desc', 'favorites_count:desc'],
|
||||
'liked' => ['likes:desc', 'favorites_count:desc'],
|
||||
'downloads' => ['downloads:desc', 'downloads_count:desc'],
|
||||
'latest' => ['published_at_ts:desc'],
|
||||
'popular' => ['views:desc', 'favorites_count:desc', 'published_at_ts:desc'],
|
||||
'liked' => ['likes:desc', 'favorites_count:desc', 'published_at_ts:desc'],
|
||||
'downloads' => ['downloads:desc', 'downloads_count:desc', 'published_at_ts:desc'],
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -66,6 +68,7 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
|
||||
private const SORT_OPTIONS = [
|
||||
['value' => 'trending', 'label' => '🔥 Trending'],
|
||||
['value' => 'fresh', 'label' => '🆕 Fresh'],
|
||||
['value' => 'latest', 'label' => '🕐 Latest'],
|
||||
['value' => 'top-rated', 'label' => '⭐ Top Rated'],
|
||||
['value' => 'favorited', 'label' => '❤️ Most Favorited'],
|
||||
['value' => 'downloaded', 'label' => '⬇ Most Downloaded'],
|
||||
@@ -88,11 +91,11 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
|
||||
$ttl = self::SORT_TTL_MAP[$sort] ?? 300;
|
||||
|
||||
$artworks = Cache::remember(
|
||||
"browse.all.catalog-visible.v2.{$sort}.{$page}",
|
||||
"browse.all.catalog-visible." . self::CACHE_VERSION . ".{$sort}.{$page}",
|
||||
$ttl,
|
||||
fn () => $this->search->searchWithThumbnailPreference([
|
||||
'filter' => 'is_public = true AND is_approved = true',
|
||||
'sort' => self::SORT_MAP[$sort] ?? ['created_at:desc'],
|
||||
'sort' => self::SORT_MAP[$sort] ?? ['published_at_ts:desc'],
|
||||
], $perPage, false, $page)
|
||||
);
|
||||
$artworks->getCollection()->transform(fn ($a) => $this->presentArtwork($a));
|
||||
@@ -150,11 +153,11 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
|
||||
$normalizedPath = trim((string) $path, '/');
|
||||
if ($normalizedPath === '') {
|
||||
$artworks = Cache::remember(
|
||||
"gallery.ct.catalog-visible.v2.{$contentSlug}.{$sort}.{$page}",
|
||||
"gallery.ct.catalog-visible." . self::CACHE_VERSION . ".{$contentSlug}.{$sort}.{$page}",
|
||||
$ttl,
|
||||
fn () => $this->search->searchWithThumbnailPreference([
|
||||
'filter' => 'is_public = true AND is_approved = true AND content_type = "' . $contentSlug . '"',
|
||||
'sort' => self::SORT_MAP[$sort] ?? ['created_at:desc'],
|
||||
'filter' => 'is_public = true AND is_approved = true AND ' . $this->contentTypeFilterClause($contentSlug),
|
||||
'sort' => self::SORT_MAP[$sort] ?? ['published_at_ts:desc'],
|
||||
], $perPage, false, $page)
|
||||
);
|
||||
$artworks->getCollection()->transform(fn ($a) => $this->presentArtwork($a));
|
||||
@@ -192,16 +195,14 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
|
||||
}
|
||||
|
||||
$categorySlugs = $this->categoryFilterSlugs($category);
|
||||
$categoryFilter = collect($categorySlugs)
|
||||
->map(fn (string $slug) => 'category = "' . addslashes($slug) . '"')
|
||||
->implode(' OR ');
|
||||
$filterExpression = $this->categoryPageFilterExpression($contentSlug, $categorySlugs);
|
||||
|
||||
$artworks = Cache::remember(
|
||||
'gallery.cat.catalog-visible.v2.' . md5($contentSlug . '|' . implode('|', $categorySlugs)) . ".{$sort}.{$page}",
|
||||
'gallery.cat.catalog-visible.' . self::CACHE_VERSION . '.' . md5($contentSlug . '|' . implode('|', $categorySlugs)) . ".{$sort}.{$page}",
|
||||
$ttl,
|
||||
fn () => $this->search->searchWithThumbnailPreference([
|
||||
'filter' => 'is_public = true AND is_approved = true AND (' . $categoryFilter . ')',
|
||||
'sort' => self::SORT_MAP[$sort] ?? ['created_at:desc'],
|
||||
'filter' => $filterExpression,
|
||||
'sort' => self::SORT_MAP[$sort] ?? ['published_at_ts:desc'],
|
||||
], $perPage, false, $page)
|
||||
);
|
||||
$artworks->getCollection()->transform(fn ($a) => $this->presentArtwork($a));
|
||||
@@ -369,6 +370,31 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
|
||||
return array_values(array_unique($slugs));
|
||||
}
|
||||
|
||||
private function categoryFilterClause(string $categorySlug): string
|
||||
{
|
||||
$quoted = addslashes($categorySlug);
|
||||
|
||||
return '(category = "' . $quoted . '" OR categories = "' . $quoted . '")';
|
||||
}
|
||||
|
||||
private function categoryPageFilterExpression(string $contentTypeSlug, array $categorySlugs): string
|
||||
{
|
||||
$categoryFilter = collect($categorySlugs)
|
||||
->map(fn (string $slug) => $this->categoryFilterClause($slug))
|
||||
->implode(' OR ');
|
||||
|
||||
return 'is_public = true AND is_approved = true AND '
|
||||
. $this->contentTypeFilterClause($contentTypeSlug)
|
||||
. ' AND (' . $categoryFilter . ')';
|
||||
}
|
||||
|
||||
private function contentTypeFilterClause(string $contentTypeSlug): string
|
||||
{
|
||||
$quoted = addslashes($contentTypeSlug);
|
||||
|
||||
return '(content_type = "' . $quoted . '" OR content_types = "' . $quoted . '")';
|
||||
}
|
||||
|
||||
private function resolvePerPage(Request $request): int
|
||||
{
|
||||
$limit = (int) $request->query('limit', 0);
|
||||
@@ -393,7 +419,7 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
|
||||
private function mainCategories(): Collection
|
||||
{
|
||||
return $this->contentTypeResolver
|
||||
->publicContentTypes()
|
||||
->toolbarContentTypes()
|
||||
->map(function (ContentType $type) {
|
||||
return (object) [
|
||||
'id' => $type->id,
|
||||
|
||||
@@ -5,21 +5,24 @@ namespace App\Http\Controllers\Web;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use App\Services\ArtworkService;
|
||||
use App\Services\CategoryDirectoryService;
|
||||
use App\Models\Category;
|
||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
|
||||
class CategoryController extends Controller
|
||||
{
|
||||
protected ArtworkService $artworkService;
|
||||
protected CategoryDirectoryService $categoryDirectory;
|
||||
|
||||
public function __construct(ArtworkService $artworkService)
|
||||
public function __construct(ArtworkService $artworkService, CategoryDirectoryService $categoryDirectory)
|
||||
{
|
||||
$this->artworkService = $artworkService;
|
||||
$this->categoryDirectory = $categoryDirectory;
|
||||
}
|
||||
|
||||
public function index(Request $request)
|
||||
{
|
||||
return $this->browseCategories();
|
||||
return $this->browseCategories($request);
|
||||
}
|
||||
|
||||
public function show(Request $request, $id, $slug = null, $group = null)
|
||||
@@ -58,20 +61,7 @@ class CategoryController extends Controller
|
||||
}
|
||||
|
||||
try {
|
||||
$category = Category::whereHas('contentType', function ($q) use ($contentTypeSlug) {
|
||||
$q->where('slug', strtolower($contentTypeSlug));
|
||||
})->whereNull('parent_id')->where('slug', strtolower($parts[0] ?? ''))->first();
|
||||
|
||||
if ($category && count($parts) > 1) {
|
||||
$cur = $category;
|
||||
foreach (array_slice($parts, 1) as $slugPart) {
|
||||
$cur = $cur->children()->where('slug', strtolower($slugPart))->first();
|
||||
if (! $cur) {
|
||||
abort(404);
|
||||
}
|
||||
}
|
||||
$category = $cur;
|
||||
}
|
||||
$category = $this->artworkService->resolveCategoryByPath($slugs);
|
||||
} catch (\Throwable $e) {
|
||||
$category = null;
|
||||
}
|
||||
@@ -109,12 +99,19 @@ class CategoryController extends Controller
|
||||
));
|
||||
}
|
||||
|
||||
public function browseCategories()
|
||||
public function browseCategories(Request $request)
|
||||
{
|
||||
$pageTitle = 'All Categories – Wallpapers, Skins & Digital Art | Skinbase';
|
||||
$pageDescription = 'Browse all categories on Skinbase including wallpapers, skins, themes, and digital art collections.';
|
||||
$payload = $this->categoryDirectory->getDirectoryPayload(
|
||||
(string) $request->query('q', ''),
|
||||
(string) $request->query('sort', 'popular'),
|
||||
(int) $request->query('page', 1),
|
||||
(int) $request->query('per_page', 24),
|
||||
);
|
||||
|
||||
return view('web.categories', [
|
||||
'initialPayload' => $payload,
|
||||
'page_title' => $pageTitle,
|
||||
'page_meta_description' => $pageDescription,
|
||||
'page_canonical' => url('/categories'),
|
||||
|
||||
@@ -176,6 +176,7 @@ final class DiscoverController extends Controller
|
||||
'user:id,name',
|
||||
'user.profile:user_id,avatar_hash',
|
||||
'categories:id,name,slug,content_type_id,parent_id,sort_order',
|
||||
'categories.contentType:id,slug,name',
|
||||
])
|
||||
->whereRaw('MONTH(published_at) = ?', [$today->month])
|
||||
->whereRaw('DAY(published_at) = ?', [$today->day])
|
||||
@@ -551,6 +552,7 @@ final class DiscoverController extends Controller
|
||||
'user.profile:user_id,avatar_hash',
|
||||
'group:id,name,slug,avatar_path',
|
||||
'categories:id,name,slug,content_type_id,parent_id,sort_order',
|
||||
'categories.contentType:id,slug,name',
|
||||
])
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
@@ -18,6 +18,7 @@ use App\Services\ThumbnailPresenter;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Pagination\AbstractCursorPaginator;
|
||||
use Illuminate\Pagination\AbstractPaginator;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
@@ -238,12 +239,102 @@ final class ExploreController extends Controller
|
||||
return $this->byType($request, $type);
|
||||
}
|
||||
|
||||
// ── /explore/best (Hall of Fame) ────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Hall of Fame: all-time highest-medal artworks, ranked by prestige.
|
||||
*
|
||||
* Algorithm:
|
||||
* 1. Primary: score_total DESC (all-time weighted medal score: gold×5 + silver×3 + bronze×1)
|
||||
* 2. Secondary: gold_count DESC (prestige tiebreak — golds are rarer and more deliberate)
|
||||
* 3. Tertiary: favorites_count DESC (overall community love)
|
||||
*
|
||||
* Only artworks published ≥ 30 days ago are eligible so freshly-viral
|
||||
* pieces don't crowd out genuine all-time standouts.
|
||||
*
|
||||
* Cache TTL is 1 hour — rankings shift slowly for the HoF.
|
||||
*/
|
||||
public function hallOfFame(Request $request)
|
||||
{
|
||||
$perPage = 24;
|
||||
$page = max(1, (int) $request->query('page', 1));
|
||||
$minAge = now()->subDays(30);
|
||||
$maturityUser = $request->user();
|
||||
|
||||
$cacheVersion = $this->cacheVersion();
|
||||
$viewerSegment = $maturityUser ? 'auth.' . $maturityUser->id : 'guest';
|
||||
$cacheKey = "explore.hall-of-fame.v{$cacheVersion}.{$viewerSegment}.p{$page}";
|
||||
|
||||
$paginator = Cache::remember($cacheKey, 3600, function () use ($perPage, $page, $minAge, $maturityUser): LengthAwarePaginator {
|
||||
$query = Artwork::query()
|
||||
->public()
|
||||
->published()
|
||||
->tap(fn ($b) => $this->maturity->applyViewerFilter($b, $maturityUser))
|
||||
->withoutMissingThumbnails()
|
||||
->with([
|
||||
'user:id,name,username',
|
||||
'user.profile:user_id,avatar_hash',
|
||||
'group:id,name,slug,headline,avatar_path,followers_count',
|
||||
'categories:id,name,slug,content_type_id,sort_order',
|
||||
'categories.contentType:id,name,slug',
|
||||
'awardStat:artwork_id,gold_count,silver_count,bronze_count,score_total',
|
||||
'stats:artwork_id,favorites',
|
||||
])
|
||||
->leftJoin('artwork_medal_stats as hof', 'hof.artwork_id', '=', 'artworks.id')
|
||||
->leftJoin('artwork_stats as hof_stats', 'hof_stats.artwork_id', '=', 'artworks.id')
|
||||
->select('artworks.*')
|
||||
// Must have at least one medal
|
||||
->whereRaw('COALESCE(hof.score_total, 0) > 0')
|
||||
// Minimum 30-day age to exclude freshly-viral pieces
|
||||
->where('artworks.published_at', '<=', $minAge)
|
||||
// Ranking: prestige-weighted medal score, then gold count, then favorites
|
||||
->orderByRaw('COALESCE(hof.score_total, 0) DESC')
|
||||
->orderByRaw('COALESCE(hof.gold_count, 0) DESC')
|
||||
->orderByRaw('COALESCE(hof_stats.favorites, 0) DESC');
|
||||
|
||||
return $query->paginate($perPage, ['artworks.*'], 'page', $page)
|
||||
->withPath(url('/explore/best'));
|
||||
});
|
||||
|
||||
$paginator->getCollection()->transform(fn (Artwork $a) => $this->presentArtwork($a));
|
||||
|
||||
$mainCategories = $this->mainCategories();
|
||||
$seo = $this->paginationSeo($request, url('/explore/best'), $paginator);
|
||||
|
||||
return view('gallery.index', [
|
||||
'gallery_type' => 'browse',
|
||||
'gallery_nav_section' => 'artworks',
|
||||
'mainCategories' => $mainCategories,
|
||||
'subcategories' => $mainCategories,
|
||||
'contentType' => null,
|
||||
'category' => null,
|
||||
'artworks' => $paginator,
|
||||
'spotlight' => collect(),
|
||||
'hide_rank_tabs' => true,
|
||||
'current_sort' => 'top-rated',
|
||||
'sort_options' => [],
|
||||
'hero_title' => 'Hall of Fame',
|
||||
'hero_description' => 'All-time medal standouts ranked by prestige — the artworks the community has honoured most across the years.',
|
||||
'breadcrumbs' => collect([
|
||||
(object) ['name' => 'Explore', 'url' => '/explore'],
|
||||
(object) ['name' => 'Hall of Fame', 'url' => '/explore/best'],
|
||||
]),
|
||||
'page_title' => 'Hall of Fame — All-Time Best Artworks - Skinbase',
|
||||
'page_meta_description' => 'The highest-medal artworks of all time on Skinbase, ranked by gold, silver and bronze prestige.',
|
||||
'page_meta_keywords' => 'hall of fame, best artworks, top rated, medals, skinbase',
|
||||
'page_canonical' => $seo['canonical'],
|
||||
'page_rel_prev' => $seo['prev'],
|
||||
'page_rel_next' => $seo['next'],
|
||||
'page_robots' => 'index,follow',
|
||||
]);
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
private function mainCategories(): Collection
|
||||
{
|
||||
$categories = $this->contentTypeResolver
|
||||
->publicContentTypes()
|
||||
->toolbarContentTypes()
|
||||
->map(fn ($ct) => (object) [
|
||||
'name' => $ct->name,
|
||||
'slug' => $ct->slug,
|
||||
@@ -311,7 +402,8 @@ final class ExploreController extends Controller
|
||||
];
|
||||
|
||||
if ($contentType !== null && $contentType !== '') {
|
||||
$filterParts[] = 'content_type = "' . addslashes($contentType) . '"';
|
||||
$quoted = addslashes($contentType);
|
||||
$filterParts[] = '(content_type = "' . $quoted . '" OR content_types = "' . $quoted . '")';
|
||||
}
|
||||
|
||||
$orientation = strtolower(trim((string) $request->query('orientation', '')));
|
||||
|
||||
@@ -22,6 +22,7 @@ class FeaturedArtworksController extends Controller
|
||||
public function index(Request $request)
|
||||
{
|
||||
$perPage = 39;
|
||||
$viewer = $request->user();
|
||||
|
||||
$type = (int) ($request->query('type', 4));
|
||||
|
||||
@@ -31,32 +32,32 @@ class FeaturedArtworksController extends Controller
|
||||
$artworks = $this->artworks->getFeaturedArtworks($typeFilter, $perPage);
|
||||
|
||||
$artworks->setCollection(
|
||||
collect($this->maturity->filterPayloadItems($artworks->getCollection()->map(function (Artwork $artwork): array {
|
||||
$primaryCategory = $artwork->categories->sortBy('sort_order')->first();
|
||||
$categoryName = $primaryCategory->name ?? '';
|
||||
$categorySlug = $primaryCategory->slug ?? '';
|
||||
$gid = $primaryCategory ? ((int) $primaryCategory->id % 5) * 5 : 0;
|
||||
$present = \App\Services\ThumbnailPresenter::present($artwork, 'md');
|
||||
$username = $artwork->user->username ?? $artwork->user->name ?? 'Skinbase';
|
||||
collect($this->maturity->filterPayloadItems($artworks->getCollection()->map(function (Artwork $artwork) use ($viewer): array {
|
||||
$primaryCategory = $artwork->categories->sortBy('sort_order')->first();
|
||||
$categoryName = $primaryCategory->name ?? '';
|
||||
$categorySlug = $primaryCategory->slug ?? '';
|
||||
$gid = $primaryCategory ? ((int) $primaryCategory->id % 5) * 5 : 0;
|
||||
$present = \App\Services\ThumbnailPresenter::present($artwork, 'md');
|
||||
$username = $artwork->user->username ?? $artwork->user->name ?? 'Skinbase';
|
||||
|
||||
return $this->maturity->decoratePayload([
|
||||
'id' => $artwork->id,
|
||||
'name' => $artwork->title,
|
||||
'slug' => $artwork->slug,
|
||||
'url' => url('/art/' . $artwork->id . '/' . Str::slug($artwork->title ?? 'artwork')),
|
||||
'content_type_name' => $primaryCategory?->contentType?->name ?? '',
|
||||
'content_type_slug' => $primaryCategory?->contentType?->slug ?? '',
|
||||
'category_name' => $categoryName,
|
||||
'category_slug' => $categorySlug,
|
||||
'gid_num' => $gid,
|
||||
'thumb_url' => $present['url'],
|
||||
'thumb_srcset' => $present['srcset'] ?? $present['url'],
|
||||
'width' => $artwork->width,
|
||||
'height' => $artwork->height,
|
||||
'uname' => $artwork->user->name ?? 'Skinbase',
|
||||
'username' => $username,
|
||||
], $artwork, $request->user());
|
||||
})->values()->all(), $request->user()))
|
||||
return $this->maturity->decoratePayload([
|
||||
'id' => $artwork->id,
|
||||
'name' => $artwork->title,
|
||||
'slug' => $artwork->slug,
|
||||
'url' => url('/art/' . $artwork->id . '/' . Str::slug($artwork->title ?? 'artwork')),
|
||||
'content_type_name' => $primaryCategory?->contentType?->name ?? '',
|
||||
'content_type_slug' => $primaryCategory?->contentType?->slug ?? '',
|
||||
'category_name' => $categoryName,
|
||||
'category_slug' => $categorySlug,
|
||||
'gid_num' => $gid,
|
||||
'thumb_url' => $present['url'],
|
||||
'thumb_srcset' => $present['srcset'] ?? $present['url'],
|
||||
'width' => $artwork->width,
|
||||
'height' => $artwork->height,
|
||||
'uname' => $artwork->user->name ?? 'Skinbase',
|
||||
'username' => $username,
|
||||
], $artwork, $viewer);
|
||||
})->values()->all(), $viewer))
|
||||
->map(static fn (array $item): object => (object) $item)
|
||||
->values()
|
||||
);
|
||||
|
||||
@@ -3,12 +3,15 @@
|
||||
namespace App\Http\Controllers\Web\Posts;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Support\Seo\SeoFactory;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class FollowingFeedController extends Controller
|
||||
{
|
||||
public function __construct(private SeoFactory $seoFactory) {}
|
||||
|
||||
/**
|
||||
* GET /feed/following
|
||||
* Renders the Following Feed Inertia page.
|
||||
@@ -16,6 +19,13 @@ class FollowingFeedController extends Controller
|
||||
*/
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$seo = $this->seoFactory->simplePage(
|
||||
title: 'Following Feed — ' . config('seo.site_name', 'Skinbase'),
|
||||
description: 'Posts from creators you follow on Skinbase.',
|
||||
canonical: url('/feed/following'),
|
||||
indexable: false,
|
||||
);
|
||||
|
||||
return Inertia::render('Feed/FollowingFeed', [
|
||||
'auth' => [
|
||||
'user' => $request->user() ? [
|
||||
@@ -25,6 +35,7 @@ class FollowingFeedController extends Controller
|
||||
'avatar' => $request->user()->profile?->avatar_url ?? null,
|
||||
] : null,
|
||||
],
|
||||
'seo' => $seo,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,15 +3,26 @@
|
||||
namespace App\Http\Controllers\Web\Posts;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Support\Seo\SeoFactory;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class HashtagFeedController extends Controller
|
||||
{
|
||||
public function __construct(private SeoFactory $seoFactory) {}
|
||||
|
||||
/** GET /tags/{tag} */
|
||||
public function index(Request $request, string $tag): Response
|
||||
{
|
||||
$normalTag = strtolower($tag);
|
||||
|
||||
$seo = $this->seoFactory->simplePage(
|
||||
title: '#' . $normalTag . ' — ' . config('seo.site_name', 'Skinbase'),
|
||||
description: 'Explore posts tagged with #' . $normalTag . ' on Skinbase.',
|
||||
canonical: url('/tags/' . rawurlencode($normalTag)),
|
||||
);
|
||||
|
||||
return Inertia::render('Feed/HashtagFeed', [
|
||||
'auth' => $request->user() ? [
|
||||
'user' => [
|
||||
@@ -21,7 +32,8 @@ class HashtagFeedController extends Controller
|
||||
'avatar' => $request->user()->profile?->avatar_url ?? null,
|
||||
],
|
||||
] : null,
|
||||
'tag' => strtolower($tag),
|
||||
'tag' => $normalTag,
|
||||
'seo' => $seo,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,15 +3,25 @@
|
||||
namespace App\Http\Controllers\Web\Posts;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Support\Seo\SeoFactory;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class SavedFeedController extends Controller
|
||||
{
|
||||
public function __construct(private SeoFactory $seoFactory) {}
|
||||
|
||||
/** GET /feed/saved */
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$seo = $this->seoFactory->simplePage(
|
||||
title: 'Saved Posts — ' . config('seo.site_name', 'Skinbase'),
|
||||
description: 'Your saved posts on Skinbase.',
|
||||
canonical: url('/feed/saved'),
|
||||
indexable: false,
|
||||
);
|
||||
|
||||
return Inertia::render('Feed/SavedFeed', [
|
||||
'auth' => [
|
||||
'user' => [
|
||||
@@ -21,6 +31,7 @@ class SavedFeedController extends Controller
|
||||
'avatar' => $request->user()->profile?->avatar_url ?? null,
|
||||
],
|
||||
],
|
||||
'seo' => $seo,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Web\Posts;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\Posts\PostHashtagService;
|
||||
use App\Support\Seo\SeoFactory;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Inertia\Inertia;
|
||||
@@ -11,7 +12,10 @@ use Inertia\Response;
|
||||
|
||||
class SearchFeedController extends Controller
|
||||
{
|
||||
public function __construct(private PostHashtagService $hashtagService) {}
|
||||
public function __construct(
|
||||
private PostHashtagService $hashtagService,
|
||||
private SeoFactory $seoFactory,
|
||||
) {}
|
||||
|
||||
/** GET /feed/search */
|
||||
public function index(Request $request): Response
|
||||
@@ -22,6 +26,12 @@ class SearchFeedController extends Controller
|
||||
fn () => $this->hashtagService->trending(10, 24)
|
||||
);
|
||||
|
||||
$seo = $this->seoFactory->simplePage(
|
||||
title: 'Search Posts — ' . config('seo.site_name', 'Skinbase'),
|
||||
description: 'Search posts, hashtags and creators on Skinbase.',
|
||||
canonical: url('/feed/search'),
|
||||
);
|
||||
|
||||
return Inertia::render('Feed/SearchFeed', [
|
||||
'auth' => $request->user() ? [
|
||||
'user' => [
|
||||
@@ -31,8 +41,9 @@ class SearchFeedController extends Controller
|
||||
'avatar' => $request->user()->profile?->avatar_url ?? null,
|
||||
],
|
||||
] : null,
|
||||
'initialQuery' => $request->query('q', ''),
|
||||
'trendingHashtags' => $trendingHashtags,
|
||||
'initialQuery' => $request->query('q', ''),
|
||||
'trendingHashtags' => $trendingHashtags,
|
||||
'seo' => $seo,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Web\Posts;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\Posts\PostHashtagService;
|
||||
use App\Support\Seo\SeoFactory;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Inertia\Inertia;
|
||||
@@ -11,13 +12,22 @@ use Inertia\Response;
|
||||
|
||||
class TrendingFeedController extends Controller
|
||||
{
|
||||
public function __construct(private PostHashtagService $hashtagService) {}
|
||||
public function __construct(
|
||||
private PostHashtagService $hashtagService,
|
||||
private SeoFactory $seoFactory,
|
||||
) {}
|
||||
|
||||
/** GET /feed/trending */
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$trendingHashtags = Cache::remember('trending_hashtags', 300, fn () => $this->hashtagService->trending(10, 24));
|
||||
|
||||
$seo = $this->seoFactory->simplePage(
|
||||
title: 'Trending Posts — ' . config('seo.site_name', 'Skinbase'),
|
||||
description: 'Discover the most popular and engaging posts on Skinbase right now.',
|
||||
canonical: url('/feed/trending'),
|
||||
);
|
||||
|
||||
return Inertia::render('Feed/TrendingFeed', [
|
||||
'auth' => $request->user() ? [
|
||||
'user' => [
|
||||
@@ -28,6 +38,7 @@ class TrendingFeedController extends Controller
|
||||
],
|
||||
] : null,
|
||||
'trendingHashtags' => $trendingHashtags,
|
||||
'seo' => $seo,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,21 +9,32 @@ use App\Http\Resources\ArtworkListResource;
|
||||
use App\Services\ArtworkSearchService;
|
||||
use App\Services\GroupDiscoveryService;
|
||||
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\View\View;
|
||||
use cPad\Plugins\News\Models\NewsArticle;
|
||||
|
||||
final class SearchController extends Controller
|
||||
{
|
||||
private const ALLOWED_SORTS = ['latest', 'popular', 'likes', 'downloads'];
|
||||
|
||||
public function __construct(
|
||||
private readonly ArtworkSearchService $search,
|
||||
private readonly GroupDiscoveryService $groups,
|
||||
) {}
|
||||
|
||||
public function index(Request $request): View
|
||||
public function index(Request $request): View|RedirectResponse
|
||||
{
|
||||
$q = trim((string) $request->query('q', ''));
|
||||
$sort = $request->query('sort', 'latest');
|
||||
$canonicalQuery = $this->canonicalQueryParameters($request);
|
||||
$canonicalUrl = $this->canonicalSearchUrl($request, $canonicalQuery);
|
||||
|
||||
if ($request->fullUrl() !== $canonicalUrl) {
|
||||
return redirect()->to($canonicalUrl, 301);
|
||||
}
|
||||
|
||||
$q = (string) ($canonicalQuery['q'] ?? '');
|
||||
$sort = (string) ($canonicalQuery['sort'] ?? 'latest');
|
||||
$hasQuery = $q !== '';
|
||||
|
||||
$sortMap = [
|
||||
@@ -98,4 +109,81 @@ final class SearchController extends Controller
|
||||
'page_robots' => 'noindex,follow',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, int|string>
|
||||
*/
|
||||
private function canonicalQueryParameters(Request $request): array
|
||||
{
|
||||
$q = $this->normalizeSearchQuery($request->query('q', ''));
|
||||
|
||||
if ($q === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$params = ['q' => $q];
|
||||
$sort = $this->normalizeSort($request->query('sort', 'latest'));
|
||||
$page = $this->normalizePage($request->query('page', 1));
|
||||
|
||||
if ($sort !== 'latest') {
|
||||
$params['sort'] = $sort;
|
||||
}
|
||||
|
||||
if ($page > 1) {
|
||||
$params['page'] = $page;
|
||||
}
|
||||
|
||||
return $params;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, int|string> $params
|
||||
*/
|
||||
private function canonicalSearchUrl(Request $request, array $params): string
|
||||
{
|
||||
$query = Arr::query($params);
|
||||
|
||||
return $query === '' ? $request->url() : $request->url() . '?' . $query;
|
||||
}
|
||||
|
||||
private function normalizeSearchQuery(mixed $value): string
|
||||
{
|
||||
$query = html_entity_decode($this->firstScalarValue($value), ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||
$query = preg_replace('/(?:\?|&)(?:amp;)?(?:page|sort|filter|group|id|txtfilter|q)=.*$/i', '', $query) ?? $query;
|
||||
$query = preg_replace('/\s+/u', ' ', $query) ?? $query;
|
||||
|
||||
return trim($query, " \t\n\r\0\x0B?&");
|
||||
}
|
||||
|
||||
private function normalizeSort(mixed $value): string
|
||||
{
|
||||
$sort = strtolower($this->firstScalarValue($value));
|
||||
$sort = preg_replace('/(?:\?|&).*/', '', $sort) ?? $sort;
|
||||
|
||||
return in_array($sort, self::ALLOWED_SORTS, true) ? $sort : 'latest';
|
||||
}
|
||||
|
||||
private function normalizePage(mixed $value): int
|
||||
{
|
||||
$page = $this->firstScalarValue($value);
|
||||
|
||||
if (preg_match('/\d+/', $page, $matches) !== 1) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return max(1, (int) $matches[0]);
|
||||
}
|
||||
|
||||
private function firstScalarValue(mixed $value): string
|
||||
{
|
||||
if (is_array($value)) {
|
||||
$value = reset($value);
|
||||
}
|
||||
|
||||
if (! is_scalar($value) && $value !== null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return trim((string) $value);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -259,7 +259,7 @@ final class SimilarArtworksPageController extends Controller
|
||||
$quoted = array_map(fn (string $t): string => 'tags = "' . addslashes($t) . '"', $tagSlugs);
|
||||
$filterParts[] = '(' . implode(' OR ', $quoted) . ')';
|
||||
} elseif ($categorySlugs !== []) {
|
||||
$quoted = array_map(fn (string $c): string => 'category = "' . addslashes($c) . '"', $categorySlugs);
|
||||
$quoted = array_map(fn (string $c): string => '(category = "' . addslashes($c) . '" OR categories = "' . addslashes($c) . '")', $categorySlugs);
|
||||
$filterParts[] = '(' . implode(' OR ', $quoted) . ')';
|
||||
}
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ final class TagController extends Controller
|
||||
$artworks->getCollection()->each(fn($m) => $m->loadMissing(['user.profile', 'categories']));
|
||||
|
||||
// Sidebar: main content type links (same as browse gallery)
|
||||
$mainCategories = ContentType::ordered()->get(['name', 'slug'])
|
||||
$mainCategories = ContentType::ordered()->where('hide_from_menu', false)->get(['name', 'slug'])
|
||||
->map(fn ($type) => (object) [
|
||||
'id' => $type->id,
|
||||
'name' => $type->name,
|
||||
|
||||
@@ -5,9 +5,9 @@ declare(strict_types=1);
|
||||
namespace App\Http\Controllers\Web;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\World;
|
||||
use App\Services\Worlds\WorldService;
|
||||
use App\Support\Seo\SeoFactory;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
@@ -32,16 +32,47 @@ final class WorldController extends Controller
|
||||
]))->rootView('collections');
|
||||
}
|
||||
|
||||
public function show(Request $request, World $world): Response
|
||||
public function show(Request $request, string $world): Response|RedirectResponse
|
||||
{
|
||||
abort_unless($world->isPubliclyVisible(), 404);
|
||||
$resolution = $this->worlds->resolvePublicWorld($world);
|
||||
$resolvedWorld = $resolution['world'] ?? null;
|
||||
|
||||
$payload = $this->worlds->publicShowPayload($world, $request->user());
|
||||
abort_unless($resolvedWorld !== null, 404);
|
||||
|
||||
if (! empty($resolution['redirect'])) {
|
||||
return redirect()->to((string) $resolution['redirect'], 301);
|
||||
}
|
||||
|
||||
$payload = $this->worlds->publicShowPayload($resolvedWorld, $request->user());
|
||||
$seo = app(SeoFactory::class)->collectionPage(
|
||||
$world->seo_title ?: ($world->title . ' — Skinbase Nova'),
|
||||
$world->seo_description ?: ($world->summary ?: $world->description ?: 'Seasonal and editorial discovery world on Skinbase Nova.'),
|
||||
route('worlds.show', ['world' => $world->slug]),
|
||||
$world->ogImageUrl(),
|
||||
$resolvedWorld->seo_title ?: ($resolvedWorld->title . ' — Skinbase Nova'),
|
||||
$resolvedWorld->seo_description ?: ($resolvedWorld->summary ?: $resolvedWorld->description ?: 'Seasonal and editorial discovery world on Skinbase Nova.'),
|
||||
$this->worlds->canonicalPublicUrl($resolvedWorld),
|
||||
$resolvedWorld->ogImageUrl(),
|
||||
)->toArray();
|
||||
|
||||
return Inertia::render('World/WorldShow', array_merge($payload, [
|
||||
'seo' => $seo,
|
||||
]))->rootView('collections');
|
||||
}
|
||||
|
||||
public function showEdition(Request $request, string $world, int $year): Response|RedirectResponse
|
||||
{
|
||||
$resolution = $this->worlds->resolvePublicEdition($world, $year);
|
||||
$resolvedWorld = $resolution['world'] ?? null;
|
||||
|
||||
abort_unless($resolvedWorld !== null, 404);
|
||||
|
||||
if (! empty($resolution['redirect'])) {
|
||||
return redirect()->to((string) $resolution['redirect'], 301);
|
||||
}
|
||||
|
||||
$payload = $this->worlds->publicShowPayload($resolvedWorld, $request->user());
|
||||
$seo = app(SeoFactory::class)->collectionPage(
|
||||
$resolvedWorld->seo_title ?: ($resolvedWorld->title . ' — Skinbase Nova'),
|
||||
$resolvedWorld->seo_description ?: ($resolvedWorld->summary ?: $resolvedWorld->description ?: 'Seasonal and editorial discovery world on Skinbase Nova.'),
|
||||
$this->worlds->canonicalPublicUrl($resolvedWorld),
|
||||
$resolvedWorld->ogImageUrl(),
|
||||
)->toArray();
|
||||
|
||||
return Inertia::render('World/WorldShow', array_merge($payload, [
|
||||
|
||||
Reference in New Issue
Block a user