Files
SkinbaseNova/app/Http/Controllers/ArtworkController.php
Gregor Klevze eee7df1f8c feat: artwork page carousels, recommendations, avatars & fixes
- Infinite loop carousels for Similar Artworks & Trending rails
- Mouse wheel horizontal scrolling on both carousels
- Author avatar shown on hover in RailCard (similar + trending)
- Removed "View" badge from RailCard hover overlay
- Added `id` to Meilisearch filterable attributes
- Auto-prepend Scout prefix in meilisearch:configure-index command
- Added author name + avatar to Similar Artworks API response
- Added avatar_url to ArtworkListResource author object
- Added direct /art/{id}/{slug} URL to ArtworkListResource
- Fixed race condition: Similar Artworks no longer briefly shows trending items
- Fixed user_profiles eager load (user_id primary key, not id)
- Bumped /api/art/{id}/similar rate limit to 300/min
- Removed decorative heart icons from tag pills
- Moved ReactionBar under artwork description
2026-02-28 14:05:39 +01:00

181 lines
7.0 KiB
PHP

<?php
namespace App\Http\Controllers;
use App\Http\Controllers\Web\BrowseGalleryController;
use App\Http\Controllers\Controller;
use App\Http\Requests\ArtworkIndexRequest;
use App\Models\Artwork;
use App\Models\Category;
use App\Services\Recommendations\SimilarArtworksService;
use Illuminate\Http\Request;
use Illuminate\View\View;
class ArtworkController extends Controller
{
/**
* Browse artworks with optional category filtering.
* Uses cursor pagination (no offset pagination) and only returns public, approved, not-deleted items.
*/
public function index(ArtworkIndexRequest $request, ?Category $category = null): View
{
$perPage = (int) ($request->get('per_page', 24));
$query = Artwork::public()->published();
if ($category) {
$query->whereHas('categories', function ($q) use ($category) {
$q->where('categories.id', $category->id);
});
}
if ($request->filled('q')) {
$q = $request->get('q');
$query->where(function ($sub) use ($q) {
$sub->where('title', 'like', '%' . $q . '%')
->orWhere('description', 'like', '%' . $q . '%');
});
}
$sort = $request->get('sort', 'latest');
if ($sort === 'oldest') {
$query->orderBy('published_at', 'asc');
} else {
$query->orderByDesc('published_at');
}
// Important: do NOT eager-load artwork_stats in listings
$artworks = $query->cursorPaginate($perPage);
return view('artworks.index', [
'artworks' => $artworks,
'category' => $category,
]);
}
/**
* Show a single artwork by slug. Resolve the slug manually to avoid implicit
* route-model binding exceptions when the slug does not correspond to an artwork.
*/
public function show(Request $request, string $contentTypeSlug, string $categoryPath, $artwork = null)
{
// Manually resolve artwork by slug when provided. The route may bind
// the 'artwork' parameter to an Artwork model or pass the slug string.
$foundArtwork = null;
$artworkSlug = null;
if ($artwork instanceof Artwork) {
$foundArtwork = $artwork;
$artworkSlug = $artwork->slug;
} elseif ($artwork) {
$artworkSlug = (string) $artwork;
$foundArtwork = Artwork::where('slug', $artworkSlug)->first();
}
// When the URL can represent a nested category path (e.g. /skins/audio/winamp),
// prefer category rendering over artwork slug collisions so same-level groups
// behave consistently.
if (! empty($artworkSlug)) {
$combinedPath = trim($categoryPath . '/' . $artworkSlug, '/');
$resolvedCategory = Category::findByPath($contentTypeSlug, $combinedPath);
if ($resolvedCategory) {
return app(BrowseGalleryController::class)->content(request(), $contentTypeSlug, $combinedPath);
}
}
// If no artwork was found, treat the request as a category path.
// The route places the artwork slug in the last segment, so include it.
// Delegate to BrowseGalleryController to render the same modern gallery
// layout used by routes like /skins/audio.
if (! $foundArtwork) {
$combinedPath = $categoryPath;
if ($artworkSlug) {
$combinedPath = trim($categoryPath . '/' . $artworkSlug, '/');
}
return app(BrowseGalleryController::class)->content(request(), $contentTypeSlug, $combinedPath);
}
if (! $foundArtwork->is_public || ! $foundArtwork->is_approved || $foundArtwork->trashed()) {
abort(404);
}
$foundArtwork->loadMissing(['categories.contentType', 'user']);
$defaultAlgoVersion = (string) config('recommendations.embedding.algo_version', 'clip-cosine-v1');
$selectedAlgoVersion = $this->selectAlgoVersionForRequest($request, $defaultAlgoVersion);
$similarService = app(SimilarArtworksService::class);
$similarArtworks = $similarService->forArtwork((int) $foundArtwork->id, 12, $selectedAlgoVersion);
if ($similarArtworks->isEmpty() && $selectedAlgoVersion !== $defaultAlgoVersion) {
$similarArtworks = $similarService->forArtwork((int) $foundArtwork->id, 12, $defaultAlgoVersion);
$selectedAlgoVersion = $defaultAlgoVersion;
}
$similarArtworks->each(static function (Artwork $item): void {
$item->loadMissing(['categories.contentType', 'user']);
});
$similarItems = $similarArtworks
->map(function (Artwork $item): ?array {
$category = $item->categories->first();
$contentType = $category?->contentType;
if (! $category || ! $contentType || empty($item->slug)) {
return null;
}
return [
'id' => (int) $item->id,
'title' => html_entity_decode((string) $item->title, ENT_QUOTES | ENT_HTML5, 'UTF-8'),
'author' => html_entity_decode((string) optional($item->user)->name, ENT_QUOTES | ENT_HTML5, 'UTF-8'),
'thumb' => (string) ($item->thumb_url ?? $item->thumb ?? '/gfx/sb_join.jpg'),
'thumb_srcset' => (string) ($item->thumb_srcset ?? ''),
'url' => route('artworks.show', [
'contentTypeSlug' => (string) $contentType->slug,
'categoryPath' => (string) $category->slug,
'artwork' => (string) $item->slug,
]),
];
})
->filter()
->values();
return view('artworks.show', [
'artwork' => $foundArtwork,
'similarItems' => $similarItems,
'similarAlgoVersion' => $selectedAlgoVersion,
]);
}
private function selectAlgoVersionForRequest(Request $request, string $default): string
{
$configured = (array) config('recommendations.ab.algo_versions', []);
$versions = array_values(array_filter(array_map(static fn ($value): string => trim((string) $value), $configured)));
if ($versions === []) {
return $default;
}
if (! in_array($default, $versions, true)) {
array_unshift($versions, $default);
$versions = array_values(array_unique($versions));
}
$forced = trim((string) $request->query('algo_version', ''));
if ($forced !== '' && in_array($forced, $versions, true)) {
return $forced;
}
if (count($versions) === 1) {
return $versions[0];
}
$visitorKey = $request->user()?->id
? 'u:' . (string) $request->user()->id
: 's:' . (string) $request->session()->getId();
$bucket = abs(crc32($visitorKey)) % count($versions);
return $versions[$bucket] ?? $default;
}
}