feat: ship creator journey v2 and profile updates
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user