feat: ship creator journey v2 and profile updates

This commit is contained in:
2026-04-12 21:42:07 +02:00
parent a2457f4e49
commit d5cff21ea2
335 changed files with 20147 additions and 1545 deletions

View File

@@ -6,11 +6,12 @@ namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use App\Models\Artwork;
use App\Models\ContentType;
use App\Services\ArtworkSearchService;
use App\Services\ContentTypes\ContentTypeSlugResolver;
use App\Services\EarlyGrowth\EarlyGrowth;
use App\Services\EarlyGrowth\GridFiller;
use App\Services\EarlyGrowth\SpotlightEngineInterface;
use App\Services\Maturity\ArtworkMaturityService;
use App\Services\ThumbnailPresenter;
use Illuminate\Http\Request;
use Illuminate\Pagination\AbstractCursorPaginator;
@@ -27,8 +28,6 @@ use Illuminate\Support\Facades\Cache;
*/
final class ExploreController extends Controller
{
private const CONTENT_TYPE_SLUGS = ['artworks', 'wallpapers', 'skins', 'photography', 'other'];
/** Meilisearch sort-field arrays per sort alias. */
private const SORT_MAP = [
'trending' => ['trending_score_24h:desc', 'trending_score_7d:desc', 'favorites_count:desc', 'created_at:desc'],
@@ -65,6 +64,8 @@ final class ExploreController extends Controller
private readonly ArtworkSearchService $search,
private readonly GridFiller $gridFiller,
private readonly SpotlightEngineInterface $spotlight,
private readonly ContentTypeSlugResolver $contentTypeResolver,
private readonly ArtworkMaturityService $maturity,
) {}
// ── /explore (hub) ──────────────────────────────────────────────────
@@ -75,13 +76,15 @@ final class ExploreController extends Controller
$perPage = $this->resolvePerPage($request);
$page = max(1, (int) $request->query('page', 1));
$ttl = self::SORT_TTL[$sort] ?? 300;
$cacheVersion = $this->cacheVersion();
$artworks = Cache::remember("explore.all.{$sort}.{$page}", $ttl, fn () =>
Artwork::search('')->options([
$artworks = Cache::remember("explore.all.v{$cacheVersion}.{$sort}.{$page}", $ttl, fn () =>
$this->search->searchWithThumbnailPreference([
'filter' => 'is_public = true AND is_approved = true',
'sort' => self::SORT_MAP[$sort] ?? ['created_at:desc'],
])->paginate($perPage)
], $perPage, false, $page)
);
$artworks = $this->filterBrowsableArtworks($artworks);
// EGS: fill grid to minimum when uploads are sparse
$artworks = $this->gridFiller->fill($artworks, 0, $page);
$artworks->getCollection()->transform(fn ($a) => $this->presentArtwork($a));
@@ -121,35 +124,43 @@ final class ExploreController extends Controller
public function byType(Request $request, string $type)
{
$type = strtolower($type);
if (!in_array($type, self::CONTENT_TYPE_SLUGS, true)) {
$resolution = $this->contentTypeResolver->resolve($type, allowVirtual: true);
if (! $resolution->found()) {
abort(404);
}
// "artworks" is the umbrella — search all types
$isAll = $type === 'artworks';
$isAll = $resolution->isVirtual && $resolution->virtualType === 'artworks';
if (! $isAll && $resolution->contentType === null) {
abort(404);
}
$resolvedTypeSlug = $isAll ? 'artworks' : strtolower((string) $resolution->contentType->slug);
// Canonical URLs for content types are /skins, /wallpapers, /photography, /other.
if (! $isAll) {
return redirect()->to($this->canonicalTypeUrl($request, $type), 301);
return redirect()->to($this->canonicalTypeUrl($request, $resolvedTypeSlug), 301);
}
$sort = $this->resolveSort($request);
$perPage = $this->resolvePerPage($request);
$page = max(1, (int) $request->query('page', 1));
$ttl = self::SORT_TTL[$sort] ?? 300;
$cacheVersion = $this->cacheVersion();
$filter = 'is_public = true AND is_approved = true';
if (!$isAll) {
$filter .= ' AND content_type = "' . $type . '"';
}
$artworks = Cache::remember("explore.{$type}.{$sort}.{$page}", $ttl, fn () =>
Artwork::search('')->options([
$artworks = Cache::remember("explore.{$resolvedTypeSlug}.v{$cacheVersion}.{$sort}.{$page}", $ttl, fn () =>
$this->search->searchWithThumbnailPreference([
'filter' => $filter,
'sort' => self::SORT_MAP[$sort] ?? ['created_at:desc'],
])->paginate($perPage)
], $perPage, false, $page)
);
$artworks = $this->filterBrowsableArtworks($artworks);
// EGS: fill grid to minimum when uploads are sparse
$artworks = $this->gridFiller->fill($artworks, 0, $page);
$artworks->getCollection()->transform(fn ($a) => $this->presentArtwork($a));
@@ -163,7 +174,7 @@ final class ExploreController extends Controller
$contentType = null;
$subcategories = $mainCategories;
if (! $isAll) {
$contentType = ContentType::where('slug', $type)->first();
$contentType = $resolution->contentType;
$subcategories = $contentType
? $contentType->rootCategories()->orderBy('sort_order')->orderBy('name')->get()
: collect();
@@ -172,10 +183,10 @@ final class ExploreController extends Controller
if ($isAll) {
$humanType = 'Artworks';
} else {
$humanType = $contentType?->name ?? ucfirst($type);
$humanType = $contentType?->name ?? ucfirst($resolvedTypeSlug);
}
$baseUrl = url('/explore/' . $type);
$baseUrl = url('/explore/' . $resolvedTypeSlug);
$seo = $this->paginationSeo($request, $baseUrl, $artworks);
return view('gallery.index', [
@@ -192,11 +203,11 @@ final class ExploreController extends Controller
'hero_description' => "Browse {$humanType} on Skinbase.",
'breadcrumbs' => collect([
(object) ['name' => 'Explore', 'url' => '/explore'],
(object) ['name' => $humanType, 'url' => "/explore/{$type}"],
(object) ['name' => $humanType, 'url' => "/explore/{$resolvedTypeSlug}"],
]),
'page_title' => "{$humanType} - Explore - Skinbase",
'page_meta_description' => "Discover the best {$humanType} artworks on Skinbase. Browse trending, new and top-rated.",
'page_meta_keywords' => strtolower($type) . ', explore, skinbase, artworks, wallpapers, skins, photography',
'page_meta_keywords' => strtolower($resolvedTypeSlug) . ', explore, skinbase, artworks, wallpapers, skins, photography',
'page_canonical' => $seo['canonical'],
'page_rel_prev' => $seo['prev'],
'page_rel_next' => $seo['next'],
@@ -208,12 +219,17 @@ final class ExploreController extends Controller
public function byTypeMode(Request $request, string $type, string $mode)
{
$type = strtolower($type);
if ($type !== 'artworks') {
$resolution = $this->contentTypeResolver->resolve($type, allowVirtual: true);
if (! $resolution->found()) {
abort(404);
}
if (! ($resolution->isVirtual && $resolution->virtualType === 'artworks')) {
$query = $request->query();
$query['sort'] = $this->normalizeSort((string) $mode);
return redirect()->to($this->canonicalTypeUrl($request, $type, $query), 301);
return redirect()->to($this->canonicalTypeUrl($request, strtolower((string) $resolution->contentType?->slug), $query), 301);
}
// Rewrite the sort via the URL segment and delegate
@@ -225,8 +241,8 @@ final class ExploreController extends Controller
private function mainCategories(): Collection
{
$categories = ContentType::ordered()
->get(['name', 'slug'])
$categories = $this->contentTypeResolver
->publicContentTypes()
->map(fn ($ct) => (object) [
'name' => $ct->name,
'slug' => $ct->slug,
@@ -272,6 +288,26 @@ final class ExploreController extends Controller
return max(12, min($v, 80));
}
private function cacheVersion(): int
{
return max(1, (int) Cache::get('explore.cache.version', 1));
}
private function filterBrowsableArtworks(AbstractPaginator $paginator): AbstractPaginator
{
$paginator->setCollection(
$paginator->getCollection()
->filter(fn ($artwork) => $artwork instanceof Artwork
&& $artwork->deleted_at === null
&& (bool) $artwork->is_public
&& (bool) $artwork->is_approved
&& $artwork->published_at !== null)
->values()
);
return $paginator;
}
private function presentArtwork(Artwork $artwork): object
{
$primary = $artwork->categories->sortBy('sort_order')->first();
@@ -289,7 +325,7 @@ final class ExploreController extends Controller
$username = $isGroupPublisher ? '' : ($artwork->user?->username ?? '');
$profileUrl = $isGroupPublisher ? $group->publicUrl() : ($username !== '' ? '/@' . $username : null);
return (object) [
return (object) $this->maturity->decoratePayload([
'id' => $artwork->id,
'name' => $artwork->title,
'content_type_name' => $primary?->contentType?->name ?? '',
@@ -314,7 +350,7 @@ final class ExploreController extends Controller
'slug' => $artwork->slug ?? '',
'width' => $artwork->width ?? null,
'height' => $artwork->height ?? null,
];
], $artwork, request()->user());
}
private function paginationSeo(Request $request, string $base, mixed $paginator): array