feat: ship creator journey v2 and profile updates
This commit is contained in:
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
37
app/Http/Controllers/Api/ProfileJourneyController.php
Normal file
37
app/Http/Controllers/Api/ProfileJourneyController.php
Normal 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(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
|
||||
310
app/Http/Controllers/Settings/ArtworkMaturityAdminController.php
Normal file
310
app/Http/Controllers/Settings/ArtworkMaturityAdminController.php
Normal 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',
|
||||
];
|
||||
}
|
||||
}
|
||||
215
app/Http/Controllers/Settings/FeaturedArtworkAdminController.php
Normal file
215
app/Http/Controllers/Settings/FeaturedArtworkAdminController.php
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -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()),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.'],
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -21,7 +21,7 @@ class ConditionalCors
|
||||
}
|
||||
|
||||
// Fallback to env if config wasn't populated for some reason.
|
||||
$enabled = env('CP_ENABLE_CORS', true);
|
||||
$enabled = env('CP_ENABLE_CORS', false);
|
||||
|
||||
if (! $enabled) {
|
||||
return $next($request);
|
||||
|
||||
32
app/Http/Middleware/EnsureArtworkMaturityAccess.php
Normal file
32
app/Http/Middleware/EnsureArtworkMaturityAccess.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
final class EnsureArtworkMaturityAccess
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if ($request->user('controlpanel') !== null) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
$user = $request->user();
|
||||
$role = strtolower((string) ($user?->role ?? ''));
|
||||
|
||||
if (in_array($role, ['admin', 'moderator'], true)) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
if (! $request->expectsJson() && route('cp.login', absolute: false) !== null) {
|
||||
return redirect()->route('cp.login');
|
||||
}
|
||||
|
||||
abort(Response::HTTP_FORBIDDEN, 'Forbidden.');
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,7 @@ final class ArtworkTagsStoreRequest extends FormRequest
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'tags' => 'required|array|max:15',
|
||||
'tags' => 'required|array|max:' . (int) config('tags.max_user_tags', 30),
|
||||
'tags.*' => 'required|string|max:64',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ final class ArtworkTagsUpdateRequest extends FormRequest
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'tags' => 'required|array|max:15',
|
||||
'tags' => 'required|array|max:' . (int) config('tags.max_user_tags', 30),
|
||||
'tags.*' => 'required|string|max:64',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\Settings;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class UpdateContentPreferencesRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'mature_content_visibility' => ['required', 'in:hide,blur,show'],
|
||||
'mature_content_warning_enabled' => ['required', 'boolean'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,7 @@ final class ApplyArtworkAiAssistRequest extends FormRequest
|
||||
'title_mode' => ['sometimes', Rule::in(['replace', 'insert'])],
|
||||
'description' => ['sometimes', 'nullable', 'string', 'max:5000'],
|
||||
'description_mode' => ['sometimes', Rule::in(['replace', 'append'])],
|
||||
'tags' => ['sometimes', 'array', 'max:15'],
|
||||
'tags' => ['sometimes', 'array', 'max:' . (int) config('tags.max_user_tags', 30)],
|
||||
'tags.*' => ['string', 'max:64'],
|
||||
'tag_mode' => ['sometimes', Rule::in(['add', 'replace', 'remove'])],
|
||||
'category_id' => ['sometimes', 'nullable', 'integer', 'exists:categories,id'],
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<?php
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use App\Services\Maturity\ArtworkMaturityService;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
use Illuminate\Http\Resources\MissingValue;
|
||||
use App\Services\ThumbnailService;
|
||||
@@ -77,7 +78,7 @@ class ArtworkListResource extends JsonResource
|
||||
]
|
||||
: null;
|
||||
|
||||
return [
|
||||
return app(ArtworkMaturityService::class)->decoratePayload([
|
||||
'id' => $artId,
|
||||
'slug' => $slugVal,
|
||||
'title' => $decode($get('title')),
|
||||
@@ -106,6 +107,6 @@ class ArtworkListResource extends JsonResource
|
||||
'direct' => $directUrl,
|
||||
'canonical' => $webUrl ?? $directUrl,
|
||||
],
|
||||
];
|
||||
], $this->resource, $request->user());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<?php
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use App\Services\ArtworkEvolutionService;
|
||||
use App\Services\ContentSanitizer;
|
||||
use App\Services\Maturity\ArtworkMaturityService;
|
||||
use App\Services\ThumbnailPresenter;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
@@ -97,11 +99,11 @@ class ArtworkResource extends JsonResource
|
||||
->exists();
|
||||
}
|
||||
|
||||
if (Schema::hasTable('artwork_awards')) {
|
||||
$viewerAward = DB::table('artwork_awards')
|
||||
if (Schema::hasTable('artwork_medals')) {
|
||||
$viewerAward = DB::table('artwork_medals')
|
||||
->where('user_id', $viewerId)
|
||||
->where('artwork_id', (int) $this->id)
|
||||
->value('medal');
|
||||
->value('medal_type');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -225,8 +227,24 @@ class ArtworkResource extends JsonResource
|
||||
'silver' => (int) ($this->awardStat?->silver_count ?? 0),
|
||||
'bronze' => (int) ($this->awardStat?->bronze_count ?? 0),
|
||||
'score' => (int) ($this->awardStat?->score_total ?? 0),
|
||||
'score_7d' => (int) ($this->awardStat?->score_7d ?? 0),
|
||||
'score_30d' => (int) ($this->awardStat?->score_30d ?? 0),
|
||||
'last_medaled_at' => optional($this->awardStat?->last_medaled_at)->toIsoString(),
|
||||
'viewer_award' => $viewerAward,
|
||||
],
|
||||
'medals' => [
|
||||
'gold' => (int) ($this->awardStat?->gold_count ?? 0),
|
||||
'silver' => (int) ($this->awardStat?->silver_count ?? 0),
|
||||
'bronze' => (int) ($this->awardStat?->bronze_count ?? 0),
|
||||
'score' => (int) ($this->awardStat?->score_total ?? 0),
|
||||
'score_7d' => (int) ($this->awardStat?->score_7d ?? 0),
|
||||
'score_30d' => (int) ($this->awardStat?->score_30d ?? 0),
|
||||
'last_medaled_at' => optional($this->awardStat?->last_medaled_at)->toIsoString(),
|
||||
'current_user_medal' => $viewerAward,
|
||||
'viewer_award' => $viewerAward,
|
||||
],
|
||||
'maturity' => app(ArtworkMaturityService::class)->presentation($this->resource, $request->user()),
|
||||
'evolution' => app(ArtworkEvolutionService::class)->publicPayload($this->resource, $request->user()),
|
||||
'categories' => $this->categories->map(fn ($category) => [
|
||||
'id' => (int) $category->id,
|
||||
'slug' => (string) $category->slug,
|
||||
|
||||
Reference in New Issue
Block a user