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

@@ -12,6 +12,7 @@ use App\Services\ContentSanitizer;
use App\Services\ThumbnailPresenter;
use App\Services\ErrorSuggestionService;
use App\Services\GroupService;
use App\Services\Maturity\ArtworkMaturityService;
use App\Support\Seo\SeoFactory;
use App\Support\AvatarUrl;
use Illuminate\Support\Carbon;
@@ -23,7 +24,10 @@ use Illuminate\View\View;
final class ArtworkPageController extends Controller
{
public function __construct(private readonly GroupService $groups) {}
public function __construct(
private readonly GroupService $groups,
private readonly ArtworkMaturityService $maturity,
) {}
public function show(Request $request, int $id, ?string $slug = null): View|RedirectResponse|Response
{
@@ -145,6 +149,7 @@ final class ArtworkPageController extends Controller
->whereKeyNot($artwork->id)
->public()
->published()
->tap(fn ($builder) => $this->maturity->applyViewerFilter($builder, $request->user()))
->where(function ($query) use ($artwork, $categoryIds, $tagIds): void {
$query->where('user_id', $artwork->user_id);
@@ -176,14 +181,14 @@ final class ArtworkPageController extends Controller
$md = ThumbnailPresenter::present($item, 'md');
$lg = ThumbnailPresenter::present($item, 'lg');
return [
return $this->maturity->decoratePayload([
'id' => (int) $item->id,
'title' => html_entity_decode((string) $item->title, ENT_QUOTES | ENT_HTML5, 'UTF-8'),
'author' => html_entity_decode((string) ($item->group?->name ?: $item->user?->name ?: $item->user?->username ?: 'Artist'), ENT_QUOTES | ENT_HTML5, 'UTF-8'),
'url' => route('art.show', ['id' => $item->id, 'slug' => $itemSlug]),
'thumb' => $md['url'] ?? null,
'thumb_srcset' => ($md['url'] ?? '') . ' 640w, ' . ($lg['url'] ?? '') . ' 1280w',
];
], $item, request()->user());
})
->values()
->all();

View File

@@ -7,7 +7,10 @@ use App\Models\ContentType;
use App\Models\Artwork;
use App\Services\ArtworkSearchService;
use App\Services\ArtworkService;
use App\Services\ContentTypes\ContentTypeSlugResolver;
use App\Services\Maturity\ArtworkMaturityService;
use App\Services\ThumbnailPresenter;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
@@ -17,8 +20,6 @@ use Illuminate\Pagination\AbstractCursorPaginator;
class BrowseGalleryController extends \App\Http\Controllers\Controller
{
private const CONTENT_TYPE_SLUGS = ['photography', 'wallpapers', 'skins', 'other', 'digital-art'];
/**
* Meilisearch sort-field arrays per sort alias.
* First element is primary sort; subsequent elements are tie-breakers.
@@ -74,6 +75,8 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
public function __construct(
private ArtworkService $artworks,
private ArtworkSearchService $search,
private ContentTypeSlugResolver $contentTypeResolver,
private ArtworkMaturityService $maturity,
) {
}
@@ -121,14 +124,18 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
public function content(Request $request, string $contentTypeSlug, ?string $path = null)
{
$contentSlug = strtolower($contentTypeSlug);
if (! in_array($contentSlug, self::CONTENT_TYPE_SLUGS, true)) {
$requestedSlug = strtolower($contentTypeSlug);
$resolution = $this->contentTypeResolver->resolve($requestedSlug);
if (! $resolution->found() || $resolution->contentType === null) {
abort(404);
}
$contentType = ContentType::where('slug', $contentSlug)->first();
if (! $contentType) {
abort(404);
$contentType = $resolution->contentType;
$contentSlug = strtolower((string) $contentType->slug);
if ($resolution->requiresRedirect()) {
return $this->redirectToContentTypePath($request, $contentSlug, $path, 301);
}
// Default sort: trending (not chronological)
@@ -265,12 +272,25 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
$contentTypeSlug = strtolower((string) $contentTypeSlug);
$categoryPath = $categoryPath !== null ? trim((string) $categoryPath, '/') : (isset($pathSegments[1]) ? implode('/', array_slice($pathSegments, 1, max(0, count($pathSegments) - 2))) : '');
$resolution = $this->contentTypeResolver->resolve($contentTypeSlug);
if (! $resolution->found() || $resolution->contentType === null) {
abort(404);
}
$resolvedContentTypeSlug = strtolower((string) $resolution->contentType->slug);
// Normalize artwork param if route-model binding returned an Artwork model
$artworkSlug = $artwork instanceof Artwork ? (string) $artwork->slug : (string) $artwork;
if ($resolution->requiresRedirect()) {
$path = trim($categoryPath . '/' . $artworkSlug, '/');
return $this->redirectToContentTypePath($req, $resolvedContentTypeSlug, $path, 301);
}
return app(\App\Http\Controllers\ArtworkController::class)->show(
$req,
$contentTypeSlug,
$resolvedContentTypeSlug,
$categoryPath,
$artworkSlug
);
@@ -293,7 +313,7 @@ class BrowseGalleryController extends \App\Http\Controllers\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' => $primaryCategory?->contentType?->name ?? '',
@@ -317,7 +337,7 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
'published_at' => $artwork->published_at,
'width' => $artwork->width ?? null,
'height' => $artwork->height ?? null,
];
], $artwork, request()->user());
}
/**
@@ -372,9 +392,8 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
private function mainCategories(): Collection
{
return ContentType::ordered()
->whereIn('slug', self::CONTENT_TYPE_SLUGS)
->get(['name', 'slug'])
return $this->contentTypeResolver
->publicContentTypes()
->map(function (ContentType $type) {
return (object) [
'id' => $type->id,
@@ -385,6 +404,18 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
});
}
private function redirectToContentTypePath(Request $request, string $contentTypeSlug, ?string $path = null, int $status = 301): RedirectResponse
{
$target = url('/' . trim($contentTypeSlug . '/' . trim((string) $path, '/'), '/'));
$queryString = $request->getQueryString();
if ($queryString) {
$target .= '?' . $queryString;
}
return redirect()->to($target, $status);
}
private function buildPaginationSeo(Request $request, string $canonicalBaseUrl, mixed $paginator): array
{
$canonicalQuery = $request->query();

View File

@@ -5,13 +5,14 @@ namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use App\Models\Artwork;
use App\Services\ArtworkService;
use App\Services\Maturity\ArtworkMaturityService;
use Illuminate\Http\Request;
class DailyUploadsController extends Controller
{
protected ArtworkService $artworks;
public function __construct(ArtworkService $artworks)
public function __construct(ArtworkService $artworks, private readonly ArtworkMaturityService $maturity)
{
$this->artworks = $artworks;
}
@@ -76,11 +77,11 @@ class DailyUploadsController extends Controller
private function prepareArts($ars)
{
return $ars->map(function (Artwork $ar) {
$items = $ars->map(function (Artwork $ar): array {
$primaryCategory = $ar->categories->sortBy('sort_order')->first();
$present = \App\Services\ThumbnailPresenter::present($ar, 'md');
return (object) [
return $this->maturity->decoratePayload([
'id' => $ar->id,
'name' => $ar->title,
'thumb' => $present['url'],
@@ -88,7 +89,11 @@ class DailyUploadsController extends Controller
'gid_num' => $primaryCategory ? ((int) $primaryCategory->id % 5) * 5 : 0,
'category_name' => $primaryCategory->name ?? '',
'uname' => $ar->user->name ?? 'Skinbase',
];
});
], $ar, request()->user());
})->values()->all();
return collect($this->maturity->filterPayloadItems($items, request()->user()))
->map(static fn (array $item): object => (object) $item)
->values();
}
}

View File

@@ -9,10 +9,12 @@ use App\Services\ArtworkSearchService;
use App\Services\ArtworkService;
use App\Services\EarlyGrowth\AdaptiveTimeWindow;
use App\Services\EarlyGrowth\GridFiller;
use App\Services\Maturity\ArtworkMaturityService;
use App\Services\Recommendations\RecommendationFeedResolver;
use App\Services\UserSuggestionService;
use App\Services\ThumbnailPresenter;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
@@ -38,6 +40,7 @@ final class DiscoverController extends Controller
private readonly GridFiller $gridFiller,
private readonly CommunityActivityService $communityActivity,
private readonly UserSuggestionService $userSuggestions,
private readonly ArtworkMaturityService $maturity,
) {}
// ─── /discover/trending ──────────────────────────────────────────────────
@@ -178,6 +181,7 @@ final class DiscoverController extends Controller
->whereRaw('MONTH(published_at) = ?', [$today->month])
->whereRaw('DAY(published_at) = ?', [$today->day])
->whereRaw('YEAR(published_at) < ?', [$today->year])
->orderMissingThumbnailsLast()
->orderByDesc('published_at')
->paginate($perPage)
->withQueryString();
@@ -270,7 +274,8 @@ final class DiscoverController extends Controller
$artworks = collect($feedResult['data'] ?? [])->map(
fn (array $item) => $this->presentRecommendedArtwork($item)
)->values();
);
$artworks = $this->reorderDiscoverItemsByThumbnailHealth($artworks)->values();
$meta = $feedResult['meta'] ?? [];
$nextCursor = $meta['next_cursor'] ?? null;
@@ -345,6 +350,7 @@ final class DiscoverController extends Controller
->published()
->with(['user:id,name,username', 'categories:id,name,slug,content_type_id,parent_id,sort_order'])
->whereIn('user_id', $followingIds)
->orderMissingThumbnailsLast()
->orderByDesc('published_at')
->paginate($perPage)
->withQueryString();
@@ -416,6 +422,7 @@ final class DiscoverController extends Controller
'categories:id,name,slug,content_type_id,parent_id,sort_order',
'categories.contentType:id,slug,name',
])
->orderMissingThumbnailsLast()
->orderByDesc('published_at')
->orderByDesc('id')
->paginate($perPage)
@@ -438,6 +445,7 @@ final class DiscoverController extends Controller
->leftJoin('artwork_stats as discover_stats', 'discover_stats.artwork_id', '=', 'artworks.id')
->select('artworks.*')
->where('artworks.published_at', '>=', $cutoff)
->orderMissingThumbnailsLast()
->orderByDesc('discover_stats.ranking_score')
->orderByDesc('discover_stats.engagement_velocity')
->orderByDesc('discover_stats.views')
@@ -465,6 +473,7 @@ final class DiscoverController extends Controller
->selectRaw('COALESCE(discover_stats.heat_score, 0) as heat_score')
->selectRaw('COALESCE(discover_stats.engagement_velocity, 0) as engagement_velocity')
->where('artworks.published_at', '>=', $cutoff)
->orderMissingThumbnailsLast()
->orderByDesc('discover_stats.heat_score')
->orderByDesc('discover_stats.engagement_velocity')
->orderByDesc('artworks.published_at')
@@ -496,6 +505,7 @@ final class DiscoverController extends Controller
->selectRaw('COALESCE(discover_stats.engagement_velocity, 0) as engagement_velocity')
->selectRaw('COALESCE(recent_rising_activity.recent_signal_24h, 0) as recent_signal_24h')
->where('artworks.published_at', '>=', $cutoff)
->orderMissingThumbnailsLast()
->orderByDesc('recent_signal_24h')
->orderByDesc('artworks.published_at')
->orderByDesc('artworks.id')
@@ -599,7 +609,7 @@ final class DiscoverController 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' => $primaryCategory?->contentType?->name ?? '',
@@ -624,7 +634,7 @@ final class DiscoverController extends Controller
'published_at' => $artwork->published_at,
'width' => $artwork->width ?? null,
'height' => $artwork->height ?? null,
];
], $artwork, request()->user());
}
/**
@@ -676,6 +686,7 @@ final class DiscoverController extends Controller
->whereIn('user_id', $followingIds)
->where('published_at', '>=', now()->subDays(30))
->leftJoin('artwork_stats as ast', 'ast.artwork_id', '=', 'artworks.id')
->orderMissingThumbnailsLast()
->orderByDesc(DB::raw('COALESCE(ast.heat_score, 0)'))
->orderByDesc(DB::raw('COALESCE(ast.favorites, 0)'))
->orderByDesc('artworks.published_at')
@@ -703,4 +714,42 @@ final class DiscoverController extends Controller
->values()
->all();
}
/**
* @param Collection<int, object> $items
* @return Collection<int, object>
*/
private function reorderDiscoverItemsByThumbnailHealth(Collection $items): Collection
{
if ($items->isEmpty()) {
return $items;
}
$ids = $items
->pluck('id')
->filter(fn ($id) => is_numeric($id) && (int) $id > 0)
->map(fn ($id) => (int) $id)
->values();
if ($ids->isEmpty()) {
return $items;
}
$missingIds = Artwork::query()
->whereIn('id', $ids)
->where('has_missing_thumbnails', true)
->pluck('id')
->map(fn ($id) => (int) $id)
->flip();
if ($missingIds->isEmpty()) {
return $items;
}
$healthy = $items->reject(fn ($item) => $missingIds->has((int) ($item->id ?? 0)));
return $healthy
->concat($items->filter(fn ($item) => $missingIds->has((int) ($item->id ?? 0))))
->values();
}
}

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

View File

@@ -5,6 +5,7 @@ namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use App\Models\Artwork;
use App\Services\ArtworkService;
use App\Services\Maturity\ArtworkMaturityService;
use Illuminate\Http\Request;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Str;
@@ -13,7 +14,7 @@ class FeaturedArtworksController extends Controller
{
protected ArtworkService $artworks;
public function __construct(ArtworkService $artworks)
public function __construct(ArtworkService $artworks, private readonly ArtworkMaturityService $maturity)
{
$this->artworks = $artworks;
}
@@ -29,7 +30,8 @@ class FeaturedArtworksController extends Controller
/** @var LengthAwarePaginator $artworks */
$artworks = $this->artworks->getFeaturedArtworks($typeFilter, $perPage);
$artworks->getCollection()->transform(function (Artwork $artwork) {
$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 ?? '';
@@ -37,7 +39,7 @@ class FeaturedArtworksController extends Controller
$present = \App\Services\ThumbnailPresenter::present($artwork, 'md');
$username = $artwork->user->username ?? $artwork->user->name ?? 'Skinbase';
return (object) [
return $this->maturity->decoratePayload([
'id' => $artwork->id,
'name' => $artwork->title,
'slug' => $artwork->slug,
@@ -53,8 +55,11 @@ class FeaturedArtworksController extends Controller
'height' => $artwork->height,
'uname' => $artwork->user->name ?? 'Skinbase',
'username' => $username,
];
});
], $artwork, $request->user());
})->values()->all(), $request->user()))
->map(static fn (array $item): object => (object) $item)
->values()
);
$artworkTypes = [
1 => 'Bronze Awards',

View File

@@ -6,7 +6,7 @@ namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use App\Models\Artwork;
use App\Models\ContentType;
use App\Services\ContentTypes\ContentTypeSlugResolver;
use Illuminate\Http\Response;
use Illuminate\View\View;
@@ -26,52 +26,6 @@ final class RssFeedController extends Controller
/** Number of items per legacy feed. */
private const FEED_LIMIT = 25;
/**
* Grouped feed definitions shown on the /rss-feeds info page.
* Each group has a 'label' and an array of 'feeds' with title + url.
*/
public const FEED_GROUPS = [
'global' => [
'label' => 'Global',
'feeds' => [
['title' => 'Latest Artworks', 'url' => '/rss', 'description' => 'All new artworks across the platform.'],
],
],
'discover' => [
'label' => 'Discover',
'feeds' => [
['title' => 'Fresh Uploads', 'url' => '/rss/discover/fresh', 'description' => 'The newest artworks just published.'],
['title' => 'Trending', 'url' => '/rss/discover/trending', 'description' => 'Most-viewed artworks over the past 7 days.'],
['title' => 'Rising', 'url' => '/rss/discover/rising', 'description' => 'Artworks gaining momentum right now.'],
],
],
'explore' => [
'label' => 'Explore',
'feeds' => [
['title' => 'All Artworks', 'url' => '/rss/explore/artworks', 'description' => 'Latest artworks of all types.'],
['title' => 'Wallpapers', 'url' => '/rss/explore/wallpapers', 'description' => 'Latest wallpapers.'],
['title' => 'Skins', 'url' => '/rss/explore/skins', 'description' => 'Latest skins.'],
['title' => 'Photography', 'url' => '/rss/explore/photography', 'description' => 'Latest photography.'],
['title' => 'Trending Wallpapers', 'url' => '/rss/explore/wallpapers/trending', 'description' => 'Trending wallpapers this week.'],
],
],
'blog' => [
'label' => 'Blog',
'feeds' => [
['title' => 'Blog Posts', 'url' => '/rss/blog', 'description' => 'Latest posts from the Skinbase blog.'],
],
],
'legacy' => [
'label' => 'Legacy Feeds',
'feeds' => [
['title' => 'Latest Uploads (XML)', 'url' => '/rss/latest-uploads.xml', 'description' => 'Legacy XML feed.'],
['title' => 'Latest Skins (XML)', 'url' => '/rss/latest-skins.xml', 'description' => 'Legacy XML feed.'],
['title' => 'Latest Wallpapers (XML)', 'url' => '/rss/latest-wallpapers.xml', 'description' => 'Legacy XML feed.'],
['title' => 'Latest Photos (XML)', 'url' => '/rss/latest-photos.xml', 'description' => 'Legacy XML feed.'],
],
],
];
/** Flat feed list kept for backward-compatibility (old view logic). */
public const FEEDS = [
'uploads' => ['title' => 'Latest Uploads', 'url' => '/rss/latest-uploads.xml'],
@@ -80,6 +34,10 @@ final class RssFeedController extends Controller
'photos' => ['title' => 'Latest Photos', 'url' => '/rss/latest-photos.xml'],
];
public function __construct(private readonly ContentTypeSlugResolver $contentTypeResolver)
{
}
/** Info page at /rss-feeds */
public function index(): View
{
@@ -94,7 +52,7 @@ final class RssFeedController extends Controller
(object) ['name' => 'RSS Feeds', 'url' => '/rss-feeds'],
]),
'feeds' => self::FEEDS,
'feed_groups' => self::FEED_GROUPS,
'feed_groups' => $this->feedGroups(),
'center_content' => true,
'center_max' => '3xl',
]);
@@ -134,7 +92,7 @@ final class RssFeedController extends Controller
private function feedByContentType(string $slug, string $title, string $feedPath): Response
{
$contentType = ContentType::where('slug', $slug)->first();
$contentType = $this->contentTypeResolver->resolve($slug)->contentType;
$query = Artwork::published()->with(['user'])->latest('published_at')->limit(self::FEED_LIMIT);
@@ -160,4 +118,70 @@ final class RssFeedController extends Controller
'Content-Type' => 'application/rss+xml; charset=utf-8',
]);
}
private function feedGroups(): array
{
$exploreFeeds = [[
'title' => 'All Artworks',
'url' => '/rss/explore/artworks',
'description' => 'Latest artworks of all types.',
]];
foreach ($this->contentTypeResolver->publicContentTypes() as $contentType) {
$name = (string) $contentType->name;
$slug = (string) $contentType->slug;
$exploreFeeds[] = [
'title' => $name,
'url' => '/rss/explore/' . $slug,
'description' => 'Latest ' . strtolower($name) . '.',
];
}
if ($this->contentTypeResolver->publicContentTypes()->isNotEmpty()) {
$firstType = $this->contentTypeResolver->publicContentTypes()->first();
$exploreFeeds[] = [
'title' => 'Trending ' . $firstType->name,
'url' => '/rss/explore/' . $firstType->slug . '/trending',
'description' => 'Trending ' . strtolower((string) $firstType->name) . ' this week.',
];
}
return [
'global' => [
'label' => 'Global',
'feeds' => [
['title' => 'Latest Artworks', 'url' => '/rss', 'description' => 'All new artworks across the platform.'],
],
],
'discover' => [
'label' => 'Discover',
'feeds' => [
['title' => 'Fresh Uploads', 'url' => '/rss/discover/fresh', 'description' => 'The newest artworks just published.'],
['title' => 'Trending', 'url' => '/rss/discover/trending', 'description' => 'Most-viewed artworks over the past 7 days.'],
['title' => 'Rising', 'url' => '/rss/discover/rising', 'description' => 'Artworks gaining momentum right now.'],
],
],
'explore' => [
'label' => 'Explore',
'feeds' => $exploreFeeds,
],
'blog' => [
'label' => 'Blog',
'feeds' => [
['title' => 'Blog Posts', 'url' => '/rss/blog', 'description' => 'Latest posts from the Skinbase blog.'],
],
],
'legacy' => [
'label' => 'Legacy Feeds',
'feeds' => [
['title' => 'Latest Uploads (XML)', 'url' => '/rss/latest-uploads.xml', 'description' => 'Legacy XML feed.'],
['title' => 'Latest Skins (XML)', 'url' => '/rss/latest-skins.xml', 'description' => 'Legacy XML feed.'],
['title' => 'Latest Wallpapers (XML)', 'url' => '/rss/latest-wallpapers.xml', 'description' => 'Legacy XML feed.'],
['title' => 'Latest Photos (XML)', 'url' => '/rss/latest-photos.xml', 'description' => 'Legacy XML feed.'],
],
],
];
}
}

View File

@@ -22,6 +22,7 @@ final class SearchController extends Controller
{
$q = trim((string) $request->query('q', ''));
$sort = $request->query('sort', 'latest');
$hasQuery = $q !== '';
$sortMap = [
'popular' => 'views:desc',
@@ -30,17 +31,17 @@ final class SearchController extends Controller
'downloads' => 'downloads:desc',
];
$artworks = $q !== ''
$artworks = $hasQuery
? $this->search->search($q, [
'sort' => ($sortMap[$sort] ?? 'created_at:desc'),
])
: $this->search->popular(24);
$groups = $q !== ''
$groups = $hasQuery
? $this->groups->searchCards($q, $request->user(), 6)
: $this->groups->surfaceCards($request->user(), 'featured', 4);
$news = $q !== ''
$news = $hasQuery
? NewsArticle::query()
->with(['author:id,username,name', 'category:id,name,slug'])
->published()
@@ -55,15 +56,59 @@ final class SearchController extends Controller
->get()
: collect();
$groupResults = collect($groups ?? []);
$newsResults = collect($news ?? []);
$resultCount = method_exists($artworks, 'total') ? (int) $artworks->total() : 0;
$groupResultCount = $groupResults->count();
$newsResultCount = $newsResults->count();
$hasAnyResults = $resultCount > 0 || $groupResultCount > 0 || $newsResultCount > 0;
$galleryArtworks = collect(method_exists($artworks, 'items') ? $artworks->items() : $artworks)
->map(fn ($art) => $this->mapArtworkCard($art))
->values();
$galleryNextPageUrl = method_exists($artworks, 'nextPageUrl') ? $artworks->nextPageUrl() : null;
return view('search.index', [
'q' => $q,
'hasQuery' => $hasQuery,
'sort' => $sort,
'groups' => $groups,
'groupResults' => $groupResults,
'groupResultCount' => $groupResultCount,
'artworks' => $artworks,
'resultCount' => $resultCount,
'news' => $news,
'page_title' => $q !== '' ? 'Search: ' . $q . ' — Skinbase' : 'Search — Skinbase',
'newsResults' => $newsResults,
'newsResultCount' => $newsResultCount,
'hasAnyResults' => $hasAnyResults,
'galleryArtworks' => $galleryArtworks,
'galleryNextPageUrl' => $galleryNextPageUrl,
'page_title' => $hasQuery ? 'Search: ' . $q . ' — Skinbase' : 'Search — Skinbase',
'page_meta_description' => 'Search Skinbase for artworks, creators, groups, photography, wallpapers and skins.',
'page_robots' => 'noindex,follow',
]);
}
private function mapArtworkCard(mixed $artwork): array
{
return [
'id' => $artwork->id ?? null,
'name' => $artwork->name ?? null,
'thumb' => $artwork->thumb_url ?? $artwork->thumb ?? null,
'thumb_srcset' => $artwork->thumb_srcset ?? null,
'uname' => $artwork->uname ?? '',
'username' => $artwork->username ?? '',
'avatar_url' => $artwork->avatar_url ?? null,
'profile_url' => $artwork->profile_url ?? null,
'published_as_type' => $artwork->published_as_type ?? null,
'publisher' => $artwork->publisher ?? null,
'category_name' => $artwork->category_name ?? '',
'category_slug' => $artwork->category_slug ?? '',
'slug' => $artwork->slug ?? '',
'width' => $artwork->width ?? null,
'height' => $artwork->height ?? null,
'views' => $artwork->views ?? null,
'likes' => $artwork->likes ?? null,
'downloads' => $artwork->downloads ?? null,
];
}
}

View File

@@ -6,6 +6,7 @@ namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use App\Models\Artwork;
use App\Services\Maturity\ArtworkMaturityService;
use App\Services\Recommendations\HybridSimilarArtworksService;
use App\Services\ThumbnailPresenter;
use App\Services\Vision\VectorService;
@@ -35,6 +36,7 @@ final class SimilarArtworksPageController extends Controller
public function __construct(
private readonly VectorService $vectors,
private readonly ArtworkMaturityService $maturity,
private readonly HybridSimilarArtworksService $hybridService,
) {}
@@ -70,6 +72,7 @@ final class SimilarArtworksPageController extends Controller
'thumb_srcset' => $sourceMd['srcset'] ?? $sourceMd['url'] ?? null,
'author_name' => $source->user?->name ?? 'Artist',
'author_username' => $source->user?->username ?? '',
'author_profile_url'=> $source->user?->username ? '/@' . $source->user->username : null,
'author_avatar' => AvatarUrl::forUser(
(int) ($source->user_id ?? 0),
$source->user?->profile?->avatar_hash ?? null,
@@ -79,6 +82,7 @@ final class SimilarArtworksPageController extends Controller
'category_slug' => $primaryCat?->slug ?? '',
'content_type_name' => $primaryCat?->contentType?->name ?? '',
'content_type_slug' => $primaryCat?->contentType?->slug ?? '',
'browse_url' => $primaryCat?->contentType?->slug ? url('/' . $primaryCat->contentType->slug) : url('/explore'),
'tag_slugs' => $source->tags->pluck('slug')->take(5)->all(),
'width' => $source->width ?? null,
'height' => $source->height ?? null,
@@ -144,8 +148,11 @@ final class SimilarArtworksPageController extends Controller
'slug' => $art->slug ?? '',
'width' => $art->width ?? null,
'height' => $art->height ?? null,
'maturity' => $art->maturity ?? null,
])->values();
$galleryItems = collect($this->maturity->filterPayloadItems($galleryItems->all(), $request->user()))->values();
return response()->json([
'data' => $galleryItems,
'similarity_source' => $similaritySource,
@@ -303,7 +310,7 @@ final class SimilarArtworksPageController 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 ?? '',
@@ -328,6 +335,6 @@ final class SimilarArtworksPageController extends Controller
'slug' => $artwork->slug ?? '',
'width' => $artwork->width ?? null,
'height' => $artwork->height ?? null,
];
], $artwork, request()->user());
}
}

View File

@@ -8,6 +8,7 @@ use App\Http\Controllers\Controller;
use App\Models\ContentType;
use App\Models\Tag;
use App\Services\ArtworkSearchService;
use App\Services\Maturity\ArtworkMaturityService;
use App\Services\Tags\TagDiscoveryService;
use App\Services\ThumbnailPresenter;
use Illuminate\Http\Request;
@@ -17,6 +18,7 @@ final class TagController extends Controller
{
public function __construct(
private readonly ArtworkSearchService $search,
private readonly ArtworkMaturityService $maturity,
private readonly TagDiscoveryService $tagDiscovery,
) {}
@@ -61,12 +63,12 @@ final class TagController extends Controller
]);
// Map artworks into the lightweight shape expected by the gallery React component.
$galleryCollection = $artworks->getCollection()->map(function ($a) {
$galleryCollection = collect($this->maturity->filterPayloadItems($artworks->getCollection()->map(function ($a) use ($request): array {
$primaryCategory = $a->categories->sortBy('sort_order')->first();
$present = ThumbnailPresenter::present($a, 'md');
$avatarUrl = \App\Support\AvatarUrl::forUser((int) ($a->user_id ?? 0), $a->user?->profile?->avatar_hash ?? null, 64);
return (object) [
return $this->maturity->decoratePayload([
'id' => $a->id,
'name' => $a->title ?? ($a->name ?? null),
'content_type_name' => $primaryCategory?->contentType?->name ?? '',
@@ -82,8 +84,10 @@ final class TagController extends Controller
'width' => $a->width ?? null,
'height' => $a->height ?? null,
'slug' => $a->slug ?? null,
];
})->values();
], $a, $request->user());
})->values()->all(), $request->user()))
->map(static fn (array $item): object => (object) $item)
->values();
// Replace paginator collection with the gallery-shaped collection so
// the gallery.index blade will generate the expected JSON payload.