Implement creator studio and upload updates

This commit is contained in:
2026-04-04 10:12:02 +02:00
parent 1da7d3bf88
commit 0b216b7ecd
15107 changed files with 31206 additions and 626514 deletions

View File

@@ -105,6 +105,13 @@ final class ArtworkDownloadController extends Controller
*/
private function resolveDownloadUrl(Artwork $artwork): string
{
$filePath = trim((string) ($artwork->file_path ?? ''), '/');
$cdn = rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/');
if ($filePath !== '') {
return $cdn . '/' . $filePath;
}
$hash = $artwork->hash ?? null;
$ext = ltrim((string) ($artwork->file_ext ?: $artwork->thumb_ext ?: 'webp'), '.');
@@ -112,9 +119,9 @@ final class ArtworkDownloadController extends Controller
$h = strtolower(preg_replace('/[^a-f0-9]/', '', $hash));
$h1 = substr($h, 0, 2);
$h2 = substr($h, 2, 2);
$cdn = rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/');
$prefix = trim((string) config('uploads.object_storage.prefix', 'artworks'), '/');
return sprintf('%s/original/%s/%s/%s.%s', $cdn, $h1, $h2, $h, $ext);
return sprintf('%s/%s/original/%s/%s/%s.%s', $cdn, $prefix, $h1, $h2, $h, $ext);
}
// Fallback: best available thumbnail size

View File

@@ -16,6 +16,7 @@ use App\Services\NovaCards\NovaCardDraftService;
use App\Services\NovaCards\NovaCardPresenter;
use App\Services\NovaCards\NovaCardPublishService;
use App\Services\NovaCards\NovaCardRenderService;
use Carbon\Carbon;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
@@ -115,9 +116,10 @@ class NovaCardDraftController extends Controller
public function publish(SaveNovaCardDraftRequest $request, int $id): JsonResponse
{
$card = $this->editableCard($request, $id);
$validated = $request->validated();
if ($request->validated() !== []) {
$card = $this->drafts->autosave($card, $request->validated());
if ($validated !== []) {
$card = $this->drafts->autosave($card, $validated);
}
if (trim((string) $card->title) === '' || trim((string) $card->quote_text) === '') {
@@ -126,6 +128,32 @@ class NovaCardDraftController extends Controller
], Response::HTTP_UNPROCESSABLE_ENTITY);
}
$publishMode = (string) ($validated['publish_mode'] ?? 'now');
if ($publishMode === 'schedule') {
if (empty($validated['scheduled_for'])) {
return response()->json([
'message' => 'Choose a date and time for scheduled publishing.',
], Response::HTTP_UNPROCESSABLE_ENTITY);
}
try {
$card = $this->publishes->schedule(
$card->loadMissing('backgroundImage'),
Carbon::parse((string) $validated['scheduled_for']),
isset($validated['scheduling_timezone']) ? (string) $validated['scheduling_timezone'] : null,
);
} catch (\InvalidArgumentException $exception) {
return response()->json([
'message' => $exception->getMessage(),
], Response::HTTP_UNPROCESSABLE_ENTITY);
}
return response()->json([
'data' => $this->presenter->card($card, true, $request->user()),
]);
}
$card = $this->publishes->publishNow($card->loadMissing('backgroundImage'));
event(new NovaCardPublished($card));

View File

@@ -83,6 +83,11 @@ final class UploadController extends Controller
$sessionId = (string) $request->validated('session_id');
$artworkId = (int) $request->validated('artwork_id');
$originalFileName = $request->validated('file_name');
$archiveSessionId = $request->validated('archive_session_id');
$archiveOriginalFileName = $request->validated('archive_file_name');
$additionalScreenshotSessions = collect($request->validated('additional_screenshot_sessions', []))
->filter(fn ($payload) => is_array($payload) && is_string($payload['session_id'] ?? null))
->values();
$session = $sessions->getOrFail($sessionId);
@@ -112,14 +117,81 @@ final class UploadController extends Controller
], Response::HTTP_UNPROCESSABLE_ENTITY);
}
$validatedArchive = null;
if (is_string($archiveSessionId) && trim($archiveSessionId) !== '') {
$validatedArchive = $pipeline->validateAndHashArchive($archiveSessionId);
if (! $validatedArchive->validation->ok || ! $validatedArchive->hash) {
return response()->json([
'message' => 'Archive validation failed.',
'reason' => $validatedArchive->validation->reason,
], Response::HTTP_UNPROCESSABLE_ENTITY);
}
$archiveScan = $pipeline->scan($archiveSessionId);
if (! $archiveScan->ok) {
return response()->json([
'message' => 'Archive scan failed.',
'reason' => $archiveScan->reason,
], Response::HTTP_UNPROCESSABLE_ENTITY);
}
}
$validatedAdditionalScreenshots = [];
foreach ($additionalScreenshotSessions as $payload) {
$screenshotSessionId = (string) ($payload['session_id'] ?? '');
if ($screenshotSessionId === '') {
continue;
}
$validatedScreenshot = $pipeline->validateAndHash($screenshotSessionId);
if (! $validatedScreenshot->validation->ok || ! $validatedScreenshot->hash) {
return response()->json([
'message' => 'Screenshot validation failed.',
'reason' => $validatedScreenshot->validation->reason,
], Response::HTTP_UNPROCESSABLE_ENTITY);
}
$screenshotScan = $pipeline->scan($screenshotSessionId);
if (! $screenshotScan->ok) {
return response()->json([
'message' => 'Screenshot scan failed.',
'reason' => $screenshotScan->reason,
], Response::HTTP_UNPROCESSABLE_ENTITY);
}
$validatedAdditionalScreenshots[] = [
'session_id' => $screenshotSessionId,
'hash' => $validatedScreenshot->hash,
'file_name' => is_string($payload['file_name'] ?? null) ? $payload['file_name'] : null,
];
}
try {
$status = DB::transaction(function () use ($pipeline, $sessionId, $validated, $artworkId, $originalFileName) {
$status = DB::transaction(function () use ($pipeline, $sessionId, $validated, $artworkId, $originalFileName, $archiveSessionId, $validatedArchive, $archiveOriginalFileName, $validatedAdditionalScreenshots) {
if ((bool) config('uploads.queue_derivatives', false)) {
GenerateDerivativesJob::dispatch($sessionId, $validated->hash, $artworkId, is_string($originalFileName) ? $originalFileName : null)->afterCommit();
GenerateDerivativesJob::dispatch(
$sessionId,
$validated->hash,
$artworkId,
is_string($originalFileName) ? $originalFileName : null,
is_string($archiveSessionId) ? $archiveSessionId : null,
$validatedArchive?->hash,
is_string($archiveOriginalFileName) ? $archiveOriginalFileName : null,
$validatedAdditionalScreenshots
)->afterCommit();
return 'queued';
}
$pipeline->processAndPublish($sessionId, $validated->hash, $artworkId, is_string($originalFileName) ? $originalFileName : null);
$pipeline->processAndPublish(
$sessionId,
$validated->hash,
$artworkId,
is_string($originalFileName) ? $originalFileName : null,
is_string($archiveSessionId) ? $archiveSessionId : null,
$validatedArchive?->hash,
is_string($archiveOriginalFileName) ? $archiveOriginalFileName : null,
$validatedAdditionalScreenshots
);
// Derivatives are available now; dispatch AI auto-tagging.
AutoTagArtworkJob::dispatch($artworkId, $validated->hash)->afterCommit();
@@ -132,6 +204,8 @@ final class UploadController extends Controller
'hash' => $validated->hash,
'artwork_id' => $artworkId,
'status' => $status,
'archive_session_id' => is_string($archiveSessionId) ? $archiveSessionId : null,
'additional_screenshot_session_ids' => array_values(array_map(static fn (array $payload): string => (string) $payload['session_id'], $validatedAdditionalScreenshots)),
]);
return response()->json([
@@ -540,13 +614,6 @@ final class UploadController extends Controller
$slugBase = 'artwork';
}
$slug = $slugBase;
$suffix = 2;
while (Artwork::query()->where('slug', $slug)->where('id', '!=', $artwork->id)->exists()) {
$slug = $slugBase . '-' . $suffix;
$suffix++;
}
$artwork->title = $title;
if (array_key_exists('description', $validated)) {
$artwork->description = $validated['description'];
@@ -554,7 +621,7 @@ final class UploadController extends Controller
if (array_key_exists('is_mature', $validated) || array_key_exists('nsfw', $validated)) {
$artwork->is_mature = (bool) ($validated['is_mature'] ?? $validated['nsfw'] ?? false);
}
$artwork->slug = $slug;
$artwork->slug = Str::limit($slugBase, 160, '');
$artwork->artwork_timezone = $validated['timezone'] ?? null;
// Sync category if provided

View File

@@ -6,6 +6,7 @@ use App\Http\Controllers\Controller;
use App\Http\Requests\ArtworkIndexRequest;
use App\Models\Artwork;
use App\Models\Category;
use App\Models\ContentType;
use Illuminate\Http\Request;
use Illuminate\View\View;
@@ -66,7 +67,7 @@ class ArtworkController extends Controller
$artworkSlug = $artwork->slug;
} elseif ($artwork) {
$artworkSlug = (string) $artwork;
$foundArtwork = Artwork::where('slug', $artworkSlug)->first();
$foundArtwork = $this->findArtworkForCategoryPath($contentTypeSlug, $categoryPath, $artworkSlug);
}
// When the URL can represent a nested category path (e.g. /skins/audio/winamp),
@@ -104,4 +105,24 @@ class ArtworkController extends Controller
$foundArtwork->slug,
);
}
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;
$query = Artwork::query()->where('slug', $artworkSlug);
if ($category) {
$query->whereHas('categories', function ($categoryQuery) use ($category): void {
$categoryQuery->where('categories.id', $category->id);
});
}
return $query
->orderByDesc('published_at')
->orderByDesc('id')
->first();
}
}

View File

@@ -27,6 +27,11 @@ final class ArtworkDownloadController extends Controller
'webp',
'bmp',
'tiff',
'zip',
'rar',
'7z',
'tar',
'gz',
];
public function __invoke(Request $request, int $id): BinaryFileResponse
@@ -36,22 +41,19 @@ final class ArtworkDownloadController extends Controller
abort(404);
}
$hash = strtolower((string) $artwork->hash);
$ext = strtolower(ltrim((string) $artwork->file_ext, '.'));
$filePath = $this->resolveOriginalPath($artwork);
$ext = strtolower(ltrim((string) pathinfo($filePath, PATHINFO_EXTENSION), '.'));
if (! $this->isValidHash($hash) || ! in_array($ext, self::ALLOWED_EXTENSIONS, true)) {
if ($filePath === '' || ! in_array($ext, self::ALLOWED_EXTENSIONS, true)) {
abort(404);
}
$filePath = $this->resolveOriginalPath($hash, $ext);
$this->recordDownload($request, $artwork->id);
$this->incrementDownloadCountIfAvailable($artwork->id);
if (! File::isFile($filePath)) {
Log::warning('Artwork original file missing for download.', [
'artwork_id' => $artwork->id,
'hash' => $hash,
'ext' => $ext,
'resolved_path' => $filePath,
]);
@@ -65,16 +67,29 @@ final class ArtworkDownloadController extends Controller
return response()->download($filePath, $downloadName);
}
private function resolveOriginalPath(string $hash, string $ext): string
private function resolveOriginalPath(Artwork $artwork): string
{
$firstDir = substr($hash, 0, 2);
$secondDir = substr($hash, 2, 2);
$root = rtrim((string) config('uploads.storage_root'), DIRECTORY_SEPARATOR);
$relative = trim((string) $artwork->file_path, '/');
$prefix = trim((string) config('uploads.object_storage.prefix', 'artworks'), '/') . '/original/';
if ($relative !== '' && str_starts_with($relative, $prefix)) {
$suffix = substr($relative, strlen($prefix));
$root = rtrim((string) config('uploads.local_originals_root'), DIRECTORY_SEPARATOR);
return $root . DIRECTORY_SEPARATOR . str_replace(['/', '\\'], DIRECTORY_SEPARATOR, (string) $suffix);
}
$hash = strtolower((string) $artwork->hash);
$ext = strtolower(ltrim((string) $artwork->file_ext, '.'));
if (! $this->isValidHash($hash) || $ext === '') {
return '';
}
$root = rtrim((string) config('uploads.local_originals_root'), DIRECTORY_SEPARATOR);
return $root
. DIRECTORY_SEPARATOR . 'original'
. DIRECTORY_SEPARATOR . $firstDir
. DIRECTORY_SEPARATOR . $secondDir
. DIRECTORY_SEPARATOR . substr($hash, 0, 2)
. DIRECTORY_SEPARATOR . substr($hash, 2, 2)
. DIRECTORY_SEPARATOR . $hash . '.' . $ext;
}

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Internal;
use App\Http\Controllers\Controller;
use App\Models\NovaCard;
use App\Services\NovaCards\NovaCardPresenter;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
class NovaCardRenderFrameController extends Controller
{
public function __construct(
private readonly NovaCardPresenter $presenter,
) {
}
public function show(Request $request, string $uuid): Response
{
abort_unless($request->hasValidSignature(), 403);
$card = NovaCard::query()
->with(['backgroundImage', 'user'])
->where('uuid', $uuid)
->firstOrFail();
$format = config('nova_cards.formats.' . $card->format) ?? config('nova_cards.formats.square');
$width = (int) ($format['width'] ?? 1080);
$height = (int) ($format['height'] ?? 1080);
$cardData = $this->presenter->card($card, true, $card->user);
$fonts = collect((array) config('nova_cards.font_presets', []))
->map(fn (array $v, string $k): array => array_merge($v, ['key' => $k]))
->values()
->all();
return response()->view('nova-cards.render-frame', compact('cardData', 'fonts', 'width', 'height'));
}
}

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use Illuminate\Http\Response;
final class RobotsTxtController extends Controller
{
public function __invoke(): Response
{
$content = implode("\n", [
'User-agent: *',
'Allow: /',
'Sitemap: ' . url('/sitemap.xml'),
'',
]);
return response($content, 200, [
'Content-Type' => 'text/plain; charset=UTF-8',
'Cache-Control' => 'public, max-age=' . max(60, (int) config('sitemaps.cache_ttl_seconds', 900)),
]);
}
}

View File

@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Services\Sitemaps\SitemapBuildService;
use App\Services\Sitemaps\PublishedSitemapResolver;
use App\Services\Sitemaps\SitemapXmlRenderer;
use Illuminate\Http\Response;
final class SitemapController extends Controller
{
public function __construct(
private readonly SitemapBuildService $build,
private readonly PublishedSitemapResolver $published,
private readonly SitemapXmlRenderer $renderer,
) {
}
public function index(): Response
{
if ((bool) config('sitemaps.delivery.prefer_published_release', true)) {
$published = $this->published->resolveIndex();
if ($published !== null) {
return $this->renderer->xmlResponse($published['content']);
}
}
abort_unless((bool) config('sitemaps.delivery.fallback_to_live_build', true), 404);
$built = $this->build->buildIndex(
force: false,
persist: (bool) config('sitemaps.refresh.build_on_request', true),
);
return $this->renderer->xmlResponse($built['content']);
}
public function show(string $name): Response
{
if ((bool) config('sitemaps.delivery.prefer_published_release', true)) {
$published = $this->published->resolveNamed($name);
if ($published !== null) {
return $this->renderer->xmlResponse($published['content']);
}
}
abort_unless((bool) config('sitemaps.delivery.fallback_to_live_build', true), 404);
$built = $this->build->buildNamed(
$name,
force: false,
persist: (bool) config('sitemaps.refresh.build_on_request', true),
);
abort_if($built === null, 404);
return $this->renderer->xmlResponse($built['content']);
}
}

View File

@@ -9,6 +9,7 @@ use App\Models\Artwork;
use App\Models\Category;
use App\Models\ContentType;
use App\Models\ArtworkVersion;
use App\Services\Cdn\ArtworkCdnPurgeService;
use App\Services\ArtworkSearchIndexer;
use App\Services\TagService;
use App\Services\ArtworkVersioningService;
@@ -36,6 +37,7 @@ final class StudioArtworksApiController extends Controller
private readonly ArtworkSearchIndexer $searchIndexer,
private readonly TagDiscoveryService $tagDiscoveryService,
private readonly TagService $tagService,
private readonly ArtworkCdnPurgeService $cdnPurge,
) {}
/**
@@ -419,17 +421,18 @@ final class StudioArtworksApiController extends Controller
$artworkFiles = app(\App\Repositories\Uploads\ArtworkFileRepository::class);
// 1. Store original on disk (preserve extension when possible)
$originalPath = $derivatives->storeOriginal($tempPath, $hash);
$originalAsset = $derivatives->storeOriginal($tempPath, $hash);
$originalPath = $originalAsset['local_path'];
$origFilename = basename($originalPath);
$originalRelative = $storage->sectionRelativePath('original', $hash, $origFilename);
$origMime = File::exists($originalPath) ? File::mimeType($originalPath) : 'application/octet-stream';
$artworkFiles->upsert($artwork->id, 'orig', $originalRelative, $origMime, (int) filesize($originalPath));
// 2. Generate thumbnails (xs/sm/md/lg/xl)
$publicAbsolute = $derivatives->generatePublicDerivatives($tempPath, $hash);
foreach ($publicAbsolute as $variant => $absolutePath) {
$publicAssets = $derivatives->generatePublicDerivatives($tempPath, $hash);
foreach ($publicAssets as $variant => $asset) {
$relativePath = $storage->sectionRelativePath($variant, $hash, $hash . '.webp');
$artworkFiles->upsert($artwork->id, $variant, $relativePath, 'image/webp', (int) filesize($absolutePath));
$artworkFiles->upsert($artwork->id, $variant, $relativePath, 'image/webp', (int) ($asset['size'] ?? 0));
}
// 3. Get new dimensions
@@ -592,18 +595,10 @@ final class StudioArtworksApiController extends Controller
private function purgeCdnCache(\App\Models\Artwork $artwork, string $oldHash): void
{
try {
$purgeUrl = config('cdn.purge_url');
if (empty($purgeUrl)) {
Log::debug('CDN purge skipped — cdn.purge_url not configured', ['artwork_id' => $artwork->id]);
return;
}
$paths = array_map(
fn (string $size) => "/thumbs/{$oldHash}/{$size}.webp",
['sm', 'md', 'lg', 'xl']
);
\Illuminate\Support\Facades\Http::timeout(5)->post($purgeUrl, ['paths' => $paths]);
$this->cdnPurge->purgeArtworkHashVariants($oldHash, 'webp', ['xs', 'sm', 'md', 'lg', 'xl', 'sq'], [
'artwork_id' => $artwork->id,
'reason' => 'artwork_file_replaced',
]);
} catch (\Throwable $e) {
Log::warning('CDN cache purge failed', ['artwork_id' => $artwork->id, 'error' => $e->getMessage()]);
}

View File

@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Studio;
use App\Http\Controllers\Controller;
use App\Services\Studio\CreatorStudioCommentService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
final class StudioCommentsApiController extends Controller
{
public function __construct(
private readonly CreatorStudioCommentService $comments,
) {
}
public function reply(Request $request, string $module, int $commentId): JsonResponse
{
$payload = $request->validate([
'content' => ['required', 'string', 'min:1', 'max:10000'],
]);
$this->comments->reply($request->user(), $module, $commentId, (string) $payload['content']);
return response()->json(['ok' => true]);
}
public function moderate(Request $request, string $module, int $commentId): JsonResponse
{
$this->comments->moderate($request->user(), $module, $commentId);
return response()->json(['ok' => true]);
}
public function report(Request $request, string $module, int $commentId): JsonResponse
{
$payload = $request->validate([
'reason' => ['required', 'string', 'max:120'],
'details' => ['nullable', 'string', 'max:4000'],
]);
return response()->json([
'ok' => true,
'report' => $this->comments->report(
$request->user(),
$module,
$commentId,
(string) $payload['reason'],
isset($payload['details']) ? (string) $payload['details'] : null,
),
], 201);
}
}

View File

@@ -5,10 +5,25 @@ declare(strict_types=1);
namespace App\Http\Controllers\Studio;
use App\Http\Controllers\Controller;
use App\Models\Category;
use App\Models\ContentType;
use App\Services\Studio\StudioMetricsService;
use App\Services\Studio\CreatorStudioAnalyticsService;
use App\Services\Studio\CreatorStudioAssetService;
use App\Services\Studio\CreatorStudioCalendarService;
use App\Services\Studio\CreatorStudioCommentService;
use App\Services\Studio\CreatorStudioContentService;
use App\Services\Studio\CreatorStudioFollowersService;
use App\Services\Studio\CreatorStudioGrowthService;
use App\Services\Studio\CreatorStudioActivityService;
use App\Services\Studio\CreatorStudioInboxService;
use App\Services\Studio\CreatorStudioOverviewService;
use App\Services\Studio\CreatorStudioPreferenceService;
use App\Services\Studio\CreatorStudioChallengeService;
use App\Services\Studio\CreatorStudioSearchService;
use App\Services\Studio\CreatorStudioScheduledService;
use App\Support\CoverUrl;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Inertia\Inertia;
use Inertia\Response;
@@ -18,20 +33,51 @@ use Inertia\Response;
final class StudioController extends Controller
{
public function __construct(
private readonly StudioMetricsService $metrics,
private readonly CreatorStudioOverviewService $overview,
private readonly CreatorStudioContentService $content,
private readonly CreatorStudioAnalyticsService $analytics,
private readonly CreatorStudioFollowersService $followers,
private readonly CreatorStudioCommentService $comments,
private readonly CreatorStudioAssetService $assets,
private readonly CreatorStudioPreferenceService $preferences,
private readonly CreatorStudioScheduledService $scheduled,
private readonly CreatorStudioActivityService $activity,
private readonly CreatorStudioCalendarService $calendar,
private readonly CreatorStudioInboxService $inbox,
private readonly CreatorStudioSearchService $search,
private readonly CreatorStudioChallengeService $challenges,
private readonly CreatorStudioGrowthService $growth,
) {}
/**
* Studio Overview Dashboard (/studio)
*/
public function index(Request $request): Response
public function index(Request $request): Response|RedirectResponse
{
$userId = $request->user()->id;
$user = $request->user();
$prefs = $this->preferences->forUser($user);
if (! $request->boolean('overview') && $prefs['default_landing_page'] !== 'overview') {
return redirect()->route($this->landingPageRoute($prefs['default_landing_page']), $request->query(), 302);
}
return Inertia::render('Studio/StudioDashboard', [
'kpis' => $this->metrics->getDashboardKpis($userId),
'topPerformers' => $this->metrics->getTopPerformers($userId, 6),
'recentComments' => $this->metrics->getRecentComments($userId, 5),
'overview' => $this->overview->build($user),
'analytics' => $this->analytics->overview($user, $prefs['analytics_range_days']),
]);
}
public function content(Request $request): Response
{
$prefs = $this->preferences->forUser($request->user());
$listing = $this->content->list($request->user(), $request->only(['module', 'bucket', 'q', 'sort', 'page', 'per_page', 'category', 'tag', 'visibility', 'activity_state', 'stale']));
$listing['default_view'] = $prefs['default_content_view'];
return Inertia::render('Studio/StudioContentIndex', [
'title' => 'Content',
'description' => 'Manage every artwork, card, collection, and story from one queue.',
'listing' => $listing,
'quickCreate' => $this->content->quickCreate(),
]);
}
@@ -40,28 +86,329 @@ final class StudioController extends Controller
*/
public function artworks(Request $request): Response
{
$provider = $this->content->provider('artworks');
$prefs = $this->preferences->forUser($request->user());
$listing = $this->content->list($request->user(), $request->only(['q', 'sort', 'bucket', 'page', 'per_page', 'category', 'tag']), null, 'artworks');
$listing['default_view'] = $prefs['default_content_view'];
return Inertia::render('Studio/StudioArtworks', [
'categories' => $this->getCategories(),
'title' => 'Artworks',
'description' => 'Upload, manage, and review long-form visual work from the shared Creator Studio workflow.',
'summary' => $provider?->summary($request->user()),
'listing' => $listing,
'quickCreate' => $this->content->quickCreate(),
]);
}
/**
* Drafts (/studio/artworks/drafts)
* Drafts (/studio/drafts)
*/
public function drafts(Request $request): Response
{
$prefs = $this->preferences->forUser($request->user());
$listing = $this->content->list($request->user(), $request->only(['module', 'q', 'sort', 'page', 'per_page', 'stale']), 'drafts');
$listing['default_view'] = $prefs['default_content_view'];
return Inertia::render('Studio/StudioDrafts', [
'categories' => $this->getCategories(),
'title' => 'Drafts',
'description' => 'Resume unfinished work across every creator module.',
'listing' => $listing,
'quickCreate' => $this->content->quickCreate(),
]);
}
/**
* Archived (/studio/artworks/archived)
* Archived (/studio/archived)
*/
public function archived(Request $request): Response
{
$prefs = $this->preferences->forUser($request->user());
$listing = $this->content->list($request->user(), $request->only(['module', 'q', 'sort', 'page', 'per_page']), 'archived');
$listing['default_view'] = $prefs['default_content_view'];
return Inertia::render('Studio/StudioArchived', [
'categories' => $this->getCategories(),
'title' => 'Archived',
'description' => 'Review hidden, rejected, and archived content in one place.',
'listing' => $listing,
'quickCreate' => $this->content->quickCreate(),
]);
}
public function scheduled(Request $request): Response
{
$listing = $this->scheduled->list($request->user(), $request->only(['module', 'q', 'page', 'per_page', 'range', 'start_date', 'end_date']));
return Inertia::render('Studio/StudioScheduled', [
'title' => 'Scheduled',
'description' => 'Keep track of upcoming publishes across artworks, cards, collections, and stories.',
'listing' => $listing,
'endpoints' => [
'publishNowPattern' => route('api.studio.schedule.publishNow', ['module' => '__MODULE__', 'id' => '__ID__']),
'unschedulePattern' => route('api.studio.schedule.unschedule', ['module' => '__MODULE__', 'id' => '__ID__']),
],
]);
}
public function calendar(Request $request): Response
{
return Inertia::render('Studio/StudioCalendar', [
'title' => 'Calendar',
'description' => 'Plan publishing cadence, spot overloaded days, and move quickly between scheduled work and the unscheduled queue.',
'calendar' => $this->calendar->build($request->user(), $request->only(['view', 'module', 'status', 'q', 'focus_date'])),
'endpoints' => [
'publishNowPattern' => route('api.studio.schedule.publishNow', ['module' => '__MODULE__', 'id' => '__ID__']),
'unschedulePattern' => route('api.studio.schedule.unschedule', ['module' => '__MODULE__', 'id' => '__ID__']),
],
]);
}
public function collections(Request $request): Response
{
$provider = $this->content->provider('collections');
$prefs = $this->preferences->forUser($request->user());
$listing = $this->content->list($request->user(), $request->only(['q', 'sort', 'page', 'per_page', 'visibility']), null, 'collections');
$listing['default_view'] = $prefs['default_content_view'];
return Inertia::render('Studio/StudioCollections', [
'title' => 'Collections',
'description' => 'Curate sets, track collection performance, and keep editorial surfaces organised.',
'summary' => $provider?->summary($request->user()),
'listing' => $listing,
'quickCreate' => $this->content->quickCreate(),
'dashboardUrl' => route('settings.collections.dashboard'),
]);
}
public function stories(Request $request): Response
{
$provider = $this->content->provider('stories');
$prefs = $this->preferences->forUser($request->user());
$listing = $this->content->list($request->user(), $request->only(['q', 'sort', 'page', 'per_page', 'activity_state']), null, 'stories');
$listing['default_view'] = $prefs['default_content_view'];
return Inertia::render('Studio/StudioStories', [
'title' => 'Stories',
'description' => 'Track drafts, jump into the editor, and monitor story reach from Studio.',
'summary' => $provider?->summary($request->user()),
'listing' => $listing,
'quickCreate' => $this->content->quickCreate(),
'dashboardUrl' => route('creator.stories.index'),
]);
}
public function assets(Request $request): Response
{
return Inertia::render('Studio/StudioAssets', [
'title' => 'Assets',
'description' => 'A reusable creator asset library for card backgrounds, story covers, collection covers, artwork previews, and profile branding.',
'assets' => $this->assets->library($request->user(), $request->only(['type', 'source', 'sort', 'q', 'page', 'per_page'])),
]);
}
public function comments(Request $request): Response
{
$listing = $this->comments->list($request->user(), $request->only(['module', 'q', 'page', 'per_page']));
return Inertia::render('Studio/StudioComments', [
'title' => 'Comments',
'description' => 'View context, reply in place, remove unsafe comments, and report issues across all of your content.',
'listing' => $listing,
'endpoints' => [
'replyPattern' => route('api.studio.comments.reply', ['module' => '__MODULE__', 'commentId' => '__COMMENT__']),
'moderatePattern' => route('api.studio.comments.moderate', ['module' => '__MODULE__', 'commentId' => '__COMMENT__']),
'reportPattern' => route('api.studio.comments.report', ['module' => '__MODULE__', 'commentId' => '__COMMENT__']),
],
]);
}
public function followers(Request $request): Response
{
return Inertia::render('Studio/StudioFollowers', [
'title' => 'Followers',
'description' => 'See who is following your work, who follows back, and which supporters are most established.',
'listing' => $this->followers->list($request->user(), $request->only(['q', 'sort', 'relationship', 'page'])),
]);
}
public function activity(Request $request): Response
{
return Inertia::render('Studio/StudioActivity', [
'title' => 'Activity',
'description' => 'One creator-facing inbox for notifications, comments, and follower activity.',
'listing' => $this->activity->list($request->user(), $request->only(['type', 'module', 'q', 'page', 'per_page'])),
'endpoints' => [
'markAllRead' => route('api.studio.activity.readAll'),
],
]);
}
public function inbox(Request $request): Response
{
return Inertia::render('Studio/StudioInbox', [
'title' => 'Inbox',
'description' => 'A creator-first response surface for comments, notifications, followers, reminders, and what needs attention now.',
'inbox' => $this->inbox->build($request->user(), $request->only(['type', 'module', 'q', 'page', 'per_page', 'read_state', 'priority'])),
'endpoints' => [
'markAllRead' => route('api.studio.activity.readAll'),
],
]);
}
public function search(Request $request): Response
{
return Inertia::render('Studio/StudioSearch', [
'title' => 'Search',
'description' => 'Search across content, comments, inbox signals, and reusable assets without leaving Creator Studio.',
'search' => $this->search->build($request->user(), $request->only(['q', 'module', 'type'])),
'quickCreate' => $this->content->quickCreate(),
]);
}
public function challenges(Request $request): Response
{
$data = $this->challenges->build($request->user());
return Inertia::render('Studio/StudioChallenges', [
'title' => 'Challenges',
'description' => 'Track active Nova Cards challenge runs, review your submissions, and keep challenge-ready cards close to hand.',
'summary' => $data['summary'],
'spotlight' => $data['spotlight'],
'activeChallenges' => $data['active_challenges'],
'recentEntries' => $data['recent_entries'],
'cardLeaders' => $data['card_leaders'],
'reminders' => $data['reminders'],
]);
}
public function growth(Request $request): Response
{
$prefs = $this->preferences->forUser($request->user());
$rangeDays = in_array((int) $request->query('range_days', 0), [7, 14, 30, 60, 90], true)
? (int) $request->query('range_days')
: $prefs['analytics_range_days'];
$data = $this->growth->build($request->user(), $rangeDays);
return Inertia::render('Studio/StudioGrowth', [
'title' => 'Growth',
'description' => 'A creator-readable view of profile readiness, publishing cadence, engagement momentum, and challenge participation.',
'summary' => $data['summary'],
'moduleFocus' => $data['module_focus'],
'checkpoints' => $data['checkpoints'],
'opportunities' => $data['opportunities'],
'milestones' => $data['milestones'],
'momentum' => $data['momentum'],
'topContent' => $data['top_content'],
'rangeDays' => $data['range_days'],
]);
}
public function profile(Request $request): Response
{
$user = $request->user()->loadMissing(['profile', 'statistics']);
$prefs = $this->preferences->forUser($user);
$socialLinks = DB::table('user_social_links')
->where('user_id', $user->id)
->orderBy('platform')
->get(['platform', 'url'])
->map(fn ($row): array => [
'platform' => (string) $row->platform,
'url' => (string) $row->url,
])
->values()
->all();
return Inertia::render('Studio/StudioProfile', [
'title' => 'Profile',
'description' => 'Keep your public creator presence aligned with the work you are publishing.',
'profile' => [
'name' => $user->name,
'username' => $user->username,
'bio' => $user->profile?->about,
'tagline' => $user->profile?->description,
'location' => $user->profile?->country,
'website' => $user->profile?->website,
'avatar_url' => $user->profile?->avatar_url,
'cover_url' => $user->cover_hash && $user->cover_ext ? CoverUrl::forUser($user->cover_hash, $user->cover_ext, time()) : null,
'cover_position' => (int) ($user->cover_position ?? 50),
'followers' => (int) ($user->statistics?->followers_count ?? 0),
'profile_url' => '/@' . strtolower((string) $user->username),
'social_links' => $socialLinks,
],
'moduleSummaries' => $this->content->moduleSummaries($user),
'featuredModules' => $prefs['featured_modules'],
'featuredContent' => $this->content->selectedItems($user, $prefs['featured_content']),
'endpoints' => [
'profile' => route('api.studio.preferences.profile'),
'avatarUpload' => route('avatar.upload'),
'coverUpload' => route('api.profile.cover.upload'),
'coverPosition' => route('api.profile.cover.position'),
'coverDelete' => route('api.profile.cover.destroy'),
],
]);
}
public function featured(Request $request): Response
{
$prefs = $this->preferences->forUser($request->user());
return Inertia::render('Studio/StudioFeatured', [
'title' => 'Featured',
'description' => 'Choose the artwork, card, collection, and story that should represent each module on your public profile.',
'items' => $this->content->featuredCandidates($request->user(), 12),
'selected' => $prefs['featured_content'],
'featuredModules' => $prefs['featured_modules'],
'endpoints' => [
'save' => route('api.studio.preferences.featured'),
],
]);
}
public function settings(Request $request): Response
{
return Inertia::render('Studio/StudioSettings', [
'title' => 'Settings',
'description' => 'Keep system handoff links, legacy dashboards, and future Studio control surfaces organized in one place.',
'links' => [
['label' => 'Profile settings', 'url' => route('settings.profile'), 'icon' => 'fa-solid fa-user-gear'],
['label' => 'Collection dashboard', 'url' => route('settings.collections.dashboard'), 'icon' => 'fa-solid fa-layer-group'],
['label' => 'Story dashboard', 'url' => route('creator.stories.index'), 'icon' => 'fa-solid fa-feather-pointed'],
['label' => 'Followers', 'url' => route('dashboard.followers'), 'icon' => 'fa-solid fa-user-group'],
['label' => 'Received comments', 'url' => route('dashboard.comments.received'), 'icon' => 'fa-solid fa-comments'],
],
'sections' => [
[
'title' => 'Studio preferences moved into their own surface',
'body' => 'Use the dedicated Preferences page for layout, landing page, analytics window, widget order, and shortcut controls.',
'href' => route('studio.preferences'),
'cta' => 'Open preferences',
],
[
'title' => 'Future-ready control points',
'body' => 'Notification routing, automation defaults, and collaboration hooks can plug into this settings surface without overloading creator workflow pages.',
'href' => route('studio.growth'),
'cta' => 'Review growth',
],
],
]);
}
public function preferences(Request $request): Response
{
$prefs = $this->preferences->forUser($request->user());
return Inertia::render('Studio/StudioPreferences', [
'title' => 'Preferences',
'description' => 'Control how Creator Studio opens, which widgets stay visible, and where your daily workflow starts.',
'preferences' => $prefs,
'links' => [
['label' => 'Profile settings', 'url' => route('settings.profile'), 'icon' => 'fa-solid fa-user-gear'],
['label' => 'Featured content', 'url' => route('studio.featured'), 'icon' => 'fa-solid fa-wand-magic-sparkles'],
['label' => 'Growth overview', 'url' => route('studio.growth'), 'icon' => 'fa-solid fa-chart-line'],
['label' => 'Studio settings', 'url' => route('studio.settings'), 'icon' => 'fa-solid fa-sliders'],
],
'endpoints' => [
'save' => route('api.studio.preferences.settings'),
],
]);
}
@@ -151,14 +498,24 @@ final class StudioController extends Controller
*/
public function analyticsOverview(Request $request): Response
{
$userId = $request->user()->id;
$data = $this->metrics->getAnalyticsOverview($userId);
$user = $request->user();
$prefs = $this->preferences->forUser($user);
$rangeDays = in_array((int) $request->query('range_days', 0), [7, 14, 30, 60, 90], true)
? (int) $request->query('range_days')
: $prefs['analytics_range_days'];
$data = $this->analytics->overview($user, $rangeDays);
return Inertia::render('Studio/StudioAnalytics', [
'totals' => $data['totals'],
'topArtworks' => $data['top_artworks'],
'contentBreakdown' => $data['content_breakdown'],
'recentComments' => $this->metrics->getRecentComments($userId, 8),
'topContent' => $data['top_content'],
'moduleBreakdown' => $data['module_breakdown'],
'viewsTrend' => $data['views_trend'],
'engagementTrend' => $data['engagement_trend'],
'publishingTimeline' => $data['publishing_timeline'],
'comparison' => $data['comparison'],
'insightBlocks' => $data['insight_blocks'],
'rangeDays' => $data['range_days'],
'recentComments' => $this->overview->recentComments($user, 8),
]);
}
@@ -184,4 +541,22 @@ final class StudioController extends Controller
];
})->values()->all();
}
private function landingPageRoute(string $page): string
{
return match ($page) {
'content' => 'studio.content',
'drafts' => 'studio.drafts',
'scheduled' => 'studio.scheduled',
'analytics' => 'studio.analytics',
'activity' => 'studio.activity',
'calendar' => 'studio.calendar',
'inbox' => 'studio.inbox',
'search' => 'studio.search',
'growth' => 'studio.growth',
'challenges' => 'studio.challenges',
'preferences' => 'studio.preferences',
default => 'studio.index',
};
}
}

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Studio;
use App\Http\Controllers\Controller;
use App\Services\Studio\CreatorStudioEventService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
final class StudioEventsApiController extends Controller
{
public function __construct(
private readonly CreatorStudioEventService $events,
) {
}
public function store(Request $request): JsonResponse
{
$payload = $request->validate([
'event_type' => ['required', 'string', Rule::in($this->events->allowedEvents())],
'module' => ['sometimes', 'nullable', 'string', 'max:40'],
'surface' => ['sometimes', 'nullable', 'string', 'max:120'],
'item_module' => ['sometimes', 'nullable', 'string', 'max:40'],
'item_id' => ['sometimes', 'nullable', 'integer'],
'meta' => ['sometimes', 'array'],
]);
$this->events->record($request->user(), $payload);
return response()->json([
'ok' => true,
], 202);
}
}

View File

@@ -7,6 +7,7 @@ namespace App\Http\Controllers\Studio;
use App\Http\Controllers\Controller;
use App\Models\NovaCard;
use App\Services\NovaCards\NovaCardPresenter;
use App\Services\Studio\CreatorStudioContentService;
use Illuminate\Http\Request;
use Illuminate\Http\RedirectResponse;
use Inertia\Inertia;
@@ -16,36 +17,22 @@ class StudioNovaCardsController extends Controller
{
public function __construct(
private readonly NovaCardPresenter $presenter,
private readonly CreatorStudioContentService $content,
) {
}
public function index(Request $request): Response
{
$cards = NovaCard::query()
->with(['category', 'template', 'backgroundImage', 'tags', 'user.profile'])
->where('user_id', $request->user()->id)
->latest('updated_at')
->paginate(18)
->withQueryString();
$baseQuery = NovaCard::query()->where('user_id', $request->user()->id);
$provider = $this->content->provider('cards');
$listing = $this->content->list($request->user(), $request->only(['q', 'sort', 'bucket', 'page', 'per_page']), null, 'cards');
return Inertia::render('Studio/StudioCardsIndex', [
'cards' => $this->presenter->paginator($cards, false, $request->user()),
'stats' => [
'all' => (clone $baseQuery)->count(),
'drafts' => (clone $baseQuery)->where('status', NovaCard::STATUS_DRAFT)->count(),
'processing' => (clone $baseQuery)->where('status', NovaCard::STATUS_PROCESSING)->count(),
'published' => (clone $baseQuery)->where('status', NovaCard::STATUS_PUBLISHED)->count(),
],
'editorOptions' => $this->presenter->options(),
'endpoints' => [
'create' => route('studio.cards.create'),
'editPattern' => route('studio.cards.edit', ['id' => '__CARD__']),
'previewPattern' => route('studio.cards.preview', ['id' => '__CARD__']),
'analyticsPattern' => route('studio.cards.analytics', ['id' => '__CARD__']),
'draftStore' => route('api.cards.drafts.store'),
],
'title' => 'Cards',
'description' => 'Manage short-form Nova cards with the same shared filters, statuses, and actions used across Creator Studio.',
'summary' => $provider?->summary($request->user()),
'listing' => $listing,
'quickCreate' => $this->content->quickCreate(),
'publicBrowseUrl' => '/cards',
]);
}

View File

@@ -0,0 +1,114 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Studio;
use App\Http\Controllers\Controller;
use App\Models\UserProfile;
use App\Services\NotificationService;
use App\Services\Studio\CreatorStudioPreferenceService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\Rule;
final class StudioPreferencesApiController extends Controller
{
public function __construct(
private readonly CreatorStudioPreferenceService $preferences,
private readonly NotificationService $notifications,
) {
}
public function updatePreferences(Request $request): JsonResponse
{
$payload = $request->validate([
'default_content_view' => ['required', Rule::in(['grid', 'list'])],
'analytics_range_days' => ['required', Rule::in([7, 14, 30, 60, 90])],
'dashboard_shortcuts' => ['required', 'array', 'max:8'],
'dashboard_shortcuts.*' => ['string'],
'draft_behavior' => ['required', Rule::in(['resume-last', 'open-drafts', 'focus-published'])],
'featured_modules' => ['nullable', 'array'],
'featured_modules.*' => [Rule::in(['artworks', 'cards', 'collections', 'stories'])],
'default_landing_page' => ['nullable', Rule::in(['overview', 'content', 'drafts', 'scheduled', 'analytics', 'activity', 'calendar', 'inbox', 'search', 'growth', 'challenges', 'preferences'])],
'widget_visibility' => ['nullable', 'array'],
'widget_order' => ['nullable', 'array'],
'widget_order.*' => ['string'],
'card_density' => ['nullable', Rule::in(['compact', 'comfortable'])],
'scheduling_timezone' => ['nullable', 'string', 'max:64'],
]);
return response()->json([
'data' => $this->preferences->update($request->user(), $payload),
]);
}
public function updateProfile(Request $request): JsonResponse
{
$payload = $request->validate([
'display_name' => ['required', 'string', 'max:60'],
'tagline' => ['nullable', 'string', 'max:1000'],
'bio' => ['nullable', 'string', 'max:1000'],
'website' => ['nullable', 'url', 'max:255'],
'social_links' => ['nullable', 'array', 'max:8'],
'social_links.*.platform' => ['required_with:social_links', 'string', 'max:32'],
'social_links.*.url' => ['required_with:social_links', 'url', 'max:255'],
]);
$user = $request->user();
$user->forceFill(['name' => (string) $payload['display_name']])->save();
UserProfile::query()->updateOrCreate(
['user_id' => $user->id],
[
'about' => $payload['bio'] ?? null,
'description' => $payload['tagline'] ?? null,
'website' => $payload['website'] ?? null,
]
);
DB::table('user_social_links')->where('user_id', $user->id)->delete();
foreach ($payload['social_links'] ?? [] as $link) {
DB::table('user_social_links')->insert([
'user_id' => $user->id,
'platform' => strtolower(trim((string) $link['platform'])),
'url' => trim((string) $link['url']),
'created_at' => now(),
'updated_at' => now(),
]);
}
return response()->json([
'ok' => true,
]);
}
public function updateFeatured(Request $request): JsonResponse
{
$payload = $request->validate([
'featured_modules' => ['nullable', 'array'],
'featured_modules.*' => [Rule::in(['artworks', 'cards', 'collections', 'stories'])],
'featured_content' => ['nullable', 'array'],
'featured_content.artworks' => ['nullable', 'integer', 'min:1'],
'featured_content.cards' => ['nullable', 'integer', 'min:1'],
'featured_content.collections' => ['nullable', 'integer', 'min:1'],
'featured_content.stories' => ['nullable', 'integer', 'min:1'],
]);
return response()->json([
'data' => $this->preferences->update($request->user(), $payload),
]);
}
public function markActivityRead(Request $request): JsonResponse
{
$this->notifications->markAllRead($request->user());
return response()->json([
'data' => $this->preferences->update($request->user(), [
'activity_last_read_at' => now()->toIso8601String(),
]),
]);
}
}

View File

@@ -0,0 +1,150 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Studio;
use App\Http\Controllers\Controller;
use App\Models\Artwork;
use App\Models\Collection;
use App\Models\NovaCard;
use App\Models\Story;
use App\Services\CollectionLifecycleService;
use App\Services\NovaCards\NovaCardPublishService;
use App\Services\StoryPublicationService;
use App\Services\Studio\CreatorStudioContentService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
final class StudioScheduleApiController extends Controller
{
public function __construct(
private readonly CreatorStudioContentService $content,
private readonly NovaCardPublishService $cards,
private readonly CollectionLifecycleService $collections,
private readonly StoryPublicationService $stories,
) {
}
public function publishNow(Request $request, string $module, int $id): JsonResponse
{
$user = $request->user();
match ($module) {
'artworks' => $this->publishArtworkNow($user->id, $id),
'cards' => $this->cards->publishNow($this->card($user->id, $id)),
'collections' => $this->publishCollectionNow($user->id, $id),
'stories' => $this->stories->publish($this->story($user->id, $id), 'published', ['published_at' => now()]),
default => abort(404),
};
return response()->json([
'ok' => true,
'item' => $this->serializedItem($request->user(), $module, $id),
]);
}
public function unschedule(Request $request, string $module, int $id): JsonResponse
{
$user = $request->user();
match ($module) {
'artworks' => $this->unscheduleArtwork($user->id, $id),
'cards' => $this->cards->clearSchedule($this->card($user->id, $id)),
'collections' => $this->unscheduleCollection($user->id, $id),
'stories' => $this->unscheduleStory($user->id, $id),
default => abort(404),
};
return response()->json([
'ok' => true,
'item' => $this->serializedItem($request->user(), $module, $id),
]);
}
private function publishArtworkNow(int $userId, int $id): void
{
$artwork = Artwork::query()
->where('user_id', $userId)
->findOrFail($id);
$artwork->forceFill([
'artwork_status' => 'published',
'publish_at' => null,
'artwork_timezone' => null,
'published_at' => now(),
'is_public' => $artwork->visibility !== Artwork::VISIBILITY_PRIVATE,
])->save();
}
private function unscheduleArtwork(int $userId, int $id): void
{
Artwork::query()
->where('user_id', $userId)
->findOrFail($id)
->forceFill([
'artwork_status' => 'draft',
'publish_at' => null,
'artwork_timezone' => null,
'published_at' => null,
])
->save();
}
private function publishCollectionNow(int $userId, int $id): void
{
$collection = Collection::query()
->where('user_id', $userId)
->findOrFail($id);
$this->collections->applyAttributes($collection, [
'published_at' => now(),
'lifecycle_state' => Collection::LIFECYCLE_PUBLISHED,
]);
}
private function unscheduleCollection(int $userId, int $id): void
{
$collection = Collection::query()
->where('user_id', $userId)
->findOrFail($id);
$this->collections->applyAttributes($collection, [
'published_at' => null,
'lifecycle_state' => Collection::LIFECYCLE_DRAFT,
]);
}
private function unscheduleStory(int $userId, int $id): void
{
Story::query()
->where('creator_id', $userId)
->findOrFail($id)
->forceFill([
'status' => 'draft',
'scheduled_for' => null,
'published_at' => null,
])
->save();
}
private function card(int $userId, int $id): NovaCard
{
return NovaCard::query()
->where('user_id', $userId)
->findOrFail($id);
}
private function story(int $userId, int $id): Story
{
return Story::query()
->where('creator_id', $userId)
->findOrFail($id);
}
private function serializedItem($user, string $module, int $id): ?array
{
return $this->content->provider($module)?->items($user, 'all', 400)
->first(fn (array $item): bool => (int) ($item['numeric_id'] ?? 0) === $id);
}
}

View File

@@ -20,6 +20,7 @@ use App\Services\CollectionSaveService;
use App\Services\CollectionSeriesService;
use App\Services\CollectionSubmissionService;
use App\Services\CollectionService;
use App\Support\Seo\SeoFactory;
use App\Support\UsernamePolicy;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
@@ -113,6 +114,16 @@ class ProfileCollectionController extends Controller
event(new CollectionViewed($collection, $viewer?->id));
$seo = app(SeoFactory::class)->collectionPage(
$collection->is_featured
? sprintf('Featured: %s by %s — Skinbase Nova', $collection->title, $collection->displayOwnerName())
: sprintf('%s by %s — Skinbase Nova', $collection->title, $collection->displayOwnerName()),
$collection->summary ?: $collection->description ?: sprintf('Explore the %s collection by %s on Skinbase Nova.', $collection->title, $collection->displayOwnerName()),
$collectionPayload['public_url'],
$collectionPayload['cover_image'],
$collection->visibility === Collection::VISIBILITY_PUBLIC,
)->toArray();
return Inertia::render('Collection/CollectionShow', [
'collection' => $collectionPayload,
'artworks' => $this->collections->mapArtworkPaginator($artworks),
@@ -168,15 +179,7 @@ class ProfileCollectionController extends Controller
]),
'featuredCollectionsUrl' => route('collections.featured'),
'reportEndpoint' => $viewer ? route('api.reports.store') : null,
'seo' => [
'title' => $collection->is_featured
? sprintf('Featured: %s by %s — Skinbase Nova', $collection->title, $collection->displayOwnerName())
: sprintf('%s by %s — Skinbase Nova', $collection->title, $collection->displayOwnerName()),
'description' => $collection->summary ?: $collection->description ?: sprintf('Explore the %s collection by %s on Skinbase Nova.', $collection->title, $collection->displayOwnerName()),
'canonical' => $collectionPayload['public_url'],
'og_image' => $collectionPayload['cover_image'],
'robots' => $collection->visibility === Collection::VISIBILITY_PUBLIC ? 'index,follow' : 'noindex,nofollow',
],
'seo' => $seo,
])->rootView('collections');
}
@@ -198,6 +201,12 @@ class ProfileCollectionController extends Controller
->first();
$seriesDescription = $seriesMeta['description'];
$seo = app(SeoFactory::class)->collectionListing(
sprintf('Series: %s — Skinbase Nova', $seriesKey),
sprintf('Explore the %s collection series on Skinbase Nova.', $seriesKey),
route('collections.series.show', ['seriesKey' => $seriesKey])
)->toArray();
return Inertia::render('Collection/CollectionSeriesShow', [
'seriesKey' => $seriesKey,
'title' => $seriesTitle ?: sprintf('Collection Series: %s', str_replace(['-', '_'], ' ', $seriesKey)),
@@ -210,12 +219,7 @@ class ProfileCollectionController extends Controller
'artworks' => $artworksCount,
'latest_activity_at' => optional($latestActivityAt)?->toISOString(),
],
'seo' => [
'title' => sprintf('Series: %s — Skinbase Nova', $seriesKey),
'description' => sprintf('Explore the %s collection series on Skinbase Nova.', $seriesKey),
'canonical' => route('collections.series.show', ['seriesKey' => $seriesKey]),
'robots' => 'index,follow',
],
'seo' => $seo,
])->rootView('collections');
}
}

View File

@@ -36,6 +36,7 @@ use App\Services\UsernameApprovalService;
use App\Services\UserStatsService;
use App\Support\AvatarUrl;
use App\Support\CoverUrl;
use App\Support\Seo\SeoFactory;
use App\Support\UsernamePolicy;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
@@ -1204,6 +1205,27 @@ class ProfileController extends Controller
? ucfirst($resolvedInitialTab)
: null;
$pageTitle = $galleryOnly
? (($user->username ?? $user->name ?? 'User') . ' Gallery on Skinbase')
: ($isTabLanding
? (($user->username ?? $user->name ?? 'User') . ' ' . $tabMetaLabel . ' on Skinbase')
: (($user->username ?? $user->name ?? 'User') . ' on Skinbase'));
$pageDescription = $galleryOnly
? ('Browse the public gallery of ' . ($user->username ?? $user->name) . ' on Skinbase.')
: ($isTabLanding
? ('Explore the ' . strtolower((string) $tabMetaLabel) . ' section for ' . ($user->username ?? $user->name) . ' on Skinbase.')
: ('View the profile of ' . ($user->username ?? $user->name) . ' on Skinbase — artworks, favourites and more.'));
$profileSeo = app(SeoFactory::class)->profilePage(
$pageTitle,
$galleryOnly ? $galleryUrl : $activeProfileUrl,
$pageDescription,
$avatarUrl,
collect([
(object) ['name' => 'Home', 'url' => '/'],
(object) ['name' => $user->username ?? $user->name ?? 'Profile', 'url' => $galleryOnly ? $galleryUrl : $activeProfileUrl],
]),
)->toArray();
return Inertia::render($component, [
'user' => [
'id' => $user->id,
@@ -1259,18 +1281,12 @@ class ProfileController extends Controller
'collectionFeatureLimit' => (int) config('collections.featured_limit', 3),
'profileTabUrls' => $profileTabUrls,
])->withViewData([
'page_title' => $galleryOnly
? (($user->username ?? $user->name ?? 'User') . ' Gallery on Skinbase')
: ($isTabLanding
? (($user->username ?? $user->name ?? 'User') . ' ' . $tabMetaLabel . ' on Skinbase')
: (($user->username ?? $user->name ?? 'User') . ' on Skinbase')),
'page_title' => $pageTitle,
'page_canonical' => $galleryOnly ? $galleryUrl : $activeProfileUrl,
'page_meta_description' => $galleryOnly
? ('Browse the public gallery of ' . ($user->username ?? $user->name) . ' on Skinbase.')
: ($isTabLanding
? ('Explore the ' . strtolower((string) $tabMetaLabel) . ' section for ' . ($user->username ?? $user->name) . ' on Skinbase.')
: ('View the profile of ' . ($user->username ?? $user->name) . ' on Skinbase.org — artworks, favourites and more.')),
'page_meta_description' => $pageDescription,
'og_image' => $avatarUrl,
'seo' => $profileSeo,
'useUnifiedSeo' => true,
]);
}

View File

@@ -11,6 +11,7 @@ use App\Models\ArtworkComment;
use App\Services\ContentSanitizer;
use App\Services\ThumbnailPresenter;
use App\Services\ErrorSuggestionService;
use App\Support\Seo\SeoFactory;
use App\Support\AvatarUrl;
use Illuminate\Support\Carbon;
use Illuminate\Http\RedirectResponse;
@@ -113,7 +114,7 @@ final class ArtworkPageController extends Controller
$description = Str::limit(trim(strip_tags(html_entity_decode((string) ($artwork->description ?? ''), ENT_QUOTES | ENT_HTML5, 'UTF-8'))), 160, '…');
$meta = [
'title' => sprintf('%s by %s | Skinbase', html_entity_decode((string) $artwork->title, ENT_QUOTES | ENT_HTML5, 'UTF-8'), html_entity_decode((string) $authorName, ENT_QUOTES | ENT_HTML5, 'UTF-8')),
'title' => sprintf('%s by %s Skinbase', html_entity_decode((string) $artwork->title, ENT_QUOTES | ENT_HTML5, 'UTF-8'), html_entity_decode((string) $authorName, ENT_QUOTES | ENT_HTML5, 'UTF-8')),
'description' => $description !== '' ? $description : html_entity_decode((string) $artwork->title, ENT_QUOTES | ENT_HTML5, 'UTF-8'),
'canonical' => $canonical,
'og_image' => $thumbXl['url'] ?? $thumbLg['url'] ?? null,
@@ -121,6 +122,12 @@ final class ArtworkPageController extends Controller
'og_height' => $thumbXl['height'] ?? $thumbLg['height'] ?? null,
];
$seo = app(SeoFactory::class)->artwork($artwork, [
'md' => $thumbMd,
'lg' => $thumbLg,
'xl' => $thumbXl,
], $canonical)->toArray();
$categoryIds = $artwork->categories->pluck('id')->filter()->values();
$tagIds = $artwork->tags->pluck('id')->filter()->values();
@@ -226,6 +233,8 @@ final class ArtworkPageController extends Controller
'presentXl' => $thumbXl,
'presentSq' => $thumbSq,
'meta' => $meta,
'seo' => $seo,
'useUnifiedSeo' => true,
'relatedItems' => $related,
'comments' => $comments,
]);

View File

@@ -82,14 +82,26 @@ class CategoryController extends Controller
$subcategories = $category->children()->orderBy('sort_order')->orderBy('name')->get();
$page_title = $category->name;
$breadcrumbs = collect(array_merge([
(object) ['name' => 'Home', 'url' => '/'],
(object) ['name' => 'Explore', 'url' => '/browse'],
(object) ['name' => $category->contentType->name, 'url' => '/' . strtolower((string) $category->contentType->slug)],
], collect($category->breadcrumbs)->map(fn ($crumb) => (object) [
'name' => $crumb->name,
'url' => $crumb->url,
])->all()));
$page_title = sprintf('%s — %s — Skinbase', $category->name, $category->contentType->name);
$page_meta_description = $category->description ?? ($category->contentType->name . ' artworks on Skinbase');
$page_meta_keywords = strtolower($category->contentType->slug) . ', skinbase, artworks, wallpapers, skins, photography';
$page_canonical = url()->current();
return view('web.category', compact(
'page_title',
'page_meta_description',
'page_meta_keywords',
'page_canonical',
'breadcrumbs',
'group',
'category',
'subcategories',

View File

@@ -13,6 +13,7 @@ use App\Services\CollectionRecommendationService;
use App\Services\CollectionSearchService;
use App\Services\CollectionService;
use App\Services\CollectionSurfaceService;
use App\Support\Seo\SeoFactory;
use Illuminate\Http\Request;
use Inertia\Inertia;
@@ -50,18 +51,23 @@ class CollectionDiscoveryController extends Controller
$results = $this->search->publicSearch($filters, (int) config('collections.v5.search.public_per_page', 18));
$seo = app(SeoFactory::class)->collectionListing(
'Search Collections — Skinbase Nova',
filled($filters['q'] ?? null)
? sprintf('Search results for "%s" across public Skinbase Nova collections.', $filters['q'])
: 'Browse public collections using filters for category, style, theme, color, quality tier, freshness, and programming metadata.',
$request->fullUrl(),
null,
false,
)->toArray();
return Inertia::render('Collection/CollectionFeaturedIndex', [
'eyebrow' => 'Search',
'title' => 'Search collections',
'description' => filled($filters['q'] ?? null)
? sprintf('Search results for "%s" across public Skinbase Nova collections.', $filters['q'])
: 'Browse public collections using filters for category, style, theme, color, quality tier, freshness, and programming metadata.',
'seo' => [
'title' => 'Search Collections — Skinbase Nova',
'description' => 'Search public collections by category, theme, quality tier, and curator context.',
'canonical' => route('collections.search'),
'robots' => 'index,follow',
],
'seo' => $seo,
'collections' => $this->collections->mapCollectionCardPayloads($results->items(), false, $request->user()),
'communityCollections' => [],
'editorialCollections' => [],
@@ -197,16 +203,17 @@ class CollectionDiscoveryController extends Controller
abort_if(! $program || collect($landing['collections'])->isEmpty(), 404);
$seo = app(SeoFactory::class)->collectionListing(
sprintf('%s — Skinbase Nova', $program['label']),
$program['description'],
route('collections.program.show', ['programKey' => $program['key']]),
)->toArray();
return Inertia::render('Collection/CollectionFeaturedIndex', [
'eyebrow' => 'Program',
'title' => $program['label'],
'description' => $program['description'],
'seo' => [
'title' => sprintf('%s — Skinbase Nova', $program['label']),
'description' => $program['description'],
'canonical' => route('collections.program.show', ['programKey' => $program['key']]),
'robots' => 'index,follow',
],
'seo' => $seo,
'collections' => $this->collections->mapCollectionCardPayloads($landing['collections'], false, $request->user()),
'communityCollections' => $this->collections->mapCollectionCardPayloads($landing['community_collections'] ?? collect(), false, $request->user()),
'editorialCollections' => $this->collections->mapCollectionCardPayloads($landing['editorial_collections'] ?? collect(), false, $request->user()),
@@ -231,16 +238,17 @@ class CollectionDiscoveryController extends Controller
$seasonalCollections = null,
$campaign = null,
) {
$seo = app(SeoFactory::class)->collectionListing(
sprintf('%s — Skinbase Nova', $title),
$description,
url()->current(),
)->toArray();
return Inertia::render('Collection/CollectionFeaturedIndex', [
'eyebrow' => $eyebrow,
'title' => $title,
'description' => $description,
'seo' => [
'title' => sprintf('%s — Skinbase Nova', $title),
'description' => $description,
'canonical' => url()->current(),
'robots' => 'index,follow',
],
'seo' => $seo,
'collections' => $this->collections->mapCollectionCardPayloads($collections, false, $viewer),
'communityCollections' => $this->collections->mapCollectionCardPayloads($communityCollections ?? collect(), false, $viewer),
'editorialCollections' => $this->collections->mapCollectionCardPayloads($editorialCollections ?? collect(), false, $viewer),

View File

@@ -23,6 +23,36 @@ final class FooterController extends Controller
'page_title' => 'FAQ — Skinbase',
'page_meta_description' => 'Frequently Asked Questions about Skinbase — the community for skins, wallpapers, and photography.',
'page_canonical' => url('/faq'),
'faq_schema' => [[
'@context' => 'https://schema.org',
'@type' => 'FAQPage',
'mainEntity' => [
[
'@type' => 'Question',
'name' => 'What is Skinbase?',
'acceptedAnswer' => [
'@type' => 'Answer',
'text' => 'Skinbase is a community gallery for desktop customisation including skins, themes, wallpapers, icons, and more.',
],
],
[
'@type' => 'Question',
'name' => 'Is Skinbase free to use?',
'acceptedAnswer' => [
'@type' => 'Answer',
'text' => 'Yes. Browsing and downloading are free, and registering is also free.',
],
],
[
'@type' => 'Question',
'name' => 'Who runs Skinbase?',
'acceptedAnswer' => [
'@type' => 'Answer',
'text' => 'Skinbase is maintained by a small volunteer staff team.',
],
],
],
]],
'hero_title' => 'Frequently Asked Questions',
'hero_description' => 'Answers to the most common questions from our members. Last updated March 1, 2026.',
'breadcrumbs' => collect([

View File

@@ -6,6 +6,7 @@ namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use App\Services\HomepageService;
use App\Support\Seo\SeoFactory;
use Illuminate\Http\Request;
final class HomeController extends Controller
@@ -30,6 +31,8 @@ final class HomeController extends Controller
];
return view('web.home', [
'seo' => app(SeoFactory::class)->homepage($meta)->toArray(),
'useUnifiedSeo' => true,
'meta' => $meta,
'props' => $sections,
]);

View File

@@ -7,6 +7,7 @@ namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use App\Models\Leaderboard;
use App\Services\LeaderboardService;
use App\Support\Seo\SeoFactory;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
@@ -26,10 +27,11 @@ class LeaderboardPageController extends Controller
'initialType' => $type,
'initialPeriod' => $period,
'initialData' => $leaderboards->getLeaderboard($type, $period),
'meta' => [
'title' => 'Top Creators & Artworks Leaderboard | Skinbase',
'description' => 'Track the leading creators, artworks, and stories across Skinbase by daily, weekly, monthly, and all-time performance.',
],
'seo' => app(SeoFactory::class)->leaderboardPage(
'Top Creators & Artworks Leaderboard Skinbase',
'Track the leading creators, artworks, and stories across Skinbase by daily, weekly, monthly, and all-time performance.',
route('leaderboard')
)->toArray(),
]);
}
}

View File

@@ -0,0 +1,317 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use App\Models\Artwork;
use App\Services\Recommendations\HybridSimilarArtworksService;
use App\Services\ThumbnailPresenter;
use App\Services\Vision\VectorService;
use App\Support\AvatarUrl;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
use RuntimeException;
/**
* GET /art/{id}/similar
*
* Renders a full gallery page of artworks similar to the given source artwork.
*
* Priority:
* 1. Qdrant visual similarity (VectorService / vision gateway)
* 2. HybridSimilarArtworksService (precomputed tag, behavior, hybrid)
* 3. Meilisearch tag-overlap + category fallback
*/
final class SimilarArtworksPageController extends Controller
{
private const PER_PAGE = 24;
/** How many candidates to request from Qdrant (> PER_PAGE to allow pagination) */
private const QDRANT_LIMIT = 120;
public function __construct(
private readonly VectorService $vectors,
private readonly HybridSimilarArtworksService $hybridService,
) {}
public function __invoke(Request $request, int $id)
{
// ── Load source artwork ────────────────────────────────────────────────
$source = Artwork::public()
->published()
->with([
'tags:id,slug',
'categories:id,slug,name',
'categories.contentType:id,name,slug',
'user:id,name,username',
'user.profile:user_id,avatar_hash',
])
->findOrFail($id);
$baseUrl = url("/art/{$id}/similar");
// ── Normalise source artwork for the view ──────────────────────────────
$primaryCat = $source->categories->sortBy('sort_order')->first();
$sourceMd = ThumbnailPresenter::present($source, 'md');
$sourceLg = ThumbnailPresenter::present($source, 'lg');
$sourceTitle = html_entity_decode((string) ($source->title ?? 'Artwork'), ENT_QUOTES | ENT_HTML5, 'UTF-8');
$sourceUrl = route('art.show', ['id' => $source->id, 'slug' => $source->slug]);
$sourceCard = (object) [
'id' => $source->id,
'title' => $sourceTitle,
'url' => $sourceUrl,
'thumb_md' => $sourceMd['url'] ?? null,
'thumb_lg' => $sourceLg['url'] ?? null,
'thumb_srcset' => $sourceMd['srcset'] ?? $sourceMd['url'] ?? null,
'author_name' => $source->user?->name ?? 'Artist',
'author_username' => $source->user?->username ?? '',
'author_avatar' => AvatarUrl::forUser(
(int) ($source->user_id ?? 0),
$source->user?->profile?->avatar_hash ?? null,
80
),
'category_name' => $primaryCat?->name ?? '',
'category_slug' => $primaryCat?->slug ?? '',
'content_type_name' => $primaryCat?->contentType?->name ?? '',
'content_type_slug' => $primaryCat?->contentType?->slug ?? '',
'tag_slugs' => $source->tags->pluck('slug')->take(5)->all(),
'width' => $source->width ?? null,
'height' => $source->height ?? null,
];
return view('gallery.similar', [
'sourceArtwork' => $sourceCard,
'gallery_type' => 'similar',
'gallery_nav_section' => 'artworks',
'mainCategories' => collect(),
'subcategories' => collect(),
'contentType' => null,
'category' => null,
'spotlight' => collect(),
'current_sort' => 'trending',
'sort_options' => [],
'page_title' => 'Similar to "' . $sourceTitle . '" — Skinbase',
'page_meta_description' => 'Discover artworks similar to "' . $sourceTitle . '" on Skinbase.',
'page_canonical' => $baseUrl,
'page_robots' => 'noindex,follow',
'breadcrumbs' => collect([
(object) ['name' => 'Explore', 'url' => '/explore'],
(object) ['name' => $sourceTitle, 'url' => $sourceUrl],
(object) ['name' => 'Similar Artworks', 'url' => $baseUrl],
]),
]);
}
/**
* GET /art/{id}/similar-results (JSON)
*
* Returns paginated similar artworks asynchronously so the page shell
* can render instantly while this slower query runs in the background.
*/
public function results(Request $request, int $id): JsonResponse
{
$source = Artwork::public()
->published()
->with([
'tags:id,slug',
'categories:id,slug,name',
'categories.contentType:id,name,slug',
'user:id,name,username',
'user.profile:user_id,avatar_hash',
])
->findOrFail($id);
$page = max(1, (int) $request->query('page', 1));
$baseUrl = url("/art/{$id}/similar");
[$artworks, $similaritySource] = $this->resolveSimilarArtworks($source, $page, $baseUrl);
$galleryItems = $artworks->getCollection()->map(fn ($art) => [
'id' => $art->id ?? null,
'name' => $art->name ?? null,
'thumb' => $art->thumb_url ?? $art->thumb ?? null,
'thumb_srcset' => $art->thumb_srcset ?? null,
'uname' => $art->uname ?? '',
'username' => $art->username ?? $art->uname ?? '',
'avatar_url' => $art->avatar_url ?? null,
'category_name' => $art->category_name ?? '',
'category_slug' => $art->category_slug ?? '',
'slug' => $art->slug ?? '',
'width' => $art->width ?? null,
'height' => $art->height ?? null,
])->values();
return response()->json([
'data' => $galleryItems,
'similarity_source' => $similaritySource,
'total' => $artworks->total(),
'current_page' => $artworks->currentPage(),
'last_page' => $artworks->lastPage(),
'next_page_url' => $artworks->nextPageUrl(),
'prev_page_url' => $artworks->previousPageUrl(),
]);
}
// ── Similarity resolution ──────────────────────────────────────────────────
/**
* @return array{0: LengthAwarePaginator, 1: string}
*/
private function resolveSimilarArtworks(Artwork $source, int $page, string $baseUrl): array
{
// Priority 1 — Qdrant visual (vision) similarity
if ($this->vectors->isConfigured()) {
$qdrantItems = $this->resolveViaQdrant($source);
if ($qdrantItems !== null && $qdrantItems->isNotEmpty()) {
$paginator = $this->paginateCollection(
$qdrantItems->map(fn ($a) => $this->presentArtwork($a)),
$page,
$baseUrl,
);
return [$paginator, 'visual'];
}
}
// Priority 2 — precomputed hybrid list (tag / behavior / AI)
$hybridItems = $this->hybridService->forArtwork($source->id, self::QDRANT_LIMIT);
if ($hybridItems->isNotEmpty()) {
$paginator = $this->paginateCollection(
$hybridItems->map(fn ($a) => $this->presentArtwork($a)),
$page,
$baseUrl,
);
return [$paginator, 'hybrid'];
}
// Priority 3 — Meilisearch tag/category overlap
$paginator = $this->meilisearchFallback($source, $page);
return [$paginator, 'tags'];
}
/**
* Query Qdrant via VectorGateway, then re-hydrate full Artwork models
* (so we have category/dimension data for the masonry grid).
*
* Returns null when the gateway call fails, so the caller can fall through.
*/
private function resolveViaQdrant(Artwork $source): ?Collection
{
try {
$raw = $this->vectors->similarToArtwork($source, self::QDRANT_LIMIT);
} catch (RuntimeException) {
return null;
}
if (empty($raw)) {
return null;
}
// Preserve Qdrant relevance order; IDs are already filtered to public+published
$orderedIds = array_column($raw, 'id');
$artworks = Artwork::query()
->whereIn('id', $orderedIds)
->where('id', '!=', $source->id) // belt-and-braces exclusion
->public()
->published()
->with([
'categories:id,slug,name',
'categories.contentType:id,name,slug',
'user:id,name,username',
'user.profile:user_id,avatar_hash',
])
->get()
->keyBy('id');
return collect($orderedIds)
->map(fn (int $id) => $artworks->get($id))
->filter()
->values();
}
/**
* Meilisearch tag-overlap query with category fallback.
*/
private function meilisearchFallback(Artwork $source, int $page): LengthAwarePaginator
{
$tagSlugs = $source->tags->pluck('slug')->values()->all();
$categorySlugs = $source->categories->pluck('slug')->values()->all();
$filterParts = [
'is_public = true',
'is_approved = true',
'id != ' . $source->id,
];
if ($tagSlugs !== []) {
$quoted = array_map(fn (string $t): string => 'tags = "' . addslashes($t) . '"', $tagSlugs);
$filterParts[] = '(' . implode(' OR ', $quoted) . ')';
} elseif ($categorySlugs !== []) {
$quoted = array_map(fn (string $c): string => 'category = "' . addslashes($c) . '"', $categorySlugs);
$filterParts[] = '(' . implode(' OR ', $quoted) . ')';
}
$results = Artwork::search('')->options([
'filter' => implode(' AND ', $filterParts),
'sort' => ['trending_score_7d:desc', 'created_at:desc'],
])->paginate(self::PER_PAGE, 'page', $page);
$results->getCollection()->transform(fn ($a) => $this->presentArtwork($a));
return $results;
}
/**
* Wrap a Collection into a LengthAwarePaginator for the view.
*/
private function paginateCollection(
Collection $items,
int $page,
string $path,
): LengthAwarePaginator {
$perPage = self::PER_PAGE;
$total = $items->count();
$slice = $items->forPage($page, $perPage)->values();
return new LengthAwarePaginator($slice, $total, $perPage, $page, [
'path' => $path,
'query' => [],
]);
}
// ── Presenter ─────────────────────────────────────────────────────────────
private function presentArtwork(Artwork $artwork): object
{
$primary = $artwork->categories->sortBy('sort_order')->first();
$present = ThumbnailPresenter::present($artwork, 'md');
$avatarUrl = AvatarUrl::forUser(
(int) ($artwork->user_id ?? 0),
$artwork->user?->profile?->avatar_hash ?? null,
64
);
return (object) [
'id' => $artwork->id,
'name' => $artwork->title,
'content_type_name' => $primary?->contentType?->name ?? '',
'content_type_slug' => $primary?->contentType?->slug ?? '',
'category_name' => $primary?->name ?? '',
'category_slug' => $primary?->slug ?? '',
'thumb_url' => $present['url'],
'thumb_srcset' => $present['srcset'] ?? $present['url'],
'uname' => $artwork->user?->name ?? 'Skinbase',
'username' => $artwork->user?->username ?? '',
'avatar_url' => $avatarUrl,
'published_at' => $artwork->published_at,
'slug' => $artwork->slug ?? '',
'width' => $artwork->width ?? null,
'height' => $artwork->height ?? null,
];
}
}

View File

@@ -34,6 +34,9 @@ class SaveNovaCardDraftRequest extends FormRequest
'allow_download' => ['sometimes', 'boolean'],
'allow_remix' => ['sometimes', 'boolean'],
'editor_mode_last_used' => ['sometimes', Rule::in(['quick', 'full'])],
'publish_mode' => ['sometimes', Rule::in(['now', 'schedule'])],
'scheduled_for' => ['sometimes', 'nullable', 'date'],
'scheduling_timezone' => ['sometimes', 'nullable', 'string', 'max:64'],
'tags' => ['sometimes', 'array', 'max:' . (int) ($validation['max_tags'] ?? 8)],
'tags.*' => ['string', 'min:2', 'max:32'],
'project_json' => ['sometimes', 'array'],
@@ -43,6 +46,9 @@ class SaveNovaCardDraftRequest extends FormRequest
'project_json.text_blocks.*.type' => ['sometimes', Rule::in(['title', 'quote', 'author', 'source', 'body', 'caption'])],
'project_json.text_blocks.*.text' => ['sometimes', 'nullable', 'string', 'max:' . (int) ($validation['quote_max'] ?? 420)],
'project_json.text_blocks.*.enabled' => ['sometimes', 'boolean'],
'project_json.text_blocks.*.pos_x' => ['sometimes', 'nullable', 'numeric', 'min:0', 'max:100'],
'project_json.text_blocks.*.pos_y' => ['sometimes', 'nullable', 'numeric', 'min:0', 'max:100'],
'project_json.text_blocks.*.pos_width' => ['sometimes', 'nullable', 'numeric', 'min:1', 'max:100'],
'project_json.assets.pack_ids' => ['sometimes', 'array'],
'project_json.assets.pack_ids.*' => ['integer'],
'project_json.assets.template_pack_ids' => ['sometimes', 'array'],
@@ -57,7 +63,13 @@ class SaveNovaCardDraftRequest extends FormRequest
'project_json.layout.padding' => ['sometimes', Rule::in((array) ($validation['allowed_padding_presets'] ?? []))],
'project_json.layout.max_width' => ['sometimes', Rule::in((array) ($validation['allowed_max_widths'] ?? []))],
'project_json.typography.font_preset' => ['sometimes', Rule::in(array_keys((array) config('nova_cards.font_presets', [])))],
'project_json.typography.quote_size' => ['sometimes', 'integer', 'min:24', 'max:160'],
'project_json.typography.quote_size' => ['sometimes', 'integer', 'min:10', 'max:160'],
'project_json.typography.quote_width' => ['sometimes', 'nullable', 'integer', 'min:30', 'max:100'],
'project_json.typography.text_opacity' => ['sometimes', 'nullable', 'integer', 'min:10', 'max:100'],
'project_json.decorations' => ['sometimes', 'array'],
'project_json.decorations.*.pos_x' => ['sometimes', 'nullable', 'numeric', 'min:0', 'max:100'],
'project_json.decorations.*.pos_y' => ['sometimes', 'nullable', 'numeric', 'min:0', 'max:100'],
'project_json.decorations.*.opacity' => ['sometimes', 'nullable', 'integer', 'min:10', 'max:100'],
'project_json.typography.author_size' => ['sometimes', 'integer', 'min:12', 'max:72'],
'project_json.typography.letter_spacing' => ['sometimes', 'integer', 'min:-2', 'max:12'],
'project_json.typography.line_height' => ['sometimes', 'numeric', 'min:0.9', 'max:1.8'],

View File

@@ -7,7 +7,6 @@ namespace App\Http\Requests\NovaCards;
use Closure;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Http\UploadedFile;
use Illuminate\Validation\Rule;
class UploadNovaCardBackgroundRequest extends FormRequest
{
@@ -26,22 +25,56 @@ class UploadNovaCardBackgroundRequest extends FormRequest
'bail',
'required',
'file',
static function (string $attribute, mixed $value, Closure $fail): void {
if (! $value instanceof UploadedFile) {
return;
}
$path = $value->getRealPath() ?: $value->getPathname();
if (! $value->isValid() || ! is_string($path) || trim($path) === '') {
$fail('The ' . $attribute . ' upload is invalid.');
}
function (string $attribute, mixed $value, Closure $fail): void {
$this->validateUpload($attribute, $value, $fail);
},
'image',
'mimes:jpeg,jpg,png,webp',
'max:' . $maxKilobytes,
Rule::dimensions()->minWidth(480)->minHeight(480),
function (string $attribute, mixed $value, Closure $fail): void {
$this->validateMinimumDimensions($attribute, $value, $fail, 480, 480);
},
],
];
}
private function validateUpload(string $attribute, mixed $value, Closure $fail): void
{
if (! $value instanceof UploadedFile) {
return;
}
$path = $value->getRealPath() ?: $value->getPathname();
if (! $value->isValid() || ! is_string($path) || trim($path) === '' || ! is_readable($path)) {
$fail('The ' . $attribute . ' upload is invalid.');
}
}
private function validateMinimumDimensions(string $attribute, mixed $value, Closure $fail, int $minWidth, int $minHeight): void
{
if (! $value instanceof UploadedFile) {
return;
}
$path = $value->getRealPath() ?: $value->getPathname();
if (! is_string($path) || trim($path) === '' || ! is_readable($path)) {
$fail('The ' . $attribute . ' upload is invalid.');
return;
}
$binary = @file_get_contents($path);
if ($binary === false || $binary === '') {
$fail('The ' . $attribute . ' upload is invalid.');
return;
}
$dimensions = @getimagesizefromstring($binary);
if (! is_array($dimensions) || ($dimensions[0] ?? 0) < $minWidth || ($dimensions[1] ?? 0) < $minHeight) {
$fail(sprintf('The %s must be at least %dx%d pixels.', $attribute, $minWidth, $minHeight));
}
}
}

View File

@@ -61,6 +61,36 @@ final class UploadFinishRequest extends FormRequest
$this->denyAsNotFound();
}
$archiveSessionId = (string) $this->input('archive_session_id');
if ($archiveSessionId !== '') {
$archiveSession = $sessions->get($archiveSessionId);
if (! $archiveSession || $archiveSession->userId !== $user->id) {
$this->logUnauthorized('archive_session_not_owned_or_missing');
$this->denyAsNotFound();
}
}
$additionalScreenshotSessions = $this->input('additional_screenshot_sessions', []);
if (is_array($additionalScreenshotSessions)) {
foreach ($additionalScreenshotSessions as $index => $payload) {
$screenshotSessionId = (string) data_get($payload, 'session_id', '');
if ($screenshotSessionId === '') {
continue;
}
$screenshotSession = $sessions->get($screenshotSessionId);
if (! $screenshotSession || $screenshotSession->userId !== $user->id) {
$this->logUnauthorized('additional_screenshot_session_not_owned_or_missing');
logger()->warning('Upload finish additional screenshot session rejected', [
'index' => $index,
'session_id' => $screenshotSessionId,
'user_id' => $user->id,
]);
$this->denyAsNotFound();
}
}
}
$artwork = Artwork::query()->find($artworkId);
if (! $artwork || (int) $artwork->user_id !== (int) $user->id) {
$this->logUnauthorized('artwork_not_owned_or_missing');
@@ -79,6 +109,11 @@ final class UploadFinishRequest extends FormRequest
'artwork_id' => 'required|integer',
'upload_token' => 'nullable|string|min:40|max:200',
'file_name' => 'nullable|string|max:255',
'archive_session_id' => 'nullable|uuid|different:session_id',
'archive_file_name' => 'nullable|string|max:255',
'additional_screenshot_sessions' => 'nullable|array|max:4',
'additional_screenshot_sessions.*.session_id' => 'required|uuid|distinct|different:session_id|different:archive_session_id',
'additional_screenshot_sessions.*.file_name' => 'nullable|string|max:255',
];
}

View File

@@ -18,6 +18,7 @@ class ArtworkResource extends JsonResource
$lg = ThumbnailPresenter::present($this->resource, 'lg');
$xl = ThumbnailPresenter::present($this->resource, 'xl');
$sq = ThumbnailPresenter::present($this->resource, 'sq');
$screenshots = $this->resolveScreenshotAssets();
$canonicalSlug = \Illuminate\Support\Str::slug((string) ($this->slug ?: $this->title));
if ($canonicalSlug === '') {
@@ -119,6 +120,7 @@ class ArtworkResource extends JsonResource
'srcset' => ThumbnailPresenter::srcsetForArtwork($this->resource),
'mime_type' => 'image/webp',
],
'screenshots' => $screenshots,
'user' => [
'id' => (int) ($this->user?->id ?? 0),
'name' => html_entity_decode((string) ($this->user?->name ?? ''), ENT_QUOTES | ENT_HTML5, 'UTF-8'),
@@ -173,6 +175,48 @@ class ArtworkResource extends JsonResource
];
}
private function resolveScreenshotAssets(): array
{
if (! Schema::hasTable('artwork_files')) {
return [];
}
return DB::table('artwork_files')
->where('artwork_id', (int) $this->id)
->where('variant', 'like', 'shot%')
->orderBy('variant')
->get(['variant', 'path', 'mime', 'size'])
->map(function ($row, int $index): array {
$path = (string) ($row->path ?? '');
$url = $this->objectUrl($path);
return [
'id' => (string) ($row->variant ?? ('shot' . ($index + 1))),
'variant' => (string) ($row->variant ?? ''),
'label' => 'Screenshot ' . ($index + 1),
'url' => $url,
'thumb_url' => $url,
'mime_type' => (string) ($row->mime ?? 'image/jpeg'),
'size' => (int) ($row->size ?? 0),
];
})
->filter(fn (array $item): bool => $item['url'] !== null)
->values()
->all();
}
private function objectUrl(string $path): ?string
{
$trimmedPath = trim($path, '/');
if ($trimmedPath === '') {
return null;
}
$base = rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/');
return $base . '/' . $trimmedPath;
}
private function renderDescriptionHtml(): string
{
$rawDescription = (string) ($this->description ?? '');