Wire admin studio SSR and search infrastructure

This commit is contained in:
2026-05-01 11:46:06 +02:00
parent 257b0dbef6
commit 18cea8b0f0
329 changed files with 197465 additions and 2741 deletions

View File

@@ -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. */

View File

@@ -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,

View File

@@ -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'),

View File

@@ -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');

View File

@@ -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', '')));

View File

@@ -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()
);

View File

@@ -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,
]);
}
}

View File

@@ -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,
]);
}
}

View File

@@ -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,
]);
}
}

View File

@@ -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,
]);
}
}

View File

@@ -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,
]);
}
}

View File

@@ -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);
}
}

View File

@@ -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) . ')';
}

View File

@@ -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,

View File

@@ -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, [