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

@@ -7,14 +7,16 @@ namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Artwork;
use App\Models\ArtworkAward;
use App\Services\ArtworkAwardService;
use App\Models\ArtworkMedal;
use App\Models\ArtworkMedalStat;
use App\Services\ArtworkMedalService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
final class ArtworkAwardController extends Controller
{
public function __construct(
private readonly ArtworkAwardService $service
private readonly ArtworkMedalService $service
) {}
/**
@@ -32,7 +34,7 @@ final class ArtworkAwardController extends Controller
'medal' => ['required', 'string', 'in:gold,silver,bronze'],
]);
$award = $this->service->award($artwork, $user, $data['medal']);
$this->service->award($artwork, $user, $data['medal']);
// Record activity event
try {
@@ -51,6 +53,32 @@ final class ArtworkAwardController extends Controller
);
}
public function upsert(Request $request, int $id): JsonResponse
{
$user = $request->user();
$artwork = Artwork::findOrFail($id);
$this->authorize('award', [ArtworkAward::class, $artwork]);
$data = $request->validate([
'medal_type' => ['required', 'string', 'in:gold,silver,bronze'],
]);
$existed = ArtworkMedal::query()
->where('artwork_id', $artwork->id)
->where('user_id', $user->id)
->exists();
$this->service->upsert($artwork, $user, $data['medal_type']);
return response()->json(
array_merge($this->buildPayload($artwork->id, $user->id), [
'message' => $existed ? 'Medal updated.' : 'Medal added.',
]),
$existed ? 200 : 201,
);
}
/**
* PUT /api/artworks/{id}/award
* Change an existing award medal.
@@ -60,7 +88,7 @@ final class ArtworkAwardController extends Controller
$user = $request->user();
$artwork = Artwork::findOrFail($id);
$existingAward = ArtworkAward::where('artwork_id', $artwork->id)
$existingAward = ArtworkMedal::where('artwork_id', $artwork->id)
->where('user_id', $user->id)
->firstOrFail();
@@ -70,7 +98,7 @@ final class ArtworkAwardController extends Controller
'medal' => ['required', 'string', 'in:gold,silver,bronze'],
]);
$award = $this->service->changeAward($artwork, $user, $data['medal']);
$this->service->changeMedal($artwork, $user, $data['medal']);
return response()->json($this->buildPayload($artwork->id, $user->id));
}
@@ -84,17 +112,29 @@ final class ArtworkAwardController extends Controller
$user = $request->user();
$artwork = Artwork::findOrFail($id);
$existingAward = ArtworkAward::where('artwork_id', $artwork->id)
$existingAward = ArtworkMedal::where('artwork_id', $artwork->id)
->where('user_id', $user->id)
->firstOrFail();
$this->authorize('remove', $existingAward);
$this->service->removeAward($artwork, $user);
$this->service->removeMedal($artwork, $user);
return response()->json($this->buildPayload($artwork->id, $user->id));
}
public function destroyMedal(Request $request, int $id): JsonResponse
{
$user = $request->user();
$artwork = Artwork::findOrFail($id);
$this->service->removeMedal($artwork, $user);
return response()->json(array_merge($this->buildPayload($artwork->id, $user->id), [
'message' => 'Medal removed.',
]));
}
/**
* GET /api/artworks/{id}/awards
* Return award stats + viewer's current award.
@@ -111,22 +151,29 @@ final class ArtworkAwardController extends Controller
private function buildPayload(int $artworkId, ?int $userId): array
{
$stat = \App\Models\ArtworkAwardStat::find($artworkId);
$stat = ArtworkMedalStat::find($artworkId);
$userAward = $userId
? ArtworkAward::where('artwork_id', $artworkId)
? ArtworkMedal::where('artwork_id', $artworkId)
->where('user_id', $userId)
->value('medal')
->value('medal_type')
: null;
$medals = [
'gold' => (int) ($stat?->gold_count ?? 0),
'silver' => (int) ($stat?->silver_count ?? 0),
'bronze' => (int) ($stat?->bronze_count ?? 0),
'score' => (int) ($stat?->score_total ?? 0),
'score_7d' => (int) ($stat?->score_7d ?? 0),
'score_30d' => (int) ($stat?->score_30d ?? 0),
'last_medaled_at' => $stat?->last_medaled_at?->toIsoString(),
];
return [
'awards' => [
'gold' => $stat?->gold_count ?? 0,
'silver' => $stat?->silver_count ?? 0,
'bronze' => $stat?->bronze_count ?? 0,
'score' => $stat?->score_total ?? 0,
],
'awards' => $medals,
'medals' => $medals,
'viewer_award' => $userAward,
'current_user_medal' => $userAward,
];
}
}

View File

@@ -7,6 +7,7 @@ namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Artwork;
use App\Services\ArtworkStatsService;
use App\Services\Profile\CreatorJourneyService;
use App\Services\ThumbnailPresenter;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@@ -31,7 +32,10 @@ use Illuminate\Support\Str;
*/
final class ArtworkDownloadController extends Controller
{
public function __construct(private readonly ArtworkStatsService $stats) {}
public function __construct(
private readonly ArtworkStatsService $stats,
private readonly CreatorJourneyService $journeys,
) {}
public function __invoke(Request $request, int $id): JsonResponse
{
@@ -48,13 +52,15 @@ final class ArtworkDownloadController extends Controller
// Record the download event — non-blocking, errors are swallowed.
$this->recordDownload($request, $artwork);
// Increment counters — deferred via Redis when available.
// Increment counters immediately so Studio stats stay fresh.
try {
$this->stats->incrementDownloads((int) $artwork->id, 1, defer: true);
$this->stats->incrementDownloads((int) $artwork->id, 1, defer: false);
} catch (\Throwable) {
// Stats failure must never interrupt the download.
}
$this->journeys->requestRebuild((int) $artwork->user_id);
// Resolve the highest-resolution download URL available.
$url = $this->resolveDownloadUrl($artwork);

View File

@@ -10,6 +10,7 @@ use App\Models\Artwork;
use App\Notifications\ArtworkLikedNotification;
use App\Services\FollowService;
use App\Services\Activity\UserActivityService;
use App\Services\ArtworkStatsService;
use App\Services\UserStatsService;
use App\Services\XPService;
use Illuminate\Http\JsonResponse;
@@ -168,7 +169,7 @@ final class ArtworkInteractionController extends Controller
public function share(Request $request, int $artworkId): JsonResponse
{
$data = $request->validate([
'platform' => ['required', 'string', 'in:facebook,twitter,pinterest,email,copy,embed'],
'platform' => ['required', 'string', 'in:facebook,twitter,pinterest,email,copy,embed,native'],
]);
if (Schema::hasTable('artwork_shares')) {
@@ -178,6 +179,8 @@ final class ArtworkInteractionController extends Controller
'platform' => $data['platform'],
'created_at' => now(),
]);
$this->syncArtworkStats($artworkId);
}
return response()->json(['ok' => true]);
@@ -216,25 +219,7 @@ final class ArtworkInteractionController extends Controller
private function syncArtworkStats(int $artworkId): void
{
if (! Schema::hasTable('artwork_stats')) {
return;
}
$favorites = Schema::hasTable('artwork_favourites')
? (int) DB::table('artwork_favourites')->where('artwork_id', $artworkId)->count()
: 0;
$likes = Schema::hasTable('artwork_likes')
? (int) DB::table('artwork_likes')->where('artwork_id', $artworkId)->count()
: 0;
DB::table('artwork_stats')->updateOrInsert(
['artwork_id' => $artworkId],
[
'favorites' => $favorites,
'rating_count' => $likes,
]
);
app(ArtworkStatsService::class)->syncEngagementCounts($artworkId);
}
private function statusPayload(int $viewerId, int $artworkId): array

View File

@@ -16,14 +16,10 @@ use Illuminate\Http\Request;
*
* Fire-and-forget view tracker.
*
* Deduplication strategy (layered):
* 1. Session key (`art_viewed.{id}`) prevents double-counts within the
* same browser session (survives page reloads).
* 2. Route throttle (5 per 10 minutes per IP+artwork) catches bots that
* don't send session cookies.
*
* The frontend should additionally guard with sessionStorage so it only
* calls this endpoint once per page load.
* Every page visit should count as a new view.
* Lightweight abuse protection is handled at the route layer via throttling,
* while the stat increment itself is applied immediately so Studio analytics
* reflect new visits without waiting for the scheduler to flush Redis deltas.
*/
final class ArtworkViewController extends Controller
{
@@ -43,18 +39,11 @@ final class ArtworkViewController extends Controller
return response()->json(['error' => 'Not found'], 404);
}
$sessionKey = 'art_viewed.' . $id;
// Already counted this session — return early without touching the DB.
if ($request->hasSession() && $request->session()->has($sessionKey)) {
return response()->json(['ok' => true, 'counted' => false]);
}
// Write persistent event log (auth user_id or null for guests).
$this->stats->logViewEvent((int) $artwork->id, $request->user()?->id);
// Defer to Redis when available, fall back to direct DB increment.
$this->stats->incrementViews((int) $artwork->id, 1, defer: true);
// Apply the increment immediately so counters stay fresh in Studio.
$this->stats->incrementViews((int) $artwork->id, 1, defer: false);
$viewerId = $request->user()?->id;
if ($artwork->user_id !== null && (int) $artwork->user_id !== (int) ($viewerId ?? 0)) {
@@ -66,11 +55,6 @@ final class ArtworkViewController extends Controller
);
}
// Mark this session so the artwork is not counted again.
if ($request->hasSession()) {
$request->session()->put($sessionKey, true);
}
return response()->json(['ok' => true, 'counted' => true]);
}
}

View File

@@ -39,6 +39,8 @@ final class ProfileApiController extends Controller
$query = Artwork::with([
'user:id,name,username,level,rank',
'user.profile:user_id,avatar_hash',
'group:id,name,slug,avatar_path',
'stats:artwork_id,views,downloads,favorites',
'categories' => function ($query) {
$query->select('categories.id', 'categories.content_type_id', 'categories.parent_id', 'categories.name', 'categories.slug', 'categories.sort_order')
@@ -115,6 +117,8 @@ final class ProfileApiController extends Controller
$indexed = Artwork::with([
'user:id,name,username,level,rank',
'user.profile:user_id,avatar_hash',
'group:id,name,slug,avatar_path',
'stats:artwork_id,views,downloads,favorites',
'categories' => function ($query) {
$query->select('categories.id', 'categories.content_type_id', 'categories.parent_id', 'categories.name', 'categories.slug', 'categories.sort_order')
@@ -190,6 +194,15 @@ final class ProfileApiController extends Controller
$category = $art->categories->first();
$contentType = $category?->contentType;
$stats = $art->stats;
$group = $art->group;
$isGroupPublisher = $group !== null;
$displayName = $isGroupPublisher ? ($group->name ?? 'Skinbase') : ($art->user?->name ?? 'Skinbase');
$username = $isGroupPublisher ? null : ($art->user?->username ?? null);
$avatarUrl = $isGroupPublisher ? $group->avatarUrl() : ($art->user?->profile?->avatar_url ?? null);
$profileUrl = $isGroupPublisher
? $group->publicUrl()
: ($username ? '/@' . $username : null);
$publisherType = $isGroupPublisher ? 'group' : 'user';
return [
'id' => $art->id,
@@ -198,8 +211,22 @@ final class ProfileApiController extends Controller
'thumb_srcset' => $present['srcset'] ?? $present['url'],
'width' => $art->width,
'height' => $art->height,
'username' => $art->user->username ?? null,
'uname' => $art->user->username ?? $art->user->name ?? 'Skinbase',
'username' => $username,
'uname' => $displayName,
'avatar_url' => $avatarUrl,
'profile_url' => $profileUrl,
'published_as_type' => $publisherType,
'publisher' => [
'type' => $publisherType,
'id' => $isGroupPublisher ? (int) $group->id : (int) ($art->user?->id ?? 0),
'name' => $displayName,
'username' => $username ?? '',
'avatar_url' => $avatarUrl,
'profile_url' => $profileUrl,
],
'user_id' => $art->user_id,
'author_level' => $isGroupPublisher ? 0 : (int) ($art->user?->level ?? 1),
'author_rank' => $isGroupPublisher ? '' : (string) ($art->user?->rank ?? 'Newbie'),
'content_type' => $contentType?->name,
'content_type_slug' => $contentType?->slug,
'category' => $category?->name,

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Services\Profile\CreatorJourneyService;
use App\Support\UsernamePolicy;
use Illuminate\Http\JsonResponse;
final class ProfileJourneyController extends Controller
{
public function __construct(private readonly CreatorJourneyService $journeys)
{
}
public function __invoke(string $username): JsonResponse
{
$normalized = UsernamePolicy::normalize($username);
$user = User::query()
->whereRaw('LOWER(username) = ?', [$normalized])
->where('is_active', true)
->whereNull('deleted_at')
->firstOrFail();
return response()->json([
'data' => $this->journeys->publicPayloadForUser($user),
'meta' => [
'username' => (string) $user->username,
'generated_at' => now()->toIso8601String(),
],
]);
}
}

View File

@@ -9,6 +9,7 @@ use App\Http\Resources\ArtworkListResource;
use App\Models\Artwork;
use App\Models\Category;
use App\Models\ContentType;
use App\Services\ContentTypes\ContentTypeSlugResolver;
use App\Services\RankingService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@@ -26,7 +27,10 @@ use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
*/
class RankController extends Controller
{
public function __construct(private readonly RankingService $ranking) {}
public function __construct(
private readonly RankingService $ranking,
private readonly ContentTypeSlugResolver $contentTypeResolver,
) {}
/**
* GET /api/rank/global
@@ -65,7 +69,7 @@ class RankController extends Controller
{
$ct = is_numeric($contentType)
? ContentType::find((int) $contentType)
: ContentType::where('slug', $contentType)->first();
: $this->contentTypeResolver->resolve($contentType)->contentType;
if ($ct === null) {
return response()->json(['message' => 'Content type not found.'], 404);

View File

@@ -71,10 +71,10 @@ final class SuggestedCreatorsController extends Controller
u.username,
up.avatar_hash,
COALESCE(us.followers_count, 0) as followers_count,
COALESCE(us.artworks_count, 0) as artworks_count,
COALESCE(us.uploads_count, 0) as artworks_count,
COUNT(*) as mutual_weight
')
->groupBy('u.id', 'u.name', 'u.username', 'up.avatar_hash', 'us.followers_count', 'us.artworks_count')
->groupBy('u.id', 'u.name', 'u.username', 'up.avatar_hash', 'us.followers_count', 'us.uploads_count')
->orderByDesc('mutual_weight')
->limit(20)
->get();
@@ -117,10 +117,10 @@ final class SuggestedCreatorsController extends Controller
u.username,
up.avatar_hash,
COALESCE(us.followers_count, 0) as followers_count,
COALESCE(us.artworks_count, 0) as artworks_count,
COALESCE(us.uploads_count, 0) as artworks_count,
COUNT(DISTINCT t.id) as matched_tags
')
->groupBy('u.id', 'u.name', 'u.username', 'up.avatar_hash', 'us.followers_count', 'us.artworks_count')
->groupBy('u.id', 'u.name', 'u.username', 'up.avatar_hash', 'us.followers_count', 'us.uploads_count')
->orderByDesc('matched_tags')
->limit(20)
->get();
@@ -197,7 +197,7 @@ final class SuggestedCreatorsController extends Controller
u.username,
up.avatar_hash,
COALESCE(us.followers_count, 0) as followers_count,
COALESCE(us.artworks_count, 0) as artworks_count
COALESCE(us.uploads_count, 0) as artworks_count
')
->orderByDesc('followers_count')
->limit($limit)

View File

@@ -33,6 +33,7 @@ use App\Uploads\Jobs\VirusScanJob;
use App\Uploads\Services\PublishService;
use App\Services\Activity\UserActivityService;
use App\Services\ArtworkAttributionService;
use App\Services\Maturity\ArtworkMaturityService;
use App\Uploads\Exceptions\UploadNotFoundException;
use App\Uploads\Exceptions\UploadOwnershipException;
use App\Uploads\Exceptions\UploadPublishValidationException;
@@ -558,7 +559,7 @@ final class UploadController extends Controller
], Response::HTTP_OK);
}
public function publish(string $id, Request $request, PublishService $publishService, ArtworkAttributionService $attribution)
public function publish(string $id, Request $request, PublishService $publishService, ArtworkAttributionService $attribution, ArtworkMaturityService $maturity)
{
$user = $request->user();
@@ -566,7 +567,7 @@ final class UploadController extends Controller
'title' => ['nullable', 'string', 'max:150'],
'description' => ['nullable', 'string'],
'category' => ['nullable', 'integer', 'exists:categories,id'],
'tags' => ['nullable', 'array', 'max:15'],
'tags' => ['nullable', 'array', 'max:' . (int) config('tags.max_user_tags', 30)],
'tags.*' => ['string', 'max:64'],
'is_mature' => ['nullable', 'boolean'],
'nsfw' => ['nullable', 'boolean'],
@@ -657,6 +658,7 @@ final class UploadController extends Controller
}
$artwork->save();
$maturity->applyUploaderDeclaration($artwork, (bool) $artwork->is_mature);
$artwork = $attribution->apply($artwork->fresh(['group.members']), $user, $validated);
if ($mode === 'schedule' && $publishAt) {
@@ -760,7 +762,7 @@ final class UploadController extends Controller
'title' => ['nullable', 'string', 'max:150'],
'description' => ['nullable', 'string'],
'category' => ['nullable', 'integer', 'exists:categories,id'],
'tags' => ['nullable', 'array', 'max:15'],
'tags' => ['nullable', 'array', 'max:' . (int) config('tags.max_user_tags', 30)],
'tags.*' => ['string', 'max:64'],
'is_mature' => ['nullable', 'boolean'],
'nsfw' => ['nullable', 'boolean'],

View File

@@ -6,12 +6,17 @@ use App\Http\Controllers\Controller;
use App\Http\Requests\ArtworkIndexRequest;
use App\Models\Artwork;
use App\Models\Category;
use App\Models\ContentType;
use App\Services\ContentTypes\ContentTypeSlugResolver;
use Illuminate\Http\Request;
use Illuminate\Http\RedirectResponse;
use Illuminate\View\View;
class ArtworkController extends Controller
{
public function __construct(private readonly ContentTypeSlugResolver $contentTypeResolver)
{
}
/**
* Browse artworks with optional category filtering.
* Uses cursor pagination (no offset pagination) and only returns public, approved, not-deleted items.
@@ -58,6 +63,17 @@ class ArtworkController extends Controller
*/
public function show(Request $request, string $contentTypeSlug, string $categoryPath, $artwork = null)
{
$resolution = $this->contentTypeResolver->resolve($contentTypeSlug);
if (! $resolution->found() || $resolution->contentType === null) {
abort(404);
}
$resolvedContentTypeSlug = strtolower((string) $resolution->contentType->slug);
if ($resolution->requiresRedirect()) {
return $this->redirectToCanonicalArtworkPath($request, $resolvedContentTypeSlug, $categoryPath, $artwork, 301);
}
// 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;
@@ -67,7 +83,7 @@ class ArtworkController extends Controller
$artworkSlug = $artwork->slug;
} elseif ($artwork) {
$artworkSlug = (string) $artwork;
$foundArtwork = $this->findArtworkForCategoryPath($contentTypeSlug, $categoryPath, $artworkSlug);
$foundArtwork = $this->findArtworkForCategoryPath($resolvedContentTypeSlug, $categoryPath, $artworkSlug);
}
// When the URL can represent a nested category path (e.g. /skins/audio/winamp),
@@ -75,9 +91,9 @@ class ArtworkController extends Controller
// behave consistently.
if (! empty($artworkSlug)) {
$combinedPath = trim($categoryPath . '/' . $artworkSlug, '/');
$resolvedCategory = Category::findByPath($contentTypeSlug, $combinedPath);
$resolvedCategory = Category::findByPath($resolvedContentTypeSlug, $combinedPath);
if ($resolvedCategory) {
return app(BrowseGalleryController::class)->content(request(), $contentTypeSlug, $combinedPath);
return app(BrowseGalleryController::class)->content(request(), $resolvedContentTypeSlug, $combinedPath);
}
}
@@ -90,7 +106,7 @@ class ArtworkController extends Controller
if ($artworkSlug) {
$combinedPath = trim($categoryPath . '/' . $artworkSlug, '/');
}
return app(BrowseGalleryController::class)->content(request(), $contentTypeSlug, $combinedPath);
return app(BrowseGalleryController::class)->content(request(), $resolvedContentTypeSlug, $combinedPath);
}
if (! $foundArtwork->is_public || ! $foundArtwork->is_approved || $foundArtwork->trashed()) {
@@ -108,9 +124,8 @@ class ArtworkController extends Controller
private function findArtworkForCategoryPath(string $contentTypeSlug, string $categoryPath, string $artworkSlug): ?Artwork
{
$contentType = ContentType::query()->where('slug', strtolower($contentTypeSlug))->first();
$segments = array_values(array_filter(explode('/', trim($categoryPath, '/'))));
$category = $contentType ? Category::findByPath($contentType->slug, $segments) : null;
$category = Category::findByPath(strtolower($contentTypeSlug), $segments);
$query = Artwork::query()->where('slug', $artworkSlug);
@@ -125,4 +140,17 @@ class ArtworkController extends Controller
->orderByDesc('id')
->first();
}
private function redirectToCanonicalArtworkPath(Request $request, string $contentTypeSlug, string $categoryPath, Artwork|string|null $artwork, int $status = 301): RedirectResponse
{
$artworkSlug = $artwork instanceof Artwork ? $artwork->slug : (string) $artwork;
$target = url('/' . trim($contentTypeSlug . '/' . trim($categoryPath, '/') . '/' . trim($artworkSlug, '/'), '/'));
$queryString = $request->getQueryString();
if ($queryString) {
$target .= '?' . $queryString;
}
return redirect()->to($target, $status);
}
}

View File

@@ -6,6 +6,7 @@ namespace App\Http\Controllers;
use App\Models\Artwork;
use App\Models\ArtworkDownload;
use App\Services\ArtworkStatsService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Log;
@@ -34,6 +35,10 @@ final class ArtworkDownloadController extends Controller
'gz',
];
public function __construct(
private readonly ArtworkStatsService $stats,
) {}
public function __invoke(Request $request, int $id): BinaryFileResponse
{
$artwork = Artwork::query()->find($id);
@@ -51,6 +56,15 @@ final class ArtworkDownloadController extends Controller
$this->recordDownload($request, $artwork->id);
$this->incrementDownloadCountIfAvailable($artwork->id);
try {
$this->stats->incrementDownloads((int) $artwork->id, 1, defer: false);
} catch (\Throwable $exception) {
Log::warning('Failed to increment artwork_stats download counter.', [
'artwork_id' => $artwork->id,
'error' => $exception->getMessage(),
]);
}
if (! File::isFile($filePath)) {
Log::warning('Artwork original file missing for download.', [
'artwork_id' => $artwork->id,

View File

@@ -3,23 +3,39 @@
namespace App\Http\Controllers;
use App\Models\Category;
use App\Models\ContentType;
use App\Services\ArtworkService;
use App\Services\ContentTypes\ContentTypeSlugResolver;
use Illuminate\Http\Request;
class CategoryPageController extends Controller
{
public function __construct(private ArtworkService $artworkService)
public function __construct(
private ArtworkService $artworkService,
private ContentTypeSlugResolver $contentTypeResolver,
)
{
}
public function show(Request $request, string $contentTypeSlug, ?string $categoryPath = null)
{
$contentType = ContentType::where('slug', strtolower($contentTypeSlug))->first();
if (! $contentType) {
$resolution = $this->contentTypeResolver->resolve($contentTypeSlug);
if (! $resolution->found() || $resolution->contentType === null) {
abort(404);
}
$contentType = $resolution->contentType;
if ($resolution->requiresRedirect()) {
$target = url('/' . trim($contentType->slug . '/' . trim((string) $categoryPath, '/'), '/'));
$queryString = $request->getQueryString();
if ($queryString) {
$target .= '?' . $queryString;
}
return redirect()->to($target, 301);
}
$sort = (string) $request->get('sort', 'latest');

View File

@@ -7,31 +7,45 @@ use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use App\Services\ArtworkService;
use App\Models\ContentType;
use App\Services\ContentTypes\ContentTypeSlugResolver;
class PhotographyController extends Controller
{
protected ArtworkService $artworks;
public function __construct(ArtworkService $artworks)
public function __construct(
ArtworkService $artworks,
private readonly ContentTypeSlugResolver $contentTypeResolver,
)
{
$this->artworks = $artworks;
}
public function index(Request $request)
{
// Legacy group mapping: Photography => id 3
// Determine the requested content type from the first URL segment (photography|wallpapers|skins)
$segment = strtolower($request->segment(1) ?? 'photography');
$contentSlug = in_array($segment, ['photography','wallpapers','skins','other']) ? $segment : 'photography';
$resolution = $this->contentTypeResolver->resolve($segment);
// Human-friendly group name (used by legacy templates)
$group = ucfirst($contentSlug);
if (! $resolution->found() || $resolution->contentType === null) {
abort(404);
}
$contentType = $resolution->contentType;
$contentSlug = strtolower((string) $contentType->slug);
if ($resolution->requiresRedirect()) {
$target = url('/' . $contentSlug);
if ($request->getQueryString()) {
$target .= '?' . $request->getQueryString();
}
return redirect()->to($target, 301);
}
// Try to load legacy category id only for photography (legacy mapping); otherwise prefer authoritative ContentType
$id = null;
if ($contentSlug === 'photography') {
$id = 3; // legacy root id for photography in oldSite (kept for backward compatibility)
$id = 3;
}
// Fetch legacy category info if available (only when we have an id)
@@ -47,25 +61,20 @@ class PhotographyController extends Controller
$category = null;
}
// Page title and description: prefer legacy category when present, otherwise use ContentType data
$ct = ContentType::where('slug', $contentSlug)->first();
$page_title = $category->category_name ?? ($ct->name ?? ucfirst($contentSlug));
$tidy = $category->description ?? ($ct->description ?? null);
$page_title = $category->category_name ?? ($contentType->name ?? ucfirst($contentSlug));
$tidy = $category->description ?? ($contentType->description ?? null);
$perPage = 40;
$sort = (string) $request->get('sort', 'latest');
// Load artworks for the requested content type using standard pagination
try {
$artworks = $this->artworks->getArtworksByContentType($contentSlug, $perPage, $sort);
} catch (\Throwable $e) {
// Return an empty paginator so views using ->links() / ->firstItem() work
$artworks = new \Illuminate\Pagination\LengthAwarePaginator([], 0, $perPage, 1, [
'path' => url()->current(),
]);
}
// Load subcategories: prefer legacy table when id present and data exists, otherwise use ContentType root categories
$subcategories = collect();
try {
if ($id !== null && Schema::hasTable('artworks_categories')) {
@@ -79,18 +88,13 @@ class PhotographyController extends Controller
}
if (! $subcategories || $subcategories->count() === 0) {
if ($ct) {
$subcategories = $ct->rootCategories()
->orderBy('sort_order')
->orderBy('name')
->get()
->map(fn ($c) => (object) ['category_id' => $c->id, 'category_name' => $c->name, 'slug' => $c->slug]);
} else {
$subcategories = collect();
}
$subcategories = $contentType->rootCategories()
->orderBy('sort_order')
->orderBy('name')
->get()
->map(fn ($c) => (object) ['category_id' => $c->id, 'category_name' => $c->name, 'slug' => $c->slug]);
}
// Coerce collections to a paginator so the view's pagination helpers work
if ($artworks instanceof \Illuminate\Database\Eloquent\Collection || $artworks instanceof \Illuminate\Support\Collection) {
$page = (int) ($request->query('page', 1));
$artworks = new \Illuminate\Pagination\LengthAwarePaginator($artworks->values()->all(), $artworks->count(), $perPage, $page, [
@@ -99,11 +103,7 @@ class PhotographyController extends Controller
]);
}
// Prepare variables for the modern content-type view
$contentType = ContentType::where('slug', $contentSlug)->first();
$rootCategories = $contentType
? $contentType->rootCategories()->orderBy('sort_order')->orderBy('name')->get()
: collect();
$rootCategories = $contentType->rootCategories()->orderBy('sort_order')->orderBy('name')->get();
$page_meta_description = $tidy;

View File

@@ -7,13 +7,16 @@ use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use App\Services\ArtworkService;
use App\Models\ContentType;
use App\Services\ContentTypes\ContentTypeSlugResolver;
class PhotographyController extends Controller
{
protected ArtworkService $artworks;
public function __construct(ArtworkService $artworks)
public function __construct(
ArtworkService $artworks,
private readonly ContentTypeSlugResolver $contentTypeResolver,
)
{
$this->artworks = $artworks;
}
@@ -21,9 +24,24 @@ class PhotographyController extends Controller
public function index(Request $request)
{
$segment = strtolower($request->segment(1) ?? 'photography');
$contentSlug = in_array($segment, ['photography','wallpapers','skins','other']) ? $segment : 'photography';
$resolution = $this->contentTypeResolver->resolve($segment);
$group = ucfirst($contentSlug);
if (! $resolution->found() || $resolution->contentType === null) {
abort(404);
}
$contentType = $resolution->contentType;
$contentSlug = strtolower((string) $contentType->slug);
if ($resolution->requiresRedirect()) {
$target = url('/' . $contentSlug);
if ($request->getQueryString()) {
$target .= '?' . $request->getQueryString();
}
return redirect()->to($target, 301);
}
$id = null;
if ($contentSlug === 'photography') {
@@ -42,9 +60,8 @@ class PhotographyController extends Controller
$category = null;
}
$ct = ContentType::where('slug', $contentSlug)->first();
$page_title = $category->category_name ?? ($ct->name ?? ucfirst($contentSlug));
$tidy = $category->description ?? ($ct->description ?? null);
$page_title = $category->category_name ?? ($contentType->name ?? ucfirst($contentSlug));
$tidy = $category->description ?? ($contentType->description ?? null);
$perPage = 40;
$sort = (string) $request->get('sort', 'latest');
@@ -70,15 +87,11 @@ class PhotographyController extends Controller
}
if (! $subcategories || $subcategories->count() === 0) {
if ($ct) {
$subcategories = $ct->rootCategories()
->orderBy('sort_order')
->orderBy('name')
->get()
->map(fn ($c) => (object) ['category_id' => $c->id, 'category_name' => $c->name, 'slug' => $c->slug]);
} else {
$subcategories = collect();
}
$subcategories = $contentType->rootCategories()
->orderBy('sort_order')
->orderBy('name')
->get()
->map(fn ($c) => (object) ['category_id' => $c->id, 'category_name' => $c->name, 'slug' => $c->slug]);
}
if ($artworks instanceof \Illuminate\Database\Eloquent\Collection || $artworks instanceof \Illuminate\Support\Collection) {
@@ -89,10 +102,7 @@ class PhotographyController extends Controller
]);
}
$contentType = ContentType::where('slug', $contentSlug)->first();
$rootCategories = $contentType
? $contentType->rootCategories()->orderBy('sort_order')->orderBy('name')->get()
: collect();
$rootCategories = $contentType->rootCategories()->orderBy('sort_order')->orderBy('name')->get();
$page_meta_description = $tidy;

View File

@@ -6,8 +6,10 @@ namespace App\Http\Controllers\RSS;
use App\Http\Controllers\Controller;
use App\Models\Artwork;
use App\Models\ContentType;
use App\Services\ContentTypes\ContentTypeSlugResolver;
use App\Services\RSS\RSSFeedBuilder;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Cache;
@@ -30,36 +32,53 @@ final class ExploreFeedController extends Controller
'latest' => 300,
];
public function __construct(private readonly RSSFeedBuilder $builder) {}
public function __construct(
private readonly RSSFeedBuilder $builder,
private readonly ContentTypeSlugResolver $contentTypeResolver,
) {}
/** /rss/explore/{type} — defaults to latest */
public function byType(string $type): Response
public function byType(Request $request, string $type): Response|RedirectResponse
{
return $this->feed($type, 'latest');
return $this->feed($request, $type, 'latest');
}
/** /rss/explore/{type}/{mode} */
public function byTypeMode(string $type, string $mode): Response
public function byTypeMode(Request $request, string $type, string $mode): Response|RedirectResponse
{
return $this->feed($type, $mode);
return $this->feed($request, $type, $mode);
}
// ─────────────────────────────────────────────────────────────────────────
private function feed(string $type, string $mode): Response
private function feed(Request $request, string $type, string $mode): Response|RedirectResponse
{
$mode = in_array($mode, ['trending', 'latest', 'best'], true) ? $mode : 'latest';
$ttl = self::SORT_TTL[$mode] ?? 300;
$feedUrl = url('/rss/explore/' . $type . ($mode !== 'latest' ? '/' . $mode : ''));
$label = ucfirst(str_replace('-', ' ', $type));
$resolution = $this->contentTypeResolver->resolve($type, allowVirtual: true);
$artworks = Cache::remember("rss:explore:{$type}:{$mode}", $ttl, function () use ($type, $mode) {
$contentType = ContentType::where('slug', $type)->first();
if (! $resolution->found()) {
abort(404);
}
$mode = in_array($mode, ['trending', 'latest', 'best'], true) ? $mode : 'latest';
$resolvedType = $resolution->isVirtual ? 'artworks' : strtolower((string) $resolution->contentType?->slug);
if ($resolution->requiresRedirect()) {
return redirect()->to(url('/rss/explore/' . $resolvedType . ($mode !== 'latest' ? '/' . $mode : '')) . ($request->getQueryString() ? ('?' . $request->getQueryString()) : ''), 301);
}
$ttl = self::SORT_TTL[$mode] ?? 300;
$feedUrl = url('/rss/explore/' . $resolvedType . ($mode !== 'latest' ? '/' . $mode : ''));
$label = $resolution->isVirtual
? 'All Artworks'
: ($resolution->contentType?->name ?? ucfirst(str_replace('-', ' ', $resolvedType)));
$artworks = Cache::remember("rss:explore:{$resolvedType}:{$mode}", $ttl, function () use ($resolution, $mode) {
$contentType = $resolution->contentType;
$query = Artwork::public()->published()
->with(['user:id,username', 'categories:id,name,slug,content_type_id']);
if ($contentType) {
if (! $resolution->isVirtual && $contentType) {
$query->whereHas('categories', fn ($q) =>
$q->where('content_type_id', $contentType->id)
);

View File

@@ -0,0 +1,310 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Settings;
use App\Http\Controllers\Controller;
use App\Models\Artwork;
use App\Models\ArtworkMaturityAuditFinding;
use App\Models\User;
use App\Services\Maturity\ArtworkMaturityAuditService;
use App\Services\Maturity\ArtworkMaturityService;
use App\Services\ThumbnailPresenter;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Inertia\Inertia;
use Inertia\Response;
final class ArtworkMaturityAdminController extends Controller
{
public function __construct(
private readonly ArtworkMaturityService $maturity,
private readonly ArtworkMaturityAuditService $audit,
)
{
}
public function index(Request $request): Response
{
$stats = $this->queueStats();
$status = $this->initialStatus($request, $stats);
$routes = $this->routeNamesForRequest($request);
return Inertia::render('Moderation/ArtworkMaturityQueue', [
'title' => 'Artwork Maturity Queue',
'initialItems' => $this->queueItems($status),
'initialFilters' => [
'status' => $status,
'ai_action' => 'all',
'ai_status' => 'all',
],
'stats' => $stats,
'endpoints' => [
'list' => route($routes['list']),
'reviewPattern' => route($routes['review'], ['artwork' => '__ARTWORK__']),
],
'filterOptions' => [
'aiAction' => [
['value' => 'all', 'label' => 'All actions'],
['value' => ArtworkMaturityService::AI_ACTION_SAFE, 'label' => 'Safe'],
['value' => ArtworkMaturityService::AI_ACTION_REVIEW, 'label' => 'Review'],
['value' => ArtworkMaturityService::AI_ACTION_FLAG_HIGH, 'label' => 'Flag high'],
],
'aiStatus' => [
['value' => 'all', 'label' => 'All statuses'],
['value' => ArtworkMaturityService::AI_STATUS_SUCCEEDED, 'label' => 'Succeeded'],
['value' => ArtworkMaturityService::AI_STATUS_PENDING, 'label' => 'Pending'],
['value' => ArtworkMaturityService::AI_STATUS_FAILED, 'label' => 'Failed'],
['value' => ArtworkMaturityService::AI_STATUS_SKIPPED, 'label' => 'Skipped'],
],
],
'reviewActions' => [
['value' => 'mark_safe', 'label' => 'Mark safe'],
['value' => 'mark_mature', 'label' => 'Mark mature'],
['value' => 'confirm_current', 'label' => 'Confirm current state'],
],
])->rootView('moderation');
}
public function list(Request $request): JsonResponse
{
$status = $this->normalizeStatus((string) $request->query('status', 'suspected'));
$aiAction = strtolower((string) $request->query('ai_action', 'all'));
$aiStatus = strtolower((string) $request->query('ai_status', 'all'));
return response()->json([
'data' => $this->queueItems($status, $aiAction, $aiStatus),
'meta' => [
'stats' => $this->queueStats(),
'status' => $status,
'filters' => [
'ai_action' => $aiAction,
'ai_status' => $aiStatus,
],
],
]);
}
public function review(Request $request, Artwork $artwork): JsonResponse
{
$validated = $request->validate([
'action' => ['required', 'in:mark_safe,mark_mature,confirm_current'],
'note' => ['nullable', 'string', 'max:2000'],
]);
/** @var User $moderator */
$moderator = $request->user('controlpanel') ?? $request->user() ?? abort(403, 'Admin access required.');
$artwork = $this->maturity->review($artwork, (string) $validated['action'], $moderator, $validated['note'] ?? null);
$this->audit->resolveFindingForReview($artwork, $moderator, (string) $validated['action'], $validated['note'] ?? null);
return response()->json([
'success' => true,
'artwork' => $this->mapQueueItem($artwork->loadMissing(['user.profile', 'group', 'categories.contentType'])),
'stats' => $this->queueStats(),
]);
}
/**
* @return array<int, array<string, mixed>>
*/
private function queueItems(string $status, string $aiAction = 'all', string $aiStatus = 'all'): array
{
if ($status === 'audit') {
return $this->auditQueueItems($aiAction, $aiStatus);
}
$query = Artwork::query()
->with(['user.profile', 'group', 'categories.contentType'])
->where(function ($builder): void {
$builder->where('maturity_status', ArtworkMaturityService::STATUS_SUSPECTED)
->orWhere(function ($reviewed): void {
$reviewed->where('maturity_status', ArtworkMaturityService::STATUS_REVIEWED)
->whereNotNull('maturity_reviewed_at');
});
})
->latest('maturity_flagged_at')
->latest('published_at')
->limit(100);
if ($status === 'reviewed') {
$query->where('maturity_status', ArtworkMaturityService::STATUS_REVIEWED);
} else {
$query->where('maturity_status', ArtworkMaturityService::STATUS_SUSPECTED);
}
if (in_array($aiAction, [
ArtworkMaturityService::AI_ACTION_SAFE,
ArtworkMaturityService::AI_ACTION_REVIEW,
ArtworkMaturityService::AI_ACTION_FLAG_HIGH,
], true)) {
$query->where('maturity_ai_action_hint', $aiAction);
}
if (in_array($aiStatus, [
ArtworkMaturityService::AI_STATUS_SUCCEEDED,
ArtworkMaturityService::AI_STATUS_PENDING,
ArtworkMaturityService::AI_STATUS_FAILED,
ArtworkMaturityService::AI_STATUS_SKIPPED,
], true)) {
$query->where('maturity_ai_status', $aiStatus);
}
return $query->get()->map(fn (Artwork $artwork): array => $this->mapQueueItem($artwork))->all();
}
/**
* @return array<int, array<string, mixed>>
*/
private function auditQueueItems(string $aiAction = 'all', string $aiStatus = 'all'): array
{
$query = $this->audit->openFindingsQuery()
->latest('detected_at')
->latest('updated_at')
->limit(100);
if (in_array($aiAction, [
ArtworkMaturityService::AI_ACTION_SAFE,
ArtworkMaturityService::AI_ACTION_REVIEW,
ArtworkMaturityService::AI_ACTION_FLAG_HIGH,
], true)) {
$query->where('ai_action_hint', $aiAction);
}
if (in_array($aiStatus, [
ArtworkMaturityService::AI_STATUS_SUCCEEDED,
ArtworkMaturityService::AI_STATUS_PENDING,
ArtworkMaturityService::AI_STATUS_FAILED,
ArtworkMaturityService::AI_STATUS_SKIPPED,
ArtworkMaturityService::AI_STATUS_NOT_REQUESTED,
], true)) {
$query->where('ai_status', $aiStatus);
}
return $query->get()->map(fn (ArtworkMaturityAuditFinding $finding): array => $this->mapAuditQueueItem($finding))->all();
}
/**
* @return array<string, int>
*/
private function queueStats(): array
{
return [
'suspected' => (int) Artwork::query()->where('maturity_status', ArtworkMaturityService::STATUS_SUSPECTED)->count(),
'audit' => $this->audit->openFindingsCount(),
'reviewed' => (int) Artwork::query()->where('maturity_status', ArtworkMaturityService::STATUS_REVIEWED)->count(),
'mature' => (int) Artwork::query()->where('is_mature', true)->count(),
];
}
/**
* @return array<string, mixed>
*/
private function mapAuditQueueItem(ArtworkMaturityAuditFinding $finding): array
{
$artwork = $finding->artwork;
return $this->mapQueueItem($artwork, [
'status' => (string) $finding->status,
'thumbnail_variant' => $finding->thumbnail_variant,
'detected_at' => optional($finding->detected_at)->toIsoString(),
'last_scanned_at' => optional($finding->last_scanned_at)->toIsoString(),
'ai_label' => $finding->ai_label,
'ai_confidence' => $finding->ai_confidence,
'ai_score' => $finding->ai_score,
'ai_labels' => $finding->ai_labels,
'ai_model' => $finding->ai_model,
'ai_threshold_used' => $finding->ai_threshold_used,
'ai_analysis_time_ms' => $finding->ai_analysis_time_ms,
'ai_action_hint' => $finding->ai_action_hint,
'ai_status' => $finding->ai_status,
'ai_advisory' => $finding->ai_advisory,
'legacy_unset' => $this->audit->isArtworkEligible($artwork),
]);
}
/**
* @return array<string, mixed>
*/
private function mapQueueItem(Artwork $artwork, ?array $audit = null): array
{
$category = $artwork->categories->sortBy('sort_order')->first();
$publisherName = $artwork->group?->name ?: $artwork->user?->name ?: $artwork->user?->username ?: 'Artist';
$thumb = ThumbnailPresenter::present($artwork, 'md');
$preview = ThumbnailPresenter::present($artwork, 'xl');
return [
'id' => (int) $artwork->id,
'title' => (string) $artwork->title,
'url' => route('art.show', ['id' => $artwork->id, 'slug' => Str::slug((string) ($artwork->slug ?: $artwork->title)) ?: (string) $artwork->id]),
'admin_url' => route('admin.cp.artworks.edit', ['id' => $artwork->id]),
'thumbnail' => $thumb['url'] ?? null,
'preview_image' => $preview['url'] ?? ($thumb['url'] ?? null),
'publisher' => $publisherName,
'published_at' => optional($artwork->published_at)->toIsoString(),
'content_type' => $category?->contentType?->name,
'category' => $category?->name,
'maturity' => $this->maturity->presentation($artwork, null),
'audit' => $audit,
'review' => [
'reviewed_at' => optional($artwork->maturity_reviewed_at)->toIsoString(),
'reviewed_by' => $artwork->maturity_reviewed_by,
'reviewer_note' => $artwork->maturity_reviewer_note,
],
];
}
private function normalizeStatus(string $status): string
{
$normalized = Str::lower(trim($status));
return in_array($normalized, ['suspected', 'reviewed', 'audit'], true)
? $normalized
: 'suspected';
}
/**
* @param array<string, int> $stats
*/
private function initialStatus(Request $request, array $stats): string
{
if ($request->query->has('status')) {
return $this->normalizeStatus((string) $request->query('status'));
}
if (($stats['suspected'] ?? 0) > 0) {
return 'suspected';
}
if (($stats['audit'] ?? 0) > 0) {
return 'audit';
}
if (($stats['reviewed'] ?? 0) > 0) {
return 'reviewed';
}
return 'suspected';
}
/**
* @return array{list: string, review: string}
*/
private function routeNamesForRequest(Request $request): array
{
$routeName = (string) $request->route()?->getName();
if (Str::startsWith($routeName, 'admin.cp.artworks.maturity.')) {
return [
'list' => 'admin.cp.artworks.maturity.queue',
'review' => 'admin.cp.artworks.maturity.review',
];
}
return [
'list' => 'cp.maturity.list',
'review' => 'cp.maturity.review',
];
}
}

View File

@@ -0,0 +1,215 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Settings;
use App\Http\Controllers\Controller;
use App\Models\ArtworkFeature;
use App\Services\FeaturedArtworkAdminService;
use Carbon\Carbon;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
use Inertia\Response;
class FeaturedArtworkAdminController extends Controller
{
public function __construct(private readonly FeaturedArtworkAdminService $featuredArtworks)
{
}
public function index(): Response
{
return Inertia::render('Collection/FeaturedArtworksAdmin', array_merge(
$this->featuredArtworks->pageProps(),
[
'endpoints' => [
'search' => route('admin.cp.artworks.featured.search'),
'store' => route('admin.cp.artworks.featured.store'),
'updatePattern' => route('admin.cp.artworks.featured.update', ['feature' => '__FEATURE__']),
'togglePattern' => route('admin.cp.artworks.featured.toggle', ['feature' => '__FEATURE__']),
'forceHeroPattern' => route('admin.cp.artworks.featured.force-hero', ['feature' => '__FEATURE__']),
'destroyPattern' => route('admin.cp.artworks.featured.delete', ['feature' => '__FEATURE__']),
],
'capabilities' => [
'forceHeroEnabled' => $this->hasForceHeroColumn(),
],
'seo' => [
'title' => 'Featured Artworks — Skinbase Nova',
'description' => 'Editorial controls for homepage featured artworks and the current hero winner.',
'canonical' => route('admin.cp.artworks.featured.main'),
'robots' => 'noindex,follow',
],
],
))->rootView('collections');
}
public function search(Request $request): JsonResponse
{
$validated = $request->validate([
'q' => ['required', 'string', 'min:1', 'max:120'],
]);
return response()->json([
'ok' => true,
'results' => $this->featuredArtworks->searchArtworks((string) $validated['q']),
]);
}
public function store(Request $request): JsonResponse
{
$validated = $this->validateStore($request);
$actor = $this->currentActor($request);
ArtworkFeature::query()->create([
'artwork_id' => (int) $validated['artwork_id'],
'priority' => (int) $validated['priority'],
'featured_at' => Carbon::parse((string) $validated['featured_at']),
'expires_at' => filled($validated['expires_at'] ?? null) ? Carbon::parse((string) $validated['expires_at']) : null,
'is_active' => (bool) $validated['is_active'],
'created_by' => (int) $actor->id,
]);
return $this->mutationResponse('Featured artwork added.');
}
public function update(Request $request, ArtworkFeature $feature): JsonResponse
{
$validated = $this->validateUpdate($request);
$this->ensureStateAvailable($feature, (bool) $validated['is_active']);
$feature->fill([
'priority' => (int) $validated['priority'],
'featured_at' => Carbon::parse((string) $validated['featured_at']),
'expires_at' => filled($validated['expires_at'] ?? null) ? Carbon::parse((string) $validated['expires_at']) : null,
'is_active' => (bool) $validated['is_active'],
]);
$feature->save();
return $this->mutationResponse('Featured artwork updated.');
}
public function toggle(ArtworkFeature $feature): JsonResponse
{
$nextState = ! (bool) $feature->is_active;
$this->ensureStateAvailable($feature, $nextState);
$feature->forceFill([
'is_active' => $nextState,
])->save();
return $this->mutationResponse($nextState ? 'Featured artwork activated.' : 'Featured artwork deactivated.');
}
public function toggleForceHero(ArtworkFeature $feature): JsonResponse
{
$this->ensureForceHeroAvailable();
$nextState = ! (bool) $feature->force_hero;
DB::transaction(function () use ($feature, $nextState): void {
if ($nextState) {
ArtworkFeature::query()
->where('force_hero', true)
->whereNull('deleted_at')
->whereKeyNot($feature->id)
->update(['force_hero' => false]);
}
$feature->forceFill([
'force_hero' => $nextState,
])->save();
});
return $this->mutationResponse($nextState ? 'Force hero enabled.' : 'Force hero disabled.');
}
public function destroy(ArtworkFeature $feature): JsonResponse
{
$feature->delete();
return $this->mutationResponse('Featured artwork entry deleted.');
}
/**
* @return array<string, mixed>
*/
private function validateStore(Request $request): array
{
return $request->validate([
'artwork_id' => [
'required',
'integer',
Rule::exists('artworks', 'id'),
Rule::unique('artwork_features', 'artwork_id')->where(fn ($query) => $query->whereNull('deleted_at')),
],
'priority' => ['required', 'integer', 'min:0', 'max:65535'],
'featured_at' => ['required', 'date'],
'expires_at' => ['nullable', 'date', 'after:featured_at'],
'is_active' => ['required', 'boolean'],
], [
'artwork_id.unique' => 'This artwork already has a featured entry. Edit the existing row instead.',
]);
}
/**
* @return array<string, mixed>
*/
private function validateUpdate(Request $request): array
{
return $request->validate([
'priority' => ['required', 'integer', 'min:0', 'max:65535'],
'featured_at' => ['required', 'date'],
'expires_at' => ['nullable', 'date', 'after:featured_at'],
'is_active' => ['required', 'boolean'],
]);
}
private function ensureStateAvailable(ArtworkFeature $feature, bool $isActive): void
{
$conflictExists = ArtworkFeature::query()
->where('artwork_id', $feature->artwork_id)
->where('is_active', $isActive)
->whereNull('deleted_at')
->whereKeyNot($feature->id)
->exists();
if ($conflictExists) {
throw ValidationException::withMessages([
'is_active' => 'Another featured entry for this artwork already uses that active state.',
]);
}
}
private function mutationResponse(string $message): JsonResponse
{
return response()->json(array_merge([
'ok' => true,
'message' => $message,
], $this->featuredArtworks->pageProps()));
}
private function currentActor(Request $request): object
{
return $request->user('controlpanel') ?? $request->user() ?? abort(403, 'Admin access required.');
}
private function ensureForceHeroAvailable(): void
{
if (! $this->hasForceHeroColumn()) {
throw ValidationException::withMessages([
'force_hero' => 'Run php artisan migrate to enable force hero controls.',
]);
}
}
private function hasForceHeroColumn(): bool
{
return Schema::hasColumn('artwork_features', 'force_hero');
}
}

View File

@@ -9,6 +9,7 @@ use App\Models\Artwork;
use App\Models\Category;
use App\Models\ContentType;
use App\Models\ArtworkVersion;
use App\Services\ArtworkEvolutionService;
use App\Services\Cdn\ArtworkCdnPurgeService;
use App\Services\ArtworkSearchIndexer;
use App\Services\ArtworkAttributionService;
@@ -122,6 +123,7 @@ final class StudioArtworksApiController extends Controller
public function update(Request $request, int $id, ArtworkAttributionService $attribution): JsonResponse
{
$artwork = $request->user()->artworks()->findOrFail($id);
$evolution = app(ArtworkEvolutionService::class);
$validated = $request->validate([
'title' => 'sometimes|string|max:255',
@@ -133,7 +135,7 @@ final class StudioArtworksApiController extends Controller
'timezone' => 'sometimes|nullable|string|max:64',
'category_id' => 'sometimes|nullable|integer|exists:categories,id',
'content_type_id' => 'sometimes|nullable|integer|exists:content_types,id',
'tags' => 'sometimes|array|max:15',
'tags' => 'sometimes|array|max:' . (int) config('tags.max_user_tags', 30),
'tags.*' => 'string|max:64',
'title_source' => 'sometimes|nullable|string|in:manual,ai_generated,ai_applied,mixed',
'description_source' => 'sometimes|nullable|string|in:manual,ai_generated,ai_applied,mixed',
@@ -147,12 +149,18 @@ final class StudioArtworksApiController extends Controller
'contributor_credits.*.user_id' => 'required|integer|min:1',
'contributor_credits.*.credit_role' => 'nullable|string|max:80',
'contributor_credits.*.is_primary' => 'nullable|boolean',
'evolution_target_artwork_id' => 'sometimes|nullable|integer|min:1',
'evolution_relation_type' => 'sometimes|nullable|string|in:remake_of,remaster_of,revision_of,inspired_by,variation_of',
'evolution_note' => 'sometimes|nullable|string|max:1200',
]);
$hasAttributionUpdates = array_key_exists('group', $validated)
|| array_key_exists('primary_author_user_id', $validated)
|| array_key_exists('contributor_user_ids', $validated)
|| array_key_exists('contributor_credits', $validated);
$hasEvolutionUpdates = array_key_exists('evolution_target_artwork_id', $validated)
|| array_key_exists('evolution_relation_type', $validated)
|| array_key_exists('evolution_note', $validated);
$attributionPayload = [
'group' => $validated['group'] ?? $artwork->group?->slug,
@@ -190,7 +198,13 @@ final class StudioArtworksApiController extends Controller
$tags = $validated['tags'] ?? null;
$categoryId = $validated['category_id'] ?? null;
$contentTypeId = $validated['content_type_id'] ?? null;
$evolutionPayload = [
'target_artwork_id' => $validated['evolution_target_artwork_id'] ?? null,
'relation_type' => $validated['evolution_relation_type'] ?? null,
'note' => $validated['evolution_note'] ?? null,
];
unset($validated['tags'], $validated['category_id'], $validated['content_type_id'], $validated['visibility'], $validated['mode'], $validated['publish_at'], $validated['timezone'], $validated['group'], $validated['primary_author_user_id'], $validated['contributor_user_ids'], $validated['contributor_credits']);
unset($validated['evolution_target_artwork_id'], $validated['evolution_relation_type'], $validated['evolution_note']);
$validated['visibility'] = $visibility;
$validated['artwork_timezone'] = $timezone;
@@ -244,6 +258,14 @@ final class StudioArtworksApiController extends Controller
$artwork = $attribution->apply($artwork->fresh(['group.members', 'contributors', 'primaryAuthor.profile']), $request->user(), $attributionPayload);
}
if ($hasEvolutionUpdates) {
try {
$evolution->syncPrimaryRelation($artwork->fresh(['group.members']), $request->user(), $evolutionPayload);
} catch (ValidationException $exception) {
return response()->json(['errors' => $exception->errors()], 422);
}
}
// Reindex in Meilisearch
try {
if ((bool) $artwork->is_public && (bool) $artwork->is_approved && $artwork->published_at) {
@@ -287,6 +309,25 @@ final class StudioArtworksApiController extends Controller
'description_source' => $artwork->description_source ?: 'manual',
'tags_source' => $artwork->tags_source ?: 'manual',
'category_source' => $artwork->category_source ?: 'manual',
'evolution_relation' => $evolution->editorRelation($artwork, $request->user()),
],
]);
}
public function evolutionOptions(Request $request, int $id): JsonResponse
{
$artwork = $request->user()->artworks()->findOrFail($id);
$validated = $request->validate([
'search' => ['nullable', 'string', 'max:120'],
]);
$evolution = app(ArtworkEvolutionService::class);
return response()->json([
'data' => $evolution->manageableSearchOptions($artwork, $request->user(), (string) ($validated['search'] ?? '')),
'meta' => [
'selected' => $evolution->editorRelation($artwork, $request->user()),
],
]);
}

View File

@@ -7,6 +7,7 @@ namespace App\Http\Controllers\Studio;
use App\Http\Controllers\Controller;
use App\Models\Group;
use App\Models\ContentType;
use App\Services\ArtworkEvolutionService;
use App\Services\GroupMembershipService;
use App\Services\GroupService;
use App\Services\Studio\CreatorStudioAnalyticsService;
@@ -478,6 +479,7 @@ final class StudioController extends Controller
'description_source' => $artwork->description_source ?: 'manual',
'tags_source' => $artwork->tags_source ?: 'manual',
'category_source' => $artwork->category_source ?: 'manual',
'evolution_relation' => app(ArtworkEvolutionService::class)->editorRelation($artwork, $user),
// Versioning
'version_count' => (int) ($artwork->version_count ?? 1),
'requires_reapproval' => (bool) $artwork->requires_reapproval,
@@ -485,6 +487,7 @@ final class StudioController extends Controller
'contentTypes' => $this->getCategories(),
'groupOptions' => $availableGroups,
'contributorOptionsByGroup' => $contributorOptionsByGroup,
'evolutionRelationTypes' => app(ArtworkEvolutionService::class)->relationTypeOptions(),
]);
}

View File

@@ -6,6 +6,7 @@ use App\Http\Controllers\Controller;
use App\Http\Requests\ProfileUpdateRequest;
use App\Http\Requests\Settings\RequestEmailChangeRequest;
use App\Http\Requests\Settings\UpdateAccountSectionRequest;
use App\Http\Requests\Settings\UpdateContentPreferencesRequest;
use App\Http\Requests\Settings\UpdateNotificationsSectionRequest;
use App\Http\Requests\Settings\UpdatePersonalSectionRequest;
use App\Http\Requests\Settings\UpdateProfileSectionRequest;
@@ -35,10 +36,11 @@ use App\Services\FollowAnalyticsService;
use App\Services\LeaderboardService;
use App\Services\UserSuggestionService;
use App\Services\Countries\CountryCatalogService;
use App\Services\Maturity\ArtworkMaturityService;
use App\Services\ThumbnailPresenter;
use App\Services\ThumbnailService;
use App\Services\XPService;
use App\Services\UsernameApprovalService;
use App\Services\Profile\CreatorJourneyService;
use App\Services\UserStatsService;
use App\Support\AvatarUrl;
use App\Support\CoverUrl;
@@ -84,6 +86,7 @@ class ProfileController extends Controller
private readonly LeaderboardService $leaderboards,
private readonly CountryCatalogService $countryCatalog,
private readonly UserSuggestionService $userSuggestions,
private readonly CreatorJourneyService $creatorJourney,
)
{
}
@@ -312,6 +315,10 @@ class ProfileController extends Controller
$followerNotifications = (bool) ($profileData['follower_notifications'] ?? true);
$commentNotifications = (bool) ($profileData['comment_notifications'] ?? true);
$newsletter = (bool) ($profileData['newsletter'] ?? $profileData['mlist'] ?? $user->mlist ?? false);
$matureContentVisibility = (string) ($profileData['mature_content_visibility'] ?? config('maturity.viewer.default_mode', 'blur'));
$matureContentWarningEnabled = array_key_exists('mature_content_warning_enabled', $profileData)
? (bool) $profileData['mature_content_warning_enabled']
: (bool) config('maturity.viewer.default_warn_on_detail', true);
return Inertia::render('Settings/ProfileEdit', [
'user' => [
@@ -332,6 +339,8 @@ class ProfileController extends Controller
'follower_notifications' => $followerNotifications,
'comment_notifications' => $commentNotifications,
'newsletter' => $newsletter,
'mature_content_visibility' => $matureContentVisibility,
'mature_content_warning_enabled' => $matureContentWarningEnabled,
'last_username_change_at' => $user->last_username_change_at,
'username_changed_at' => $user->username_changed_at,
],
@@ -576,6 +585,18 @@ class ProfileController extends Controller
return $this->settingsResponse($request, 'Notification settings saved successfully.');
}
public function updateContentPreferencesSection(UpdateContentPreferencesRequest $request): RedirectResponse|JsonResponse
{
$validated = $request->validated();
$this->persistProfileUpdates((int) $request->user()->id, [
'mature_content_visibility' => (string) $validated['mature_content_visibility'],
'mature_content_warning_enabled' => (bool) $validated['mature_content_warning_enabled'],
]);
return $this->settingsResponse($request, 'Content preferences saved successfully.');
}
public function updateSecurityPassword(UpdateSecurityPasswordRequest $request): RedirectResponse|JsonResponse
{
$validated = $request->validated();
@@ -918,7 +939,7 @@ class ProfileController extends Controller
$perPage = 24;
// ── Artworks (cursor-paginated) ──────────────────────────────────────
$artworks = $this->artworkService->getArtworksByUser($user->id, $isOwner, $perPage)
$artworks = $this->artworkService->getArtworksByUser($user->id, $isOwner, $perPage, $viewer)
->through(function (Artwork $art) {
return (object) $this->mapArtworkCardPayload($art);
});
@@ -926,34 +947,38 @@ class ProfileController extends Controller
// ── Featured artworks for this user ─────────────────────────────────
$featuredArtworks = collect();
if (Schema::hasTable('artwork_features')) {
$featuredArtworks = DB::table('artwork_features as af')
->join('artworks as a', 'a.id', '=', 'af.artwork_id')
->where('a.user_id', $user->id)
$featuredQuery = Artwork::query()
->with([
'user:id,name,username,level,rank',
'user.profile:user_id,avatar_hash',
'group:id,name,slug,avatar_path',
'stats:artwork_id,views,downloads,favorites',
'categories' => function ($query) {
$query->select('categories.id', 'categories.content_type_id', 'categories.parent_id', 'categories.name', 'categories.slug', 'categories.sort_order')
->with(['parent:id,parent_id,content_type_id,name,slug', 'contentType:id,slug,name']);
},
])
->join('artwork_features as af', 'af.artwork_id', '=', 'artworks.id')
->where('artworks.user_id', $user->id)
->where('af.is_active', true)
->whereNull('af.deleted_at')
->whereNull('a.deleted_at')
->where('a.is_public', true)
->where('a.is_approved', true)
->whereNull('artworks.deleted_at')
->where('artworks.is_public', true)
->where('artworks.is_approved', true)
->whereNotNull('artworks.published_at')
->select(['artworks.*', 'af.label as featured_label', 'af.featured_at as featured_slot_at'])
->orderByDesc('af.featured_at')
->limit(3)
->select([
'a.id', 'a.title as name', 'a.hash', 'a.thumb_ext',
'a.width', 'a.height', 'af.label', 'af.featured_at',
])
->limit(3);
app(ArtworkMaturityService::class)->applyViewerFilter($featuredQuery, $viewer);
$featuredArtworks = $featuredQuery
->get()
->map(function ($row) {
$thumbUrl = ($row->hash && $row->thumb_ext)
? ThumbnailService::fromHash($row->hash, $row->thumb_ext, 'md')
: '/images/placeholder.jpg';
return (object) [
'id' => $row->id,
'name' => $row->name,
'thumb' => $thumbUrl,
'label' => $row->label,
'featured_at' => $row->featured_at,
'width' => $row->width,
'height' => $row->height,
];
->map(function (Artwork $artwork) {
return (object) array_merge($this->mapArtworkCardPayload($artwork), [
'label' => $artwork->featured_label,
'featured_at' => $this->formatIsoDate($artwork->featured_slot_at),
]);
});
}
@@ -972,6 +997,10 @@ class ProfileController extends Controller
->where('a.is_public', true)
->where('a.is_approved', true)
->whereNotNull('a.published_at')
->when(app(ArtworkMaturityService::class)->viewerPreferences($viewer)['visibility'] === ArtworkMaturityService::VIEW_HIDE, function ($query): void {
$query->whereRaw('COALESCE(a.is_mature, 0) = 0')
->whereRaw("COALESCE(a.maturity_status, 'clear') != ?", [ArtworkMaturityService::STATUS_SUSPECTED]);
})
->orderByDesc('af.created_at')
->orderByDesc('af.artwork_id')
->limit($favouriteLimit + 1)
@@ -981,7 +1010,16 @@ class ProfileController extends Controller
$hasMore = $favIds->count() > $favouriteLimit;
$favIds = $favIds->take($favouriteLimit);
$indexed = Artwork::with('user:id,name,username')
$indexed = Artwork::with([
'user:id,name,username,level,rank',
'user.profile:user_id,avatar_hash',
'group:id,name,slug,avatar_path',
'stats:artwork_id,views,downloads,favorites',
'categories' => function ($query) {
$query->select('categories.id', 'categories.content_type_id', 'categories.parent_id', 'categories.name', 'categories.slug', 'categories.sort_order')
->with(['contentType:id,slug,name']);
},
])
->whereIn('id', $favIds)
->get()
->keyBy('id');
@@ -1056,18 +1094,38 @@ class ProfileController extends Controller
->count();
}
$liveAwardsReceivedCount = 0;
if (Schema::hasTable('artwork_awards') && Schema::hasTable('artworks')) {
$liveAwardsReceivedCount = (int) DB::table('artwork_awards as aw')
->join('artworks as a', 'a.id', '=', 'aw.artwork_id')
$medalTotals = [
'gold' => 0,
'silver' => 0,
'bronze' => 0,
'count' => 0,
'score_total' => 0,
];
if (Schema::hasTable('artwork_medal_stats') && Schema::hasTable('artworks')) {
$totals = DB::table('artwork_medal_stats as aas')
->join('artworks as a', 'a.id', '=', 'aas.artwork_id')
->where('a.user_id', $user->id)
->whereNull('a.deleted_at')
->count();
->selectRaw('COALESCE(SUM(aas.gold_count), 0) as gold_count')
->selectRaw('COALESCE(SUM(aas.silver_count), 0) as silver_count')
->selectRaw('COALESCE(SUM(aas.bronze_count), 0) as bronze_count')
->selectRaw('COALESCE(SUM(aas.score_total), 0) as score_total')
->first();
$medalTotals = [
'gold' => (int) ($totals->gold_count ?? 0),
'silver' => (int) ($totals->silver_count ?? 0),
'bronze' => (int) ($totals->bronze_count ?? 0),
'count' => (int) (($totals->gold_count ?? 0) + ($totals->silver_count ?? 0) + ($totals->bronze_count ?? 0)),
'score_total' => (int) ($totals->score_total ?? 0),
];
}
$statsPayload = array_merge($stats ? (array) $stats : [], [
'uploads_count' => $liveUploadsCount,
'awards_received_count' => $liveAwardsReceivedCount,
'awards_received_count' => $medalTotals['count'],
'medal_totals' => $medalTotals,
'followers_count' => (int) $followerCount,
'following_count' => (int) $followingCount,
]);
@@ -1145,7 +1203,7 @@ class ProfileController extends Controller
]);
$profileCollections = $this->collections->getProfileCollections($user, $viewer);
$profileCollectionsPayload = $this->collections->mapCollectionCardPayloads($profileCollections, $isOwner);
$profileCollectionsPayload = $this->collections->mapCollectionCardPayloads($profileCollections, $isOwner, $viewer);
// ── Profile data ─────────────────────────────────────────────────────
$profile = $user->profile;
@@ -1203,6 +1261,7 @@ class ProfileController extends Controller
$achievementSummary = $this->achievements->summary((int) $user->id);
$leaderboardRank = $this->leaderboards->creatorRankSummary((int) $user->id);
$groupContributionHistory = $this->buildGroupContributionHistory($user);
$journey = $this->creatorJourney->publicPayloadForUser($user);
$resolvedInitialTab = $this->normalizeProfileTab($initialTab);
$isTabLanding = ! $galleryOnly && $resolvedInitialTab !== null;
$activeProfileUrl = $resolvedInitialTab !== null
@@ -1276,6 +1335,7 @@ class ProfileController extends Controller
'collections' => $profileCollectionsPayload,
'achievements' => $achievementSummary,
'leaderboardRank' => $leaderboardRank,
'journey' => $journey,
'groupContributionHistory' => $groupContributionHistory,
'countryName' => $countryName,
'isOwner' => $isOwner,
@@ -1288,6 +1348,7 @@ class ProfileController extends Controller
'collectionsFeaturedUrl' => route('collections.featured'),
'collectionFeatureLimit' => (int) config('collections.featured_limit', 3),
'profileTabUrls' => $profileTabUrls,
'journeyApiUrl' => route('api.profile.journey', ['username' => $usernameSlug]),
])->withViewData([
'page_title' => $pageTitle,
'page_canonical' => $galleryOnly ? $galleryUrl : $activeProfileUrl,
@@ -1435,8 +1496,17 @@ class ProfileController extends Controller
$category = $art->categories->first();
$contentType = $category?->contentType;
$stats = $art->stats;
$group = $art->group;
$isGroupPublisher = $group !== null;
$displayName = $isGroupPublisher ? ($group->name ?? 'Skinbase') : ($art->user?->name ?? 'Skinbase');
$username = $isGroupPublisher ? null : ($art->user?->username ?? null);
$avatarUrl = $isGroupPublisher ? $group->avatarUrl() : ($art->user?->profile?->avatar_url ?? null);
$profileUrl = $isGroupPublisher
? $group->publicUrl()
: ($username ? '/@' . $username : null);
$publisherType = $isGroupPublisher ? 'group' : 'user';
return [
return app(ArtworkMaturityService::class)->decoratePayload([
'id' => $art->id,
'name' => $art->title,
'picture' => $art->file_name,
@@ -1444,11 +1514,22 @@ class ProfileController extends Controller
'published_at' => $this->formatIsoDate($art->published_at),
'thumb' => $present['url'],
'thumb_srcset' => $present['srcset'] ?? $present['url'],
'uname' => $art->user->name ?? 'Skinbase',
'username' => $art->user->username ?? null,
'uname' => $displayName,
'username' => $username,
'avatar_url' => $avatarUrl,
'profile_url' => $profileUrl,
'published_as_type' => $publisherType,
'publisher' => [
'type' => $publisherType,
'id' => $isGroupPublisher ? (int) $group->id : (int) ($art->user?->id ?? 0),
'name' => $displayName,
'username' => $username ?? '',
'avatar_url' => $avatarUrl,
'profile_url' => $profileUrl,
],
'user_id' => $art->user_id,
'author_level' => (int) ($art->user?->level ?? 1),
'author_rank' => (string) ($art->user?->rank ?? 'Newbie'),
'author_level' => $isGroupPublisher ? 0 : (int) ($art->user?->level ?? 1),
'author_rank' => $isGroupPublisher ? '' : (string) ($art->user?->rank ?? 'Newbie'),
'content_type' => $contentType?->name,
'content_type_slug' => $contentType?->slug,
'category' => $category?->name,
@@ -1458,7 +1539,7 @@ class ProfileController extends Controller
'likes' => (int) ($stats?->favorites ?? $art->favourite_count ?? 0),
'width' => $art->width,
'height' => $art->height,
];
], $art, request()->user());
}
private function formatIsoDate(mixed $value): ?string

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.