Compare commits
3 Commits
79235133f0
...
a9dfa6ea11
| Author | SHA1 | Date | |
|---|---|---|---|
| a9dfa6ea11 | |||
| b6be6ed2ac | |||
| caf1464aa5 |
@@ -172,7 +172,9 @@ class NewsController extends Controller
|
||||
$userId = Auth::id();
|
||||
$session = 'news_view_' . $article->id;
|
||||
|
||||
if ($request->session()->has($session)) {
|
||||
$canReadSession = $request->hasSession() && ! $request->attributes->get('skinbase.session_skipped');
|
||||
|
||||
if ($canReadSession && $request->session()->has($session)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -185,8 +187,10 @@ class NewsController extends Controller
|
||||
|
||||
$article->incrementViews();
|
||||
|
||||
if ($canReadSession) {
|
||||
$request->session()->put($session, true);
|
||||
}
|
||||
}
|
||||
|
||||
private function sidebarData(): array
|
||||
{
|
||||
|
||||
@@ -92,7 +92,7 @@ final class SitemapCacheService
|
||||
{
|
||||
$prefix = trim((string) config('sitemaps.pre_generated.path', 'generated-sitemaps'), '/');
|
||||
$segments = $name === self::INDEX_DOCUMENT
|
||||
? [$prefix, 'sitemap.xml']
|
||||
? [$prefix, 'sitemaps', 'sitemap.xml']
|
||||
: [$prefix, 'sitemaps', $name . '.xml'];
|
||||
|
||||
return implode('/', array_values(array_filter($segments, static fn (string $segment): bool => $segment !== '')));
|
||||
|
||||
@@ -124,7 +124,7 @@ final class SitemapReleaseManager
|
||||
public function documentRelativePath(string $documentName): string
|
||||
{
|
||||
return $documentName === SitemapCacheService::INDEX_DOCUMENT
|
||||
? 'sitemap.xml'
|
||||
? 'sitemaps/sitemap.xml'
|
||||
: 'sitemaps/' . $documentName . '.xml';
|
||||
}
|
||||
|
||||
|
||||
@@ -107,7 +107,7 @@ export default function ArtworkMaturityQueue() {
|
||||
], [stats])
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl px-4 pb-16 pt-8 sm:px-6 lg:px-8">
|
||||
<div className="w-full pb-16 pt-8">
|
||||
<Head title="Artwork Maturity Queue" />
|
||||
|
||||
<section className="rounded-[32px] border border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(251,191,36,0.16),transparent_36%),linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,0.88))] p-6 shadow-[0_24px_70px_rgba(2,6,23,0.32)]">
|
||||
|
||||
@@ -56,6 +56,7 @@ export default function Topbar({ user = null }) {
|
||||
<div className="border-t border-neutral-700" />
|
||||
<a href={user.uploadUrl} className="block px-4 py-2 text-sm hover:bg-white/5">Upload</a>
|
||||
<a href="/studio/artworks" className="block px-4 py-2 text-sm hover:bg-white/5">Studio</a>
|
||||
{user.moderationUrl ? <a href={user.moderationUrl} className="block px-4 py-2 text-sm hover:bg-white/5">Moderation</a> : null}
|
||||
<a href="/dashboard" className="block px-4 py-2 text-sm hover:bg-white/5">Dashboard</a>
|
||||
<div className="border-t border-neutral-700" />
|
||||
<a href="/logout" className="block px-4 py-2 text-sm text-red-400 hover:bg-white/5"
|
||||
|
||||
@@ -13,6 +13,7 @@ function mount() {
|
||||
username: container.dataset.username || '',
|
||||
avatarUrl: container.dataset.avatarUrl || null,
|
||||
uploadUrl: container.dataset.uploadUrl || '/upload',
|
||||
moderationUrl: container.dataset.moderationUrl || null,
|
||||
}
|
||||
: null
|
||||
|
||||
|
||||
@@ -245,6 +245,7 @@
|
||||
data-username="{{ Auth::user()->username ?? '' }}"
|
||||
data-avatar-url="{{ \App\Support\AvatarUrl::forUser((int) Auth::id(), optional(Auth::user()->profile)->avatar_hash, 64) }}"
|
||||
data-upload-url="{{ Route::has('upload') ? route('upload') : '/upload' }}"
|
||||
data-moderation-url="{{ in_array(strtolower((string) (Auth::user()->role ?? '')), ['admin', 'moderator'], true) ? '/moderation' : '' }}"
|
||||
@endif
|
||||
></div>
|
||||
@include('layouts.nova.toolbar')
|
||||
|
||||
@@ -310,6 +310,7 @@
|
||||
@php
|
||||
$toolbarUsername = strtolower((string) (Auth::user()->username ?? ''));
|
||||
$routeUpload = Route::has('upload') ? route('upload') : '/upload';
|
||||
$routeModeration = '/moderation';
|
||||
$routeDashboard = Route::has('dashboard') ? route('dashboard') : '/dashboard';
|
||||
$routeMyArtworks = Route::has('studio.artworks') ? route('studio.artworks') : '/studio/artworks';
|
||||
$routeMyStories = Route::has('creator.stories.index') ? route('creator.stories.index') : '/creator/stories';
|
||||
@@ -376,6 +377,12 @@
|
||||
<span class="w-6 h-6 rounded-md bg-white/5 inline-flex items-center justify-center shrink-0"><i class="fa-solid fa-palette text-xs text-sb-muted"></i></span>
|
||||
Studio
|
||||
</a>
|
||||
@if(in_array(strtolower((string) (Auth::user()->role ?? '')), ['admin', 'moderator'], true))
|
||||
<a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="{{ $routeModeration }}">
|
||||
<span class="w-6 h-6 rounded-md bg-white/5 inline-flex items-center justify-center shrink-0"><i class="fa-solid fa-user-shield text-xs text-sb-muted"></i></span>
|
||||
Moderation
|
||||
</a>
|
||||
@endif
|
||||
<a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="{{ $routeDashboard }}">
|
||||
<span class="w-6 h-6 rounded-md bg-white/5 inline-flex items-center justify-center shrink-0"><i class="fa-solid fa-table-columns text-xs text-sb-muted"></i></span>
|
||||
Dashboard
|
||||
@@ -401,13 +408,6 @@
|
||||
Settings
|
||||
</a>
|
||||
|
||||
@if(in_array(strtolower((string) (Auth::user()->role ?? '')), ['admin', 'moderator'], true) && \Illuminate\Support\Facades\Route::has('admin.usernames.moderation'))
|
||||
<a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="{{ route('admin.usernames.moderation') }}">
|
||||
<span class="w-6 h-6 rounded-md bg-white/5 inline-flex items-center justify-center shrink-0"><i class="fa-solid fa-user-shield text-xs text-sb-muted"></i></span>
|
||||
Moderation
|
||||
</a>
|
||||
@endif
|
||||
|
||||
<div class="border-t border-panel mt-1 mb-1"></div>
|
||||
<form method="POST" action="{{ route('logout') }}" class="mb-1">
|
||||
@csrf
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
$hero_description = "We're always grateful for volunteers who want to help.";
|
||||
$center_content = true;
|
||||
$center_max = '3xl';
|
||||
$errors = $errors ?? new \Illuminate\Support\ViewErrorBag();
|
||||
@endphp
|
||||
|
||||
@section('page-content')
|
||||
|
||||
@@ -41,7 +41,7 @@ SESSION_ENCRYPT=false
|
||||
SESSION_PATH=/
|
||||
SESSION_DOMAIN=null
|
||||
|
||||
# Skinbase Nova conditional public sessions
|
||||
# Skinbase conditional public sessions
|
||||
SKINBASE_CONDITIONAL_SESSIONS_ENABLED=true
|
||||
SKINBASE_SKIP_ANONYMOUS_PUBLIC_GET_SESSIONS=true
|
||||
SKINBASE_SKIP_BOT_PUBLIC_GET_SESSIONS=true
|
||||
|
||||
@@ -10,7 +10,7 @@ use Illuminate\Support\Facades\Storage;
|
||||
|
||||
/**
|
||||
* Builds all sitemap documents and writes them as static .xml files to the
|
||||
* public disk (default: public/sitemap.xml and public/sitemaps/{name}.xml).
|
||||
* public disk (default: public/sitemaps/sitemap.xml and public/sitemaps/{name}.xml).
|
||||
*
|
||||
* Nginx can then serve those files directly (try_files $uri @php) without
|
||||
* hitting PHP at all. The SitemapController falls back to these same files
|
||||
@@ -25,7 +25,7 @@ final class GenerateSitemapsCommand extends Command
|
||||
{--only=* : Limit to specific sitemap families (comma or space separated)}
|
||||
{--disk= : Override the target filesystem disk (default: sitemaps.static_publish.disk)}';
|
||||
|
||||
protected $description = 'Build all sitemaps and write them as static .xml files to public/.';
|
||||
protected $description = 'Build all sitemaps and write them as static .xml files to the configured public sitemap disk.';
|
||||
|
||||
public function handle(SitemapBuildService $build): int
|
||||
{
|
||||
@@ -50,10 +50,10 @@ final class GenerateSitemapsCommand extends Command
|
||||
// ── Root sitemap index ────────────────────────────────────────────
|
||||
$t = microtime(true);
|
||||
$index = $build->buildIndex(force: true, persist: false, families: $families);
|
||||
$disk->put('sitemap.xml', $index['content']);
|
||||
$disk->put('sitemaps/sitemap.xml', $index['content']);
|
||||
$written++;
|
||||
$this->line(sprintf(
|
||||
' <info>✔</info> sitemap.xml %d entries <comment>%.3fs</comment>',
|
||||
' <info>✔</info> sitemaps/sitemap.xml %d entries <comment>%.3fs</comment>',
|
||||
$index['url_count'],
|
||||
microtime(true) - $t,
|
||||
));
|
||||
|
||||
34
app/Console/Commands/RefreshLeaderboardsCommand.php
Normal file
34
app/Console/Commands/RefreshLeaderboardsCommand.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\LeaderboardService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class RefreshLeaderboardsCommand extends Command
|
||||
{
|
||||
protected $signature = 'leaderboards:refresh';
|
||||
|
||||
protected $description = 'Refresh all leaderboard rows and clear leaderboard caches.';
|
||||
|
||||
public function __construct(private readonly LeaderboardService $leaderboards)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$this->info('Refreshing leaderboards …');
|
||||
|
||||
$results = $this->leaderboards->refreshAll();
|
||||
$updated = collect($results)
|
||||
->flatten(1)
|
||||
->sum(fn (int $count): int => $count);
|
||||
|
||||
$this->info("Done. Updated: {$updated} leaderboard row(s).");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
162
app/Console/Commands/SendUserVerificationEmailCommand.php
Normal file
162
app/Console/Commands/SendUserVerificationEmailCommand.php
Normal file
@@ -0,0 +1,162 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Jobs\SendVerificationEmailJob;
|
||||
use App\Mail\RegistrationVerificationMail;
|
||||
use App\Models\EmailSendEvent;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\RegistrationEmailQuotaService;
|
||||
use App\Services\Auth\RegistrationVerificationTokenService;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
|
||||
class SendUserVerificationEmailCommand extends Command
|
||||
{
|
||||
protected $signature = 'user:send-verification-email
|
||||
{userId : The user ID that should receive the verification email}
|
||||
{--now : Send immediately instead of queueing the existing verification job}
|
||||
{--force : Allow sending even if the user is already verified}';
|
||||
|
||||
protected $description = 'Send the registration verification email to a specific user ID.';
|
||||
|
||||
public function __construct(
|
||||
private readonly RegistrationVerificationTokenService $tokenService,
|
||||
private readonly RegistrationEmailQuotaService $quotaService,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$userId = (int) $this->argument('userId');
|
||||
|
||||
if ($userId < 1) {
|
||||
$this->error('The user ID must be a positive integer.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$user = User::query()->find($userId);
|
||||
|
||||
if (! $user) {
|
||||
$this->error("User {$userId} was not found.");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$email = strtolower(trim((string) $user->email));
|
||||
|
||||
if ($email === '') {
|
||||
$this->error("User {$userId} does not have an email address.");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if ($user->email_verified_at !== null && ! $this->option('force')) {
|
||||
$this->error("User {$userId} already has a verified email address. Use --force to send anyway.");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$token = $this->tokenService->createForUser($userId);
|
||||
|
||||
$event = EmailSendEvent::query()->create([
|
||||
'type' => 'verify_email',
|
||||
'email' => $email,
|
||||
'ip' => null,
|
||||
'user_id' => $userId,
|
||||
'status' => $this->option('now') ? 'pending' : 'queued',
|
||||
'reason' => null,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
if ($this->option('now')) {
|
||||
return $this->sendNow($user, $event, $token);
|
||||
}
|
||||
|
||||
SendVerificationEmailJob::dispatch(
|
||||
emailEventId: (int) $event->id,
|
||||
email: $email,
|
||||
token: $token,
|
||||
userId: $userId,
|
||||
ip: null,
|
||||
);
|
||||
|
||||
$this->markVerificationEmailSent($user);
|
||||
|
||||
$this->info("Queued verification email for user {$userId} <{$email}>.");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function sendNow(User $user, EmailSendEvent $event, string $token): int
|
||||
{
|
||||
if (! $this->acquireGlobalSendSlot()) {
|
||||
$this->updateEvent($event, 'blocked', 'rate_limited');
|
||||
$this->error('The global verification email rate limit is currently exhausted. Try again in a minute.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if ($this->quotaService->isExceeded()) {
|
||||
$this->updateEvent($event, 'blocked', 'quota');
|
||||
$this->error('The monthly registration email quota is exceeded.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
try {
|
||||
Mail::to($user->email)->send(new RegistrationVerificationMail($token));
|
||||
} catch (\Throwable $exception) {
|
||||
$this->updateEvent($event, 'failed', 'send_error');
|
||||
$this->error('Failed to send the verification email: ' . $exception->getMessage());
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->quotaService->incrementSentCount();
|
||||
$this->updateEvent($event, 'sent', null);
|
||||
$this->markVerificationEmailSent($user);
|
||||
|
||||
$email = strtolower(trim((string) $user->email));
|
||||
$this->info("Sent verification email to user {$user->id} <{$email}>.");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function acquireGlobalSendSlot(): bool
|
||||
{
|
||||
$key = 'registration:verification-email:global';
|
||||
$maxPerMinute = max(1, (int) config('registration.email_global_send_per_minute', 30));
|
||||
|
||||
return RateLimiter::attempt($key, $maxPerMinute, static fn () => true, 60);
|
||||
}
|
||||
|
||||
private function updateEvent(EmailSendEvent $event, string $status, ?string $reason): void
|
||||
{
|
||||
EmailSendEvent::query()
|
||||
->whereKey($event->getKey())
|
||||
->update([
|
||||
'status' => $status,
|
||||
'reason' => $reason,
|
||||
]);
|
||||
}
|
||||
|
||||
private function markVerificationEmailSent(User $user): void
|
||||
{
|
||||
$now = now();
|
||||
|
||||
$windowStartedAt = $user->verification_send_window_started_at;
|
||||
if (! $windowStartedAt || $windowStartedAt->lt($now->copy()->subDay())) {
|
||||
$user->verification_send_window_started_at = $now;
|
||||
$user->verification_send_count_24h = 1;
|
||||
} else {
|
||||
$user->verification_send_count_24h = ((int) $user->verification_send_count_24h) + 1;
|
||||
}
|
||||
|
||||
$user->last_verification_sent_at = $now;
|
||||
$user->save();
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Enums\UserRole;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\AuthAuditLog;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\Story;
|
||||
use App\Models\User;
|
||||
@@ -157,4 +158,83 @@ final class AdminController extends Controller
|
||||
'settings' => [],
|
||||
]);
|
||||
}
|
||||
|
||||
public function authAudit(Request $request): Response
|
||||
{
|
||||
abort_unless($request->user()?->isAdmin(), 403, 'Only admins can access this area.');
|
||||
|
||||
$search = $request->string('search')->trim()->toString();
|
||||
$eventType = $request->string('event')->trim()->toString();
|
||||
$status = $request->string('status')->trim()->toString();
|
||||
|
||||
$query = AuthAuditLog::query()
|
||||
->with('user:id,name,username,email,role')
|
||||
->latest('created_at')
|
||||
->latest('id');
|
||||
|
||||
if ($search !== '') {
|
||||
$query->where(function ($builder) use ($search): void {
|
||||
$builder
|
||||
->where('identifier', 'like', "%{$search}%")
|
||||
->orWhere('ip', 'like', "%{$search}%")
|
||||
->orWhere('reason', 'like', "%{$search}%")
|
||||
->orWhereHas('user', function ($userQuery) use ($search): void {
|
||||
$userQuery
|
||||
->where('name', 'like', "%{$search}%")
|
||||
->orWhere('username', 'like', "%{$search}%")
|
||||
->orWhere('email', 'like', "%{$search}%");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if ($eventType !== '' && $eventType !== 'all') {
|
||||
$query->where('event_type', $eventType);
|
||||
}
|
||||
|
||||
if ($status !== '' && $status !== 'all') {
|
||||
$query->where('status', $status);
|
||||
}
|
||||
|
||||
$logs = $query->paginate(50)->withQueryString()->through(function (AuthAuditLog $log): array {
|
||||
return [
|
||||
'id' => $log->id,
|
||||
'event_type' => $log->event_type,
|
||||
'identifier' => $log->identifier,
|
||||
'status' => $log->status,
|
||||
'reason' => $log->reason,
|
||||
'ip' => $log->ip,
|
||||
'user_agent' => $log->user_agent,
|
||||
'metadata' => $log->metadata ?? [],
|
||||
'created_at' => $log->created_at,
|
||||
'user' => $log->user ? [
|
||||
'id' => $log->user->id,
|
||||
'name' => $log->user->name,
|
||||
'username' => $log->user->username,
|
||||
'email' => $log->user->email,
|
||||
'role' => $log->user->role,
|
||||
] : null,
|
||||
];
|
||||
});
|
||||
|
||||
return Inertia::render('Admin/AuthAudit', [
|
||||
'logs' => $logs,
|
||||
'filters' => [
|
||||
'search' => $search,
|
||||
'event' => $eventType,
|
||||
'status' => $status,
|
||||
],
|
||||
'eventOptions' => [
|
||||
['value' => 'all', 'label' => 'All events'],
|
||||
['value' => 'login', 'label' => 'Login'],
|
||||
['value' => 'register', 'label' => 'Register'],
|
||||
['value' => 'forgot_password', 'label' => 'Forgot password'],
|
||||
['value' => 'reset_password', 'label' => 'Reset password'],
|
||||
],
|
||||
'statusOptions' => [
|
||||
['value' => 'all', 'label' => 'All statuses'],
|
||||
['value' => 'success', 'label' => 'Success'],
|
||||
['value' => 'failed', 'label' => 'Failed'],
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,12 +30,7 @@ class PostTrendingFeedController extends Controller
|
||||
|
||||
$result = $this->trendingService->getTrending($viewer, $page);
|
||||
|
||||
$formatted = array_map(
|
||||
fn ($post) => $this->feedService->formatPost($post, $viewer),
|
||||
$result['data'],
|
||||
);
|
||||
|
||||
return response()->json(['data' => array_values($formatted), 'meta' => $result['meta']]);
|
||||
return response()->json($result);
|
||||
}
|
||||
|
||||
public function hashtag(Request $request, string $tag): JsonResponse
|
||||
|
||||
@@ -13,9 +13,11 @@ use App\Support\UsernamePolicy;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Pagination\Cursor;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use UnexpectedValueException;
|
||||
|
||||
/**
|
||||
* ProfileApiController
|
||||
@@ -60,7 +62,22 @@ final class ProfileApiController extends Controller
|
||||
$query = $this->applyArtworkSort($query, $sort);
|
||||
|
||||
$perPage = 24;
|
||||
$paginator = $query->cursorPaginate($perPage);
|
||||
$cursor = Cursor::fromEncoded($request->input('cursor'));
|
||||
|
||||
try {
|
||||
$paginator = (clone $query)->cursorPaginate($perPage, ['*'], 'cursor', $cursor);
|
||||
} catch (UnexpectedValueException) {
|
||||
$originalCursor = $request->query('cursor');
|
||||
$request->query->remove('cursor');
|
||||
|
||||
try {
|
||||
$paginator = (clone $query)->cursorPaginate($perPage, ['*'], 'cursor', null);
|
||||
} finally {
|
||||
if ($originalCursor !== null) {
|
||||
$request->query->set('cursor', $originalCursor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$data = collect($paginator->items())
|
||||
->map(fn (Artwork $art) => $this->mapArtworkCardPayload($art))
|
||||
@@ -196,14 +213,15 @@ final class ProfileApiController extends Controller
|
||||
return $query
|
||||
->leftJoin('artwork_stats as profile_artwork_stats', 'profile_artwork_stats.artwork_id', '=', 'artworks.id')
|
||||
->select('artworks.*')
|
||||
->orderByDesc($statsColumn)
|
||||
->orderByDesc('artworks.published_at')
|
||||
->orderByDesc('artworks.id');
|
||||
->selectRaw('COALESCE(' . $statsColumn . ', 0) as cursor_sort_value')
|
||||
->orderByDesc('cursor_sort_value')
|
||||
->orderByDesc('published_at')
|
||||
->orderByDesc('id');
|
||||
}
|
||||
|
||||
return $query
|
||||
->orderByDesc('artworks.published_at')
|
||||
->orderByDesc('artworks.id');
|
||||
->orderByDesc('published_at')
|
||||
->orderByDesc('id');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -30,15 +30,14 @@ final class UploadVisionSuggestController extends Controller
|
||||
|
||||
public function __invoke(int $id, Request $request): JsonResponse
|
||||
{
|
||||
if (! $this->vision->isEnabled()) {
|
||||
return response()->json(['tags' => [], 'vision_enabled' => false]);
|
||||
}
|
||||
|
||||
$artwork = Artwork::query()->findOrFail($id);
|
||||
$this->authorizeOrNotFound($request->user(), $artwork);
|
||||
$limit = (int) $request->query('limit', 10);
|
||||
|
||||
return response()->json($this->vision->suggestTags($artwork, $this->normalizer, $limit));
|
||||
return response()->json([
|
||||
'tags' => [],
|
||||
'vision_enabled' => false,
|
||||
'reason' => 'disabled',
|
||||
]);
|
||||
}
|
||||
|
||||
private function authorizeOrNotFound(mixed $user, Artwork $artwork): void
|
||||
|
||||
@@ -200,7 +200,7 @@ final class ArtworkDownloadController extends Controller
|
||||
$host = preg_replace('/^www\./', '', $host) ?? '';
|
||||
|
||||
if ($host === '' || in_array($host, ['localhost', '127.0.0.1'], true) || str_ends_with($host, '.test')) {
|
||||
return 'skinbase.top';
|
||||
return 'skinbase.org';
|
||||
}
|
||||
|
||||
return $host;
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Auth\LoginRequest;
|
||||
use App\Services\Auth\AuthAuditLogger;
|
||||
use App\Services\Security\CaptchaVerifier;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -17,6 +18,7 @@ class AuthenticatedSessionController extends Controller
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly CaptchaVerifier $captchaVerifier,
|
||||
private readonly AuthAuditLogger $authAuditLogger,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -35,9 +37,22 @@ class AuthenticatedSessionController extends Controller
|
||||
{
|
||||
$request->authenticate();
|
||||
|
||||
$user = $request->authenticatedUser();
|
||||
|
||||
$this->authAuditLogger->log(
|
||||
eventType: 'login',
|
||||
request: $request,
|
||||
status: 'success',
|
||||
identifier: (string) $request->input('email'),
|
||||
user: $user,
|
||||
metadata: [
|
||||
'via' => $request->authenticatedViaUsername() ? 'username' : 'email',
|
||||
'remember' => $request->boolean('remember'),
|
||||
],
|
||||
);
|
||||
|
||||
$request->session()->regenerate();
|
||||
|
||||
$user = $request->authenticatedUser();
|
||||
if ($user && $request->authenticatedViaUsername() && ! $user->hasCompletedOnboarding()) {
|
||||
$request->session()->put('username_login_upgrade', true);
|
||||
|
||||
|
||||
@@ -4,17 +4,24 @@ namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\AuthAuditLogger;
|
||||
use Illuminate\Auth\Events\PasswordReset;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Password;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rules;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class NewPasswordController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AuthAuditLogger $authAuditLogger,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the password reset view.
|
||||
*/
|
||||
@@ -30,17 +37,36 @@ class NewPasswordController extends Controller
|
||||
*/
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$request->validate([
|
||||
$validator = Validator::make($request->all(), [
|
||||
'token' => ['required'],
|
||||
'email' => ['required', 'email'],
|
||||
'password' => ['required', 'confirmed', Rules\Password::defaults()],
|
||||
]);
|
||||
|
||||
// Here we will attempt to reset the user's password. If it is successful we
|
||||
// will update the password on an actual user model and persist it to the
|
||||
// database. Otherwise we will parse the error and return the response.
|
||||
if ($validator->fails()) {
|
||||
$this->authAuditLogger->log(
|
||||
eventType: 'reset_password',
|
||||
request: $request,
|
||||
status: 'failed',
|
||||
reason: 'validation_failed',
|
||||
identifier: (string) $request->input('email'),
|
||||
metadata: ['fields' => array_keys($validator->errors()->toArray())],
|
||||
);
|
||||
|
||||
$validator->validate();
|
||||
}
|
||||
|
||||
$validated = $validator->validated();
|
||||
$email = strtolower(trim((string) $validated['email']));
|
||||
$user = User::query()->whereRaw('LOWER(email) = ?', [$email])->first();
|
||||
|
||||
$status = Password::reset(
|
||||
$request->only('email', 'password', 'password_confirmation', 'token'),
|
||||
[
|
||||
'email' => $email,
|
||||
'password' => (string) $validated['password'],
|
||||
'password_confirmation' => (string) $request->input('password_confirmation'),
|
||||
'token' => (string) $validated['token'],
|
||||
],
|
||||
function (User $user) use ($request) {
|
||||
$user->forceFill([
|
||||
'password' => Hash::make($request->password),
|
||||
@@ -51,12 +77,20 @@ class NewPasswordController extends Controller
|
||||
}
|
||||
);
|
||||
|
||||
// If the password was successfully reset, we will redirect the user back to
|
||||
// the application's home authenticated view. If there is an error we can
|
||||
// redirect them back to where they came from with their error message.
|
||||
return $status == Password::PASSWORD_RESET
|
||||
$success = $status === Password::PASSWORD_RESET;
|
||||
|
||||
$this->authAuditLogger->log(
|
||||
eventType: 'reset_password',
|
||||
request: $request,
|
||||
status: $success ? 'success' : 'failed',
|
||||
reason: strtolower((string) $status),
|
||||
identifier: $email,
|
||||
user: $user,
|
||||
);
|
||||
|
||||
return $success
|
||||
? redirect()->route('login')->with('status', __($status))
|
||||
: back()->withInput($request->only('email'))
|
||||
: back()->withInput(['email' => $email])
|
||||
->withErrors(['email' => __($status)]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,13 +3,21 @@
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\AuthAuditLogger;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Password;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class PasswordResetLinkController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AuthAuditLogger $authAuditLogger,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the password reset link request view.
|
||||
*/
|
||||
@@ -25,20 +33,45 @@ class PasswordResetLinkController extends Controller
|
||||
*/
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$request->validate([
|
||||
$validator = Validator::make($request->all(), [
|
||||
'email' => ['required', 'email'],
|
||||
]);
|
||||
|
||||
// We will send the password reset link to this user. Once we have attempted
|
||||
// to send the link, we will examine the response then see the message we
|
||||
// need to show to the user. Finally, we'll send out a proper response.
|
||||
$status = Password::sendResetLink(
|
||||
$request->only('email')
|
||||
if ($validator->fails()) {
|
||||
$this->authAuditLogger->log(
|
||||
eventType: 'forgot_password',
|
||||
request: $request,
|
||||
status: 'failed',
|
||||
reason: 'validation_failed',
|
||||
identifier: (string) $request->input('email'),
|
||||
metadata: ['fields' => array_keys($validator->errors()->toArray())],
|
||||
);
|
||||
|
||||
return $status == Password::RESET_LINK_SENT
|
||||
$validator->validate();
|
||||
}
|
||||
|
||||
$validated = $validator->validated();
|
||||
$email = strtolower(trim((string) $validated['email']));
|
||||
$user = User::query()->whereRaw('LOWER(email) = ?', [$email])->first();
|
||||
|
||||
$status = Password::sendResetLink(
|
||||
['email' => $email]
|
||||
);
|
||||
|
||||
$success = $status === Password::RESET_LINK_SENT;
|
||||
|
||||
$this->authAuditLogger->log(
|
||||
eventType: 'forgot_password',
|
||||
request: $request,
|
||||
status: $success ? 'success' : 'failed',
|
||||
reason: strtolower((string) $status),
|
||||
identifier: $email,
|
||||
user: $user,
|
||||
);
|
||||
|
||||
return $success
|
||||
? back()->with('status', __($status))
|
||||
: back()->withInput($request->only('email'))
|
||||
: back()->withInput(['email' => $email])
|
||||
->withErrors(['email' => __($status)]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ use App\Jobs\SendVerificationEmailJob;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\EmailSendEvent;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\AuthAuditLogger;
|
||||
use App\Services\Auth\DisposableEmailService;
|
||||
use App\Services\Auth\RegistrationVerificationTokenService;
|
||||
use App\Services\Security\CaptchaVerifier;
|
||||
@@ -15,6 +16,7 @@ use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\View\View;
|
||||
|
||||
@@ -25,6 +27,7 @@ class RegisteredUserController extends Controller
|
||||
private readonly TurnstileVerifier $turnstileVerifier,
|
||||
private readonly DisposableEmailService $disposableEmailService,
|
||||
private readonly RegistrationVerificationTokenService $verificationTokenService,
|
||||
private readonly AuthAuditLogger $authAuditLogger,
|
||||
)
|
||||
{
|
||||
}
|
||||
@@ -65,7 +68,22 @@ class RegisteredUserController extends Controller
|
||||
];
|
||||
$rules[$this->captchaVerifier->inputName()] = ['nullable', 'string'];
|
||||
|
||||
$validated = $request->validate($rules);
|
||||
$validator = Validator::make($request->all(), $rules);
|
||||
|
||||
if ($validator->fails()) {
|
||||
$this->authAuditLogger->log(
|
||||
eventType: 'register',
|
||||
request: $request,
|
||||
status: 'failed',
|
||||
reason: 'validation_failed',
|
||||
identifier: (string) $request->input('email'),
|
||||
metadata: ['fields' => array_keys($validator->errors()->toArray())],
|
||||
);
|
||||
|
||||
$validator->validate();
|
||||
}
|
||||
|
||||
$validated = $validator->validated();
|
||||
|
||||
$email = strtolower(trim((string) $validated['email']));
|
||||
$ip = $request->ip();
|
||||
@@ -86,6 +104,14 @@ class RegisteredUserController extends Controller
|
||||
}
|
||||
|
||||
if (! $verified) {
|
||||
$this->authAuditLogger->log(
|
||||
eventType: 'register',
|
||||
request: $request,
|
||||
status: 'failed',
|
||||
reason: 'captcha_failed',
|
||||
identifier: $email,
|
||||
);
|
||||
|
||||
return back()
|
||||
->withInput($request->except('website'))
|
||||
->withErrors(['captcha' => 'Captcha verification failed. Please try again.']);
|
||||
@@ -94,6 +120,13 @@ class RegisteredUserController extends Controller
|
||||
|
||||
if ($this->disposableEmailService->isDisposableEmail($email)) {
|
||||
$this->logEmailEvent($email, $ip, null, 'blocked', 'disposable');
|
||||
$this->authAuditLogger->log(
|
||||
eventType: 'register',
|
||||
request: $request,
|
||||
status: 'failed',
|
||||
reason: 'disposable_email',
|
||||
identifier: $email,
|
||||
);
|
||||
|
||||
return back()
|
||||
->withInput($request->except('website'))
|
||||
@@ -103,6 +136,15 @@ class RegisteredUserController extends Controller
|
||||
$user = User::query()->where('email', $email)->first();
|
||||
|
||||
if ($user && $user->hasCompletedOnboarding()) {
|
||||
$this->authAuditLogger->log(
|
||||
eventType: 'register',
|
||||
request: $request,
|
||||
status: 'failed',
|
||||
reason: 'email_exists',
|
||||
identifier: $email,
|
||||
user: $user,
|
||||
);
|
||||
|
||||
return back()
|
||||
->withInput($request->except('website'))
|
||||
->withErrors(['email' => 'An account with this email already exists.']);
|
||||
@@ -136,6 +178,15 @@ class RegisteredUserController extends Controller
|
||||
|
||||
Auth::login($user);
|
||||
|
||||
$this->authAuditLogger->log(
|
||||
eventType: 'register',
|
||||
request: $request,
|
||||
status: 'success',
|
||||
reason: $user->wasRecentlyCreated ? 'user_created' : 'resume_onboarding',
|
||||
identifier: $email,
|
||||
user: $user,
|
||||
);
|
||||
|
||||
$needsPasswordSetup = strtolower((string) ($user->onboarding_step ?? '')) !== 'password'
|
||||
|| (bool) $user->needs_password_reset;
|
||||
|
||||
|
||||
@@ -194,7 +194,9 @@ class NewsController extends Controller
|
||||
$userId = Auth::id();
|
||||
$session = 'news_view_' . $article->id;
|
||||
|
||||
if ($request->session()->has($session)) {
|
||||
$canReadSession = $request->hasSession() && ! $request->attributes->get('skinbase.session_skipped');
|
||||
|
||||
if ($canReadSession && $request->session()->has($session)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -207,8 +209,10 @@ class NewsController extends Controller
|
||||
|
||||
$article->incrementViews();
|
||||
|
||||
if ($canReadSession) {
|
||||
$request->session()->put($session, true);
|
||||
}
|
||||
}
|
||||
|
||||
private function sidebarData(): array
|
||||
{
|
||||
|
||||
@@ -108,7 +108,7 @@ class CollectionInsightsController extends Controller
|
||||
'bulkActions' => route('settings.collections.bulk-actions'),
|
||||
],
|
||||
'seo' => [
|
||||
'title' => 'Collections Dashboard — Skinbase Nova',
|
||||
'title' => 'Collections Dashboard — Skinbase',
|
||||
'description' => 'Overview of collection lifecycle, quality, activity, and upcoming collection campaigns.',
|
||||
'canonical' => route('settings.collections.dashboard'),
|
||||
'robots' => 'noindex,follow',
|
||||
@@ -127,7 +127,7 @@ class CollectionInsightsController extends Controller
|
||||
'historyUrl' => route('settings.collections.history', ['collection' => $collection->id]),
|
||||
'dashboardUrl' => route('settings.collections.dashboard'),
|
||||
'seo' => [
|
||||
'title' => sprintf('%s Analytics — Skinbase Nova', $collection->title),
|
||||
'title' => sprintf('%s Analytics — Skinbase', $collection->title),
|
||||
'description' => sprintf('Analytics and performance history for the %s collection.', $collection->title),
|
||||
'canonical' => route('settings.collections.analytics', ['collection' => $collection->id]),
|
||||
'robots' => 'noindex,follow',
|
||||
@@ -150,7 +150,7 @@ class CollectionInsightsController extends Controller
|
||||
'analyticsUrl' => route('settings.collections.analytics', ['collection' => $collection->id]),
|
||||
'restorePattern' => route('settings.collections.history.restore', ['collection' => $collection->id, 'history' => '__HISTORY__']),
|
||||
'seo' => [
|
||||
'title' => sprintf('%s History — Skinbase Nova', $collection->title),
|
||||
'title' => sprintf('%s History — Skinbase', $collection->title),
|
||||
'description' => sprintf('Audit history and lifecycle changes for the %s collection.', $collection->title),
|
||||
'canonical' => route('settings.collections.history', ['collection' => $collection->id]),
|
||||
'robots' => 'noindex,follow',
|
||||
|
||||
@@ -92,7 +92,7 @@ class CollectionProgrammingController extends Controller
|
||||
'surfaces' => route('settings.collections.surfaces.index'),
|
||||
],
|
||||
'seo' => [
|
||||
'title' => 'Collection Programming — Skinbase Nova',
|
||||
'title' => 'Collection Programming — Skinbase',
|
||||
'description' => 'Staff programming tools for assignments, previews, eligibility diagnostics, and recommendation refreshes.',
|
||||
'canonical' => route('staff.collections.programming'),
|
||||
'robots' => 'noindex,follow',
|
||||
|
||||
@@ -66,7 +66,7 @@ class CollectionSurfaceController extends Controller
|
||||
'batchEditorial' => route('settings.collections.surfaces.batch-editorial'),
|
||||
],
|
||||
'seo' => [
|
||||
'title' => 'Collection Surfaces - Skinbase Nova',
|
||||
'title' => 'Collection Surfaces - Skinbase',
|
||||
'description' => 'Staff tools for homepage, discovery, and campaign collection surfaces.',
|
||||
'canonical' => route('settings.collections.surfaces.index'),
|
||||
'robots' => 'noindex,follow',
|
||||
|
||||
@@ -43,7 +43,7 @@ class FeaturedArtworkAdminController extends Controller
|
||||
'forceHeroEnabled' => $this->hasForceHeroColumn(),
|
||||
],
|
||||
'seo' => [
|
||||
'title' => 'Featured Artworks — Skinbase Nova',
|
||||
'title' => 'Featured Artworks — Skinbase',
|
||||
'description' => 'Editorial controls for homepage featured artworks and the current hero winner.',
|
||||
'canonical' => route($routePrefix . 'main'),
|
||||
'robots' => 'noindex,follow',
|
||||
|
||||
@@ -198,6 +198,14 @@ class HomepageAnnouncementController extends Controller
|
||||
return;
|
||||
}
|
||||
|
||||
$backgroundDisk = $this->announcements->backgroundImageDisk();
|
||||
|
||||
if (Storage::disk($backgroundDisk)->exists($path)) {
|
||||
Storage::disk($backgroundDisk)->delete($path);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (Storage::disk('public')->exists($path)) {
|
||||
Storage::disk('public')->delete($path);
|
||||
}
|
||||
@@ -268,8 +276,8 @@ class HomepageAnnouncementController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
$storedPath = 'homepage-announcements/' . pathinfo(Str::replace('\\', '/', $file->hashName()), PATHINFO_FILENAME) . '.webp';
|
||||
Storage::disk('public')->put($storedPath, $webpBinary, ['visibility' => 'public']);
|
||||
$storedPath = $this->announcements->backgroundImagePrefix() . '/' . pathinfo(Str::replace('\\', '/', $file->hashName()), PATHINFO_FILENAME) . '.webp';
|
||||
Storage::disk($this->announcements->backgroundImageDisk())->put($storedPath, $webpBinary, ['visibility' => 'public']);
|
||||
} finally {
|
||||
imagedestroy($image);
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ final class StudioWorldController extends Controller
|
||||
|
||||
return Inertia::render('Studio/StudioWorldsIndex', [
|
||||
'title' => 'Worlds',
|
||||
'description' => 'Create and manage seasonal, event, and campaign destinations across Skinbase Nova.',
|
||||
'description' => 'Create and manage seasonal, event, and campaign destinations across Skinbase.',
|
||||
'listing' => $this->worlds->studioListing($request->only(['q', 'status', 'type', 'per_page'])),
|
||||
'analytics' => $this->analytics->portfolioReport(),
|
||||
'statusOptions' => [
|
||||
@@ -435,7 +435,7 @@ final class StudioWorldController extends Controller
|
||||
|
||||
$payload = $this->worlds->publicShowPayload($world, $request->user(), true);
|
||||
$seo = app(SeoFactory::class)->collectionPage(
|
||||
$world->seo_title ?: ($world->title . ' — Skinbase Nova Preview'),
|
||||
$world->seo_title ?: ($world->title . ' — Skinbase Preview'),
|
||||
$world->seo_description ?: ($world->summary ?: $world->description ?: 'Preview world page'),
|
||||
route('studio.worlds.preview', ['world' => $world]),
|
||||
$world->ogImageUrl(),
|
||||
|
||||
@@ -116,9 +116,9 @@ class ProfileCollectionController extends Controller
|
||||
|
||||
$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()),
|
||||
? sprintf('Featured: %s by %s — Skinbase', $collection->title, $collection->displayOwnerName())
|
||||
: sprintf('%s by %s — Skinbase', $collection->title, $collection->displayOwnerName()),
|
||||
$collection->summary ?: $collection->description ?: sprintf('Explore the %s collection by %s on Skinbase.', $collection->title, $collection->displayOwnerName()),
|
||||
$collectionPayload['public_url'],
|
||||
$collectionPayload['cover_image'],
|
||||
$collection->visibility === Collection::VISIBILITY_PUBLIC,
|
||||
@@ -202,8 +202,8 @@ class ProfileCollectionController extends Controller
|
||||
$seriesDescription = $seriesMeta['description'];
|
||||
|
||||
$seo = app(SeoFactory::class)->collectionListing(
|
||||
sprintf('Series: %s — Skinbase Nova', $seriesKey),
|
||||
sprintf('Explore the %s collection series on Skinbase Nova.', $seriesKey),
|
||||
sprintf('Series: %s — Skinbase', $seriesKey),
|
||||
sprintf('Explore the %s collection series on Skinbase.', $seriesKey),
|
||||
route('collections.series.show', ['seriesKey' => $seriesKey])
|
||||
)->toArray();
|
||||
|
||||
|
||||
@@ -155,8 +155,8 @@ class SavedCollectionController extends Controller
|
||||
'libraryUrl' => route('me.saved.collections'),
|
||||
'browseUrl' => route('collections.featured'),
|
||||
'seo' => [
|
||||
'title' => $activeList ? sprintf('%s — Saved Collections — Skinbase Nova', $activeList->title) : 'Saved Collections — Skinbase Nova',
|
||||
'description' => $activeList ? sprintf('Saved collections in the %s list on Skinbase Nova.', $activeList->title) : 'Your saved collections on Skinbase Nova.',
|
||||
'title' => $activeList ? sprintf('%s — Saved Collections — Skinbase', $activeList->title) : 'Saved Collections — Skinbase',
|
||||
'description' => $activeList ? sprintf('Saved collections in the %s list on Skinbase.', $activeList->title) : 'Your saved collections on Skinbase.',
|
||||
'canonical' => $activeList ? route('me.saved.collections.lists.show', ['listSlug' => $activeList->slug]) : route('me.saved.collections'),
|
||||
'robots' => 'noindex,follow',
|
||||
],
|
||||
|
||||
@@ -18,7 +18,7 @@ final class AccountHelpPageController extends Controller
|
||||
$seo = app(SeoFactory::class)
|
||||
->collectionPage(
|
||||
'Account Settings Help — Skinbase',
|
||||
'Learn how account settings, profile settings, email changes, password care, and creator preferences work on Skinbase Nova.',
|
||||
'Learn how account settings, profile settings, email changes, password care, and creator preferences work on Skinbase.',
|
||||
$canonical,
|
||||
)
|
||||
->toArray();
|
||||
|
||||
@@ -18,6 +18,7 @@ use App\Services\Maturity\ArtworkMaturityService;
|
||||
use App\Support\Seo\SeoFactory;
|
||||
use App\Support\AvatarUrl;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
@@ -104,6 +105,8 @@ final class ArtworkPageController extends Controller
|
||||
->published()
|
||||
->firstOrFail();
|
||||
|
||||
$this->loadCategoryAncestors($artwork->categories);
|
||||
|
||||
$canonicalSlug = Str::slug((string) ($artwork->slug ?: $artwork->title));
|
||||
if ($canonicalSlug === '') {
|
||||
$canonicalSlug = (string) $artwork->id;
|
||||
@@ -203,10 +206,25 @@ final class ArtworkPageController extends Controller
|
||||
->values()
|
||||
->all();
|
||||
|
||||
// Recursive helper to format a comment and its nested replies
|
||||
$approvedComments = ArtworkComment::query()
|
||||
->with('user.profile')
|
||||
->where('artwork_id', $artwork->id)
|
||||
->where('is_approved', true)
|
||||
->orderBy('created_at')
|
||||
->limit(500)
|
||||
->get();
|
||||
|
||||
$commentsByParent = $approvedComments->groupBy(
|
||||
static fn (ArtworkComment $comment): string => $comment->parent_id === null
|
||||
? 'root'
|
||||
: (string) $comment->parent_id
|
||||
);
|
||||
|
||||
// Recursive helper to format a comment and its nested replies.
|
||||
$formatComment = null;
|
||||
$formatComment = function (ArtworkComment $c) use (&$formatComment): array {
|
||||
$replies = $c->relationLoaded('approvedReplies') ? $c->approvedReplies : collect();
|
||||
$formatComment = function (ArtworkComment $c) use (&$formatComment, $commentsByParent): array {
|
||||
/** @var Collection<int, ArtworkComment> $replies */
|
||||
$replies = $commentsByParent->get((string) $c->id, collect());
|
||||
$user = $c->user;
|
||||
$userId = (int) ($c->user_id ?? 0);
|
||||
$avatarHash = $user?->profile?->avatar_hash ?? null;
|
||||
@@ -234,7 +252,9 @@ final class ArtworkPageController extends Controller
|
||||
'username' => $user?->username,
|
||||
'display' => $user?->username ?? $user?->name ?? 'User',
|
||||
'profile_url' => $user?->username ? '/@' . $user->username : ($userId > 0 ? '/profile/' . $userId : null),
|
||||
'avatar_url' => AvatarUrl::forUser($userId, $avatarHash, 64),
|
||||
'avatar_url' => $avatarHash !== null
|
||||
? AvatarUrl::forUser($userId, $avatarHash, 64)
|
||||
: AvatarUrl::default(),
|
||||
'level' => (int) ($user?->level ?? 1),
|
||||
'rank' => (string) ($user?->rank ?? 'Newbie'),
|
||||
],
|
||||
@@ -242,13 +262,8 @@ final class ArtworkPageController extends Controller
|
||||
];
|
||||
};
|
||||
|
||||
$comments = ArtworkComment::with(['user.profile', 'approvedReplies'])
|
||||
->where('artwork_id', $artwork->id)
|
||||
->where('is_approved', true)
|
||||
->whereNull('parent_id')
|
||||
->orderBy('created_at')
|
||||
->limit(500)
|
||||
->get()
|
||||
$comments = $commentsByParent
|
||||
->get('root', collect())
|
||||
->map($formatComment)
|
||||
->values()
|
||||
->all();
|
||||
@@ -314,6 +329,41 @@ final class ArtworkPageController extends Controller
|
||||
return $totals;
|
||||
}
|
||||
|
||||
private function loadCategoryAncestors(Collection $categories): void
|
||||
{
|
||||
$currentLevel = $categories->filter();
|
||||
|
||||
while ($currentLevel->isNotEmpty()) {
|
||||
$fetchedParents = collect();
|
||||
$missingParentIds = $currentLevel
|
||||
->filter(static fn ($category) => $category->parent_id !== null && ! $category->relationLoaded('parent'))
|
||||
->pluck('parent_id')
|
||||
->filter()
|
||||
->unique()
|
||||
->values();
|
||||
|
||||
if ($missingParentIds->isNotEmpty()) {
|
||||
$fetchedParents = \App\Models\Category::query()
|
||||
->with('contentType')
|
||||
->whereIn('id', $missingParentIds->all())
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
$currentLevel->each(function ($category) use ($fetchedParents): void {
|
||||
if ($category->parent_id !== null && ! $category->relationLoaded('parent')) {
|
||||
$category->setRelation('parent', $fetchedParents->get($category->parent_id));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
$currentLevel = $currentLevel
|
||||
->map(static fn ($category) => $category->relationLoaded('parent') ? $category->getRelation('parent') : null)
|
||||
->filter()
|
||||
->unique('id')
|
||||
->values();
|
||||
}
|
||||
}
|
||||
|
||||
/** Silently catch suggestion query failures so error page never crashes. */
|
||||
private function safeSuggestions(callable $fn): mixed
|
||||
{
|
||||
|
||||
@@ -18,7 +18,7 @@ final class AuthHelpPageController extends Controller
|
||||
$seo = app(SeoFactory::class)
|
||||
->collectionPage(
|
||||
'Signup and Login Help — Skinbase',
|
||||
'Learn how signup, login, password recovery, verification, and account access work on Skinbase Nova, with clear guidance for common access problems and practical next steps.',
|
||||
'Learn how signup, login, password recovery, verification, and account access work on Skinbase, with clear guidance for common access problems and practical next steps.',
|
||||
$canonical,
|
||||
)
|
||||
->toArray();
|
||||
@@ -27,7 +27,7 @@ final class AuthHelpPageController extends Controller
|
||||
|
||||
return Inertia::render('Help/AuthHelpPage', [
|
||||
'title' => 'Signup & Login Help',
|
||||
'description' => 'Get clear help for account creation, sign-in, password recovery, verification basics, and common access problems on Skinbase Nova.',
|
||||
'description' => 'Get clear help for account creation, sign-in, password recovery, verification basics, and common access problems on Skinbase.',
|
||||
'seo' => $seo,
|
||||
'links' => [
|
||||
'help_home' => route('help'),
|
||||
|
||||
@@ -148,7 +148,12 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
|
||||
$ttl = self::SORT_TTL_MAP[$sort] ?? 300;
|
||||
|
||||
$mainCategories = $this->mainCategories();
|
||||
$rootCategories = $contentType->rootCategories()->orderBy('sort_order')->orderBy('name')->get();
|
||||
$rootCategories = $contentType->rootCategories()
|
||||
->with('contentType')
|
||||
->orderBy('sort_order')
|
||||
->orderBy('name')
|
||||
->get();
|
||||
$rootCategoryLinks = $this->buildCategoryLinkItems($rootCategories, $contentSlug);
|
||||
|
||||
$normalizedPath = trim((string) $path, '/');
|
||||
if ($normalizedPath === '') {
|
||||
@@ -160,13 +165,14 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
|
||||
'sort' => self::SORT_MAP[$sort] ?? ['published_at_ts:desc'],
|
||||
], $perPage, false, $page)
|
||||
);
|
||||
$this->loadGalleryArtworkRelations($artworks->getCollection());
|
||||
$artworks->getCollection()->transform(fn ($a) => $this->presentArtwork($a));
|
||||
$seo = $this->buildPaginationSeo($request, url('/' . $contentSlug), $artworks);
|
||||
|
||||
return view('gallery.index', [
|
||||
'gallery_type' => 'content-type',
|
||||
'mainCategories' => $mainCategories,
|
||||
'subcategories' => $rootCategories,
|
||||
'subcategories' => $rootCategoryLinks,
|
||||
'contentType' => $contentType,
|
||||
'category' => null,
|
||||
'artworks' => $artworks,
|
||||
@@ -178,7 +184,7 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
|
||||
(object) ['name' => 'Explore', 'url' => '/browse'],
|
||||
(object) ['name' => $contentType->name, 'url' => '/' . $contentSlug],
|
||||
]),
|
||||
'page_title' => $contentType->name . ' – Skinbase Nova',
|
||||
'page_title' => $contentType->name . ' – Skinbase',
|
||||
'page_meta_description' => $contentType->description ?? ('Discover the best ' . $contentType->name . ' artworks on Skinbase'),
|
||||
'page_meta_keywords' => strtolower($contentType->slug) . ', skinbase, artworks, wallpapers, skins, photography',
|
||||
'page_canonical' => $seo['canonical'],
|
||||
@@ -194,6 +200,8 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$this->loadCategoryLineage($category);
|
||||
|
||||
$categorySlugs = $this->categoryFilterSlugs($category);
|
||||
$filterExpression = $this->categoryPageFilterExpression($contentSlug, $categorySlugs);
|
||||
|
||||
@@ -205,14 +213,25 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
|
||||
'sort' => self::SORT_MAP[$sort] ?? ['published_at_ts:desc'],
|
||||
], $perPage, false, $page)
|
||||
);
|
||||
$this->loadGalleryArtworkRelations($artworks->getCollection());
|
||||
$artworks->getCollection()->transform(fn ($a) => $this->presentArtwork($a));
|
||||
$seo = $this->buildPaginationSeo($request, url('/' . $contentSlug . '/' . strtolower($category->full_slug_path)), $artworks);
|
||||
|
||||
$navigationCategory = $category->parent ?: $category;
|
||||
$navigationPath = strtolower($navigationCategory->full_slug_path);
|
||||
$subcategoryParent = (object) [
|
||||
'id' => $navigationCategory->id,
|
||||
'url' => $this->buildCategoryUrl($contentSlug, $navigationPath),
|
||||
];
|
||||
|
||||
$subcategories = $navigationCategory->children()->orderBy('sort_order')->orderBy('name')->get();
|
||||
$subcategories = $navigationCategory->children()
|
||||
->with(['contentType', 'parent.contentType'])
|
||||
->orderBy('sort_order')
|
||||
->orderBy('name')
|
||||
->get();
|
||||
$subcategoryLinks = $this->buildCategoryLinkItems($subcategories, $contentSlug, $navigationPath);
|
||||
if ($subcategories->isEmpty()) {
|
||||
$subcategories = $rootCategories;
|
||||
$subcategoryLinks = $rootCategoryLinks;
|
||||
}
|
||||
|
||||
$breadcrumbs = collect(array_merge([
|
||||
@@ -235,8 +254,8 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
|
||||
return view('gallery.index', [
|
||||
'gallery_type' => 'category',
|
||||
'mainCategories' => $mainCategories,
|
||||
'subcategories' => $subcategories,
|
||||
'subcategory_parent' => $navigationCategory,
|
||||
'subcategories' => $subcategoryLinks,
|
||||
'subcategory_parent' => $subcategoryParent,
|
||||
'contentType' => $contentType,
|
||||
'category' => $category,
|
||||
'artworks' => $artworks,
|
||||
@@ -245,7 +264,7 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
|
||||
'hero_title' => $category->name,
|
||||
'hero_description' => $category->description ?? ($contentType->name . ' artworks on Skinbase.'),
|
||||
'breadcrumbs' => $breadcrumbs,
|
||||
'page_title' => $category->name . ' – Skinbase Nova',
|
||||
'page_title' => $category->name . ' – Skinbase',
|
||||
'page_meta_description' => $category->description ?? ('Discover the best ' . $category->name . ' ' . $contentType->name . ' artworks on Skinbase'),
|
||||
'page_meta_keywords' => strtolower($contentType->slug) . ', skinbase, artworks, wallpapers, skins, photography',
|
||||
'page_canonical' => $seo['canonical'],
|
||||
@@ -303,13 +322,12 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
|
||||
$present = ThumbnailPresenter::present($artwork, 'md');
|
||||
$group = $artwork->group;
|
||||
$isGroupPublisher = $group !== null;
|
||||
$avatarHash = $artwork->user?->profile?->avatar_hash ?? null;
|
||||
$avatarUrl = $isGroupPublisher
|
||||
? $group->avatarUrl()
|
||||
: \App\Support\AvatarUrl::forUser(
|
||||
(int) ($artwork->user_id ?? 0),
|
||||
$artwork->user?->profile?->avatar_hash ?? null,
|
||||
64
|
||||
);
|
||||
: ($avatarHash !== null
|
||||
? \App\Support\AvatarUrl::forUser((int) ($artwork->user_id ?? 0), $avatarHash, 64)
|
||||
: \App\Support\AvatarUrl::default());
|
||||
$displayName = $isGroupPublisher ? ($group->name ?? 'Skinbase') : ($artwork->user?->name ?? 'Skinbase');
|
||||
$username = $isGroupPublisher ? '' : ($artwork->user?->username ?? '');
|
||||
$profileUrl = $isGroupPublisher ? $group->publicUrl() : ($username !== '' ? '/@' . $username : null);
|
||||
@@ -349,27 +367,74 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
|
||||
*/
|
||||
private function categoryFilterSlugs(Category $category): array
|
||||
{
|
||||
$category->loadMissing('descendants');
|
||||
|
||||
$slugs = [];
|
||||
$stack = [$category];
|
||||
$pendingParentIds = [$category->id];
|
||||
|
||||
while ($stack !== []) {
|
||||
/** @var Category $current */
|
||||
$current = array_pop($stack);
|
||||
if (! empty($current->slug)) {
|
||||
$slugs[] = Str::lower($current->slug);
|
||||
if (! empty($category->slug)) {
|
||||
$slugs[] = Str::lower($category->slug);
|
||||
}
|
||||
|
||||
foreach ($current->children as $child) {
|
||||
$child->loadMissing('descendants');
|
||||
$stack[] = $child;
|
||||
while ($pendingParentIds !== []) {
|
||||
$children = Category::query()
|
||||
->whereIn('parent_id', $pendingParentIds)
|
||||
->get(['id', 'slug']);
|
||||
|
||||
$pendingParentIds = $children->pluck('id')->all();
|
||||
|
||||
foreach ($children as $child) {
|
||||
if (! empty($child->slug)) {
|
||||
$slugs[] = Str::lower($child->slug);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique($slugs));
|
||||
}
|
||||
|
||||
private function loadCategoryLineage(Category $category): void
|
||||
{
|
||||
$current = $category;
|
||||
|
||||
while ($current !== null) {
|
||||
$current->loadMissing(['contentType', 'parent']);
|
||||
$current = $current->parent;
|
||||
}
|
||||
}
|
||||
|
||||
private function buildCategoryLinkItems(Collection $categories, string $contentSlug, ?string $basePath = null): Collection
|
||||
{
|
||||
$normalizedBasePath = trim(strtolower((string) $basePath), '/');
|
||||
|
||||
return $categories->map(function (Category $category) use ($contentSlug, $normalizedBasePath) {
|
||||
return (object) [
|
||||
'id' => $category->id,
|
||||
'name' => $category->name,
|
||||
'slug' => $category->slug,
|
||||
'url' => $this->buildCategoryUrl($contentSlug, implode('/', array_filter([$normalizedBasePath, $category->slug]))),
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
private function buildCategoryUrl(string $contentSlug, ?string $path = null): string
|
||||
{
|
||||
$normalizedPath = trim(strtolower((string) $path), '/');
|
||||
|
||||
return '/' . implode('/', array_filter([$contentSlug, $normalizedPath]));
|
||||
}
|
||||
|
||||
private function loadGalleryArtworkRelations(Collection $artworks): void
|
||||
{
|
||||
if ($artworks->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$artworks->loadMissing([
|
||||
'user.profile',
|
||||
'group',
|
||||
'categories.contentType',
|
||||
]);
|
||||
}
|
||||
|
||||
private function categoryFilterClause(string $categorySlug): string
|
||||
{
|
||||
$quoted = addslashes($categorySlug);
|
||||
|
||||
@@ -18,7 +18,7 @@ final class CardsHelpPageController extends Controller
|
||||
$seo = app(SeoFactory::class)
|
||||
->collectionPage(
|
||||
'Cards Help — Skinbase',
|
||||
'Learn what Cards are on Skinbase Nova, how they differ from artworks, posts, and collections, and how to create, publish, and use them effectively in personal and Group workflows.',
|
||||
'Learn what Cards are on Skinbase, how they differ from artworks, posts, and collections, and how to create, publish, and use them effectively in personal and Group workflows.',
|
||||
$canonical,
|
||||
)
|
||||
->toArray();
|
||||
@@ -27,7 +27,7 @@ final class CardsHelpPageController extends Controller
|
||||
|
||||
return Inertia::render('Help/CardsHelpPage', [
|
||||
'title' => 'Cards Help',
|
||||
'description' => 'Understand Cards as a distinct creative format on Skinbase Nova, with guidance for creation, publishing, ownership, design quality, and real-world use cases.',
|
||||
'description' => 'Understand Cards as a distinct creative format on Skinbase, with guidance for creation, publishing, ownership, design quality, and real-world use cases.',
|
||||
'seo' => $seo,
|
||||
'links' => [
|
||||
'help_home' => route('help'),
|
||||
|
||||
@@ -52,9 +52,9 @@ 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',
|
||||
'Search Collections — Skinbase',
|
||||
filled($filters['q'] ?? null)
|
||||
? sprintf('Search results for "%s" across public Skinbase Nova collections.', $filters['q'])
|
||||
? sprintf('Search results for "%s" across public Skinbase collections.', $filters['q'])
|
||||
: 'Browse public collections using filters for category, style, theme, color, quality tier, freshness, and programming metadata.',
|
||||
$request->fullUrl(),
|
||||
null,
|
||||
@@ -65,7 +65,7 @@ class CollectionDiscoveryController extends Controller
|
||||
'eyebrow' => 'Search',
|
||||
'title' => 'Search collections',
|
||||
'description' => filled($filters['q'] ?? null)
|
||||
? sprintf('Search results for "%s" across public Skinbase Nova collections.', $filters['q'])
|
||||
? sprintf('Search results for "%s" across public Skinbase collections.', $filters['q'])
|
||||
: 'Browse public collections using filters for category, style, theme, color, quality tier, freshness, and programming metadata.',
|
||||
'seo' => $seo,
|
||||
'collections' => $this->collections->mapCollectionCardPayloads($results->items(), false, $request->user()),
|
||||
@@ -100,7 +100,7 @@ class CollectionDiscoveryController extends Controller
|
||||
viewer: $request->user(),
|
||||
eyebrow: 'Discovery',
|
||||
title: 'Featured collections',
|
||||
description: 'A rotating set of standout galleries from across Skinbase Nova. Some are meticulously hand-sequenced. Others are smart collections that stay fresh as the creator publishes new work.',
|
||||
description: 'A rotating set of standout galleries from across Skinbase. Some are meticulously hand-sequenced. Others are smart collections that stay fresh as the creator publishes new work.',
|
||||
collections: $featuredCollections->isNotEmpty() ? $featuredCollections : $this->discovery->publicFeaturedCollections((int) config('collections.discovery.featured_limit', 18)),
|
||||
communityCollections: $this->discovery->publicCollectionsByType(Collection::TYPE_COMMUNITY, 6),
|
||||
editorialCollections: $this->discovery->publicCollectionsByType(Collection::TYPE_EDITORIAL, 6),
|
||||
@@ -204,7 +204,7 @@ class CollectionDiscoveryController extends Controller
|
||||
abort_if(! $program || collect($landing['collections'])->isEmpty(), 404);
|
||||
|
||||
$seo = app(SeoFactory::class)->collectionListing(
|
||||
sprintf('%s — Skinbase Nova', $program['label']),
|
||||
sprintf('%s — Skinbase', $program['label']),
|
||||
$program['description'],
|
||||
route('collections.program.show', ['programKey' => $program['key']]),
|
||||
)->toArray();
|
||||
@@ -239,7 +239,7 @@ class CollectionDiscoveryController extends Controller
|
||||
$campaign = null,
|
||||
) {
|
||||
$seo = app(SeoFactory::class)->collectionListing(
|
||||
sprintf('%s — Skinbase Nova', $title),
|
||||
sprintf('%s — Skinbase', $title),
|
||||
$description,
|
||||
url()->current(),
|
||||
)->toArray();
|
||||
|
||||
@@ -92,12 +92,17 @@ final class ExploreController extends Controller
|
||||
$artworks = $this->filterBrowsableArtworks($artworks);
|
||||
// EGS: fill grid to minimum when uploads are sparse
|
||||
$artworks = $this->gridFiller->fill($artworks, 0, $page);
|
||||
$this->loadPresentationRelations($artworks->getCollection());
|
||||
$artworks->getCollection()->transform(fn ($a) => $this->presentArtwork($a));
|
||||
|
||||
// EGS §11: featured spotlight row on page 1 only
|
||||
$spotlightItems = ($page === 1 && EarlyGrowth::spotlightEnabled())
|
||||
? $this->spotlight->getSpotlight(6)->map(fn ($a) => $this->presentArtwork($a))
|
||||
: collect();
|
||||
$spotlightItems = collect();
|
||||
|
||||
if ($page === 1 && EarlyGrowth::spotlightEnabled()) {
|
||||
$spotlightItems = $this->spotlight->getSpotlight(6);
|
||||
$this->loadPresentationRelations($spotlightItems);
|
||||
$spotlightItems = $spotlightItems->map(fn ($a) => $this->presentArtwork($a));
|
||||
}
|
||||
|
||||
$mainCategories = $this->mainCategories();
|
||||
$seo = $this->paginationSeo($request, url('/explore'), $artworks);
|
||||
@@ -165,12 +170,17 @@ final class ExploreController extends Controller
|
||||
$artworks = $this->filterBrowsableArtworks($artworks);
|
||||
// EGS: fill grid to minimum when uploads are sparse
|
||||
$artworks = $this->gridFiller->fill($artworks, 0, $page);
|
||||
$this->loadPresentationRelations($artworks->getCollection());
|
||||
$artworks->getCollection()->transform(fn ($a) => $this->presentArtwork($a));
|
||||
|
||||
// EGS §11: featured spotlight row on page 1 only
|
||||
$spotlightItems = ($page === 1 && EarlyGrowth::spotlightEnabled())
|
||||
? $this->spotlight->getSpotlight(6)->map(fn ($a) => $this->presentArtwork($a))
|
||||
: collect();
|
||||
$spotlightItems = collect();
|
||||
|
||||
if ($page === 1 && EarlyGrowth::spotlightEnabled()) {
|
||||
$spotlightItems = $this->spotlight->getSpotlight(6);
|
||||
$this->loadPresentationRelations($spotlightItems);
|
||||
$spotlightItems = $spotlightItems->map(fn ($a) => $this->presentArtwork($a));
|
||||
}
|
||||
|
||||
$mainCategories = $this->mainCategories();
|
||||
$contentType = null;
|
||||
@@ -557,6 +567,13 @@ final class ExploreController extends Controller
|
||||
], $artwork, request()->user());
|
||||
}
|
||||
|
||||
private function loadPresentationRelations(mixed $artworks): void
|
||||
{
|
||||
if (is_object($artworks) && method_exists($artworks, 'loadMissing')) {
|
||||
$artworks->loadMissing(['user.profile', 'group', 'categories.contentType']);
|
||||
}
|
||||
}
|
||||
|
||||
private function paginationSeo(Request $request, string $base, mixed $paginator): array
|
||||
{
|
||||
$q = $request->query();
|
||||
|
||||
@@ -18,7 +18,7 @@ final class GroupFaqPageController extends Controller
|
||||
$seo = app(SeoFactory::class)
|
||||
->collectionPage(
|
||||
'Groups FAQ — Skinbase',
|
||||
'Fast answers to the most common Groups questions on Skinbase Nova, including roles, permissions, publishing, contributor credit, invites, workflows, and troubleshooting.',
|
||||
'Fast answers to the most common Groups questions on Skinbase, including roles, permissions, publishing, contributor credit, invites, workflows, and troubleshooting.',
|
||||
$canonical,
|
||||
)
|
||||
->toArray();
|
||||
@@ -27,7 +27,7 @@ final class GroupFaqPageController extends Controller
|
||||
|
||||
return Inertia::render('Group/GroupFaqPage', [
|
||||
'title' => 'Groups FAQ',
|
||||
'description' => 'Quick answers about Groups, roles, permissions, publishing, contributor credit, invites, workflows, and troubleshooting on Skinbase Nova.',
|
||||
'description' => 'Quick answers about Groups, roles, permissions, publishing, contributor credit, invites, workflows, and troubleshooting on Skinbase.',
|
||||
'seo' => $seo,
|
||||
'links' => [
|
||||
'groups_directory' => route('groups.index'),
|
||||
|
||||
@@ -18,7 +18,7 @@ final class GroupHelpPageController extends Controller
|
||||
$seo = app(SeoFactory::class)
|
||||
->collectionPage(
|
||||
'Groups Guide, Help, and Best Practices — Skinbase',
|
||||
'Learn how Groups work on Skinbase Nova, how shared publishing preserves contributor credit, and how to manage roles, releases, reviews, projects, and team workflows with confidence.',
|
||||
'Learn how Groups work on Skinbase, how shared publishing preserves contributor credit, and how to manage roles, releases, reviews, projects, and team workflows with confidence.',
|
||||
$canonical,
|
||||
)
|
||||
->toArray();
|
||||
@@ -27,7 +27,7 @@ final class GroupHelpPageController extends Controller
|
||||
|
||||
return Inertia::render('Group/GroupHelpPage', [
|
||||
'title' => 'Groups Help & Guide',
|
||||
'description' => 'Everything creators need to understand Groups, publish collaboratively, preserve contributor credit, and build a healthy shared identity on Skinbase Nova.',
|
||||
'description' => 'Everything creators need to understand Groups, publish collaboratively, preserve contributor credit, and build a healthy shared identity on Skinbase.',
|
||||
'seo' => $seo,
|
||||
'links' => [
|
||||
'groups_directory' => route('groups.index'),
|
||||
|
||||
@@ -18,7 +18,7 @@ final class GroupQuickstartPageController extends Controller
|
||||
$seo = app(SeoFactory::class)
|
||||
->collectionPage(
|
||||
'Groups Quickstart — Skinbase',
|
||||
'A fast, creator-friendly Groups quickstart for Skinbase Nova. Learn when to use a Group, create one, invite members, and publish your first Group artwork with correct contributor credit.',
|
||||
'A fast, creator-friendly Groups quickstart for Skinbase. Learn when to use a Group, create one, invite members, and publish your first Group artwork with correct contributor credit.',
|
||||
$canonical,
|
||||
)
|
||||
->toArray();
|
||||
|
||||
@@ -18,7 +18,7 @@ final class HelpCenterPageController extends Controller
|
||||
$seo = app(SeoFactory::class)
|
||||
->collectionPage(
|
||||
'Help Center — Skinbase',
|
||||
'Find help, guides, quickstarts, FAQs, and troubleshooting for Skinbase Nova, including Groups, Studio, Upload, Cards, Profile, and account access.',
|
||||
'Find help, guides, quickstarts, FAQs, and troubleshooting for Skinbase, including Groups, Studio, Upload, Cards, Profile, and account access.',
|
||||
$canonical,
|
||||
)
|
||||
->toArray();
|
||||
@@ -27,7 +27,7 @@ final class HelpCenterPageController extends Controller
|
||||
|
||||
return Inertia::render('Help/HelpCenterPage', [
|
||||
'title' => 'Help Center',
|
||||
'description' => 'Find guides, quickstarts, FAQs, and troubleshooting for Skinbase Nova in one structured help hub.',
|
||||
'description' => 'Find guides, quickstarts, FAQs, and troubleshooting for Skinbase in one structured help hub.',
|
||||
'seo' => $seo,
|
||||
'links' => [
|
||||
'studio_help' => route('help.studio'),
|
||||
|
||||
@@ -60,12 +60,12 @@ class NovaCardsController extends Controller
|
||||
|
||||
return view('cards.index', [
|
||||
'meta' => [
|
||||
'title' => 'Nova Cards - Skinbase Nova',
|
||||
'description' => 'Browse featured, trending, and latest Nova Cards. Discover beautiful quote cards, mood cards, and visual text art by the Skinbase Nova community.',
|
||||
'title' => 'Cards - Skinbase',
|
||||
'description' => 'Browse featured, trending, and latest Cards. Discover beautiful quote cards, mood cards, and visual text art by the Skinbase community.',
|
||||
'canonical' => route('cards.index'),
|
||||
'robots' => 'index,follow',
|
||||
],
|
||||
'heading' => 'Nova Cards',
|
||||
'heading' => 'Cards',
|
||||
'subheading' => (string) config('nova_cards.brand.subtitle'),
|
||||
'cards' => $this->presenter->cards($latest->items()),
|
||||
'pagination' => $latest,
|
||||
@@ -90,13 +90,13 @@ class NovaCardsController extends Controller
|
||||
|
||||
return view('cards.index', [
|
||||
'meta' => [
|
||||
'title' => $category->name . ' Cards - Skinbase Nova',
|
||||
'description' => $category->description ?: ('Browse ' . strtolower($category->name) . ' Nova Cards on Skinbase Nova.'),
|
||||
'title' => $category->name . ' Cards - Skinbase',
|
||||
'description' => $category->description ?: ('Browse ' . strtolower($category->name) . ' Cards on Skinbase.'),
|
||||
'canonical' => route('cards.category', ['categorySlug' => $category->slug]),
|
||||
'robots' => 'index,follow',
|
||||
],
|
||||
'heading' => $category->name,
|
||||
'subheading' => $category->description ?: 'Explore this Nova Cards category.',
|
||||
'subheading' => $category->description ?: 'Explore this Cards category.',
|
||||
'cards' => $this->presenter->cards($cards->items()),
|
||||
'pagination' => $cards,
|
||||
'featuredCards' => [],
|
||||
@@ -119,8 +119,8 @@ class NovaCardsController extends Controller
|
||||
|
||||
return view('cards.index', [
|
||||
'meta' => [
|
||||
'title' => 'Popular Cards - Skinbase Nova',
|
||||
'description' => 'Browse the most liked, saved, and viewed Nova Cards on Skinbase Nova.',
|
||||
'title' => 'Popular Cards - Skinbase',
|
||||
'description' => 'Browse the most liked, saved, and viewed Cards on Skinbase.',
|
||||
'canonical' => route('cards.popular'),
|
||||
'robots' => 'index,follow',
|
||||
],
|
||||
@@ -153,13 +153,13 @@ class NovaCardsController extends Controller
|
||||
|
||||
return view('cards.index', [
|
||||
'meta' => [
|
||||
'title' => 'Rising Cards - Skinbase Nova',
|
||||
'description' => 'Discover Nova Cards that are gaining traction right now — fresh creators and fast-rising saves and remixes.',
|
||||
'title' => 'Rising Cards - Skinbase',
|
||||
'description' => 'Discover Cards that are gaining traction right now — fresh creators and fast-rising saves and remixes.',
|
||||
'canonical' => route('cards.rising'),
|
||||
'robots' => 'index,follow',
|
||||
],
|
||||
'heading' => 'Rising',
|
||||
'subheading' => 'Fresh Nova Cards gaining momentum right now.',
|
||||
'subheading' => 'Fresh Cards gaining momentum right now.',
|
||||
'cards' => $this->presenter->cards($paginated->items(), false, $request->user()),
|
||||
'pagination' => $paginated,
|
||||
'featuredCards' => [],
|
||||
@@ -182,13 +182,13 @@ class NovaCardsController extends Controller
|
||||
|
||||
return view('cards.index', [
|
||||
'meta' => [
|
||||
'title' => 'Remixed Cards - Skinbase Nova',
|
||||
'description' => 'Discover Nova Cards remixed from community originals with attribution and lineage.',
|
||||
'title' => 'Remixed Cards - Skinbase',
|
||||
'description' => 'Discover Cards remixed from community originals with attribution and lineage.',
|
||||
'canonical' => route('cards.remixed'),
|
||||
'robots' => 'index,follow',
|
||||
],
|
||||
'heading' => 'Remixed cards',
|
||||
'subheading' => 'Community reinterpretations linked back to their original Nova Cards.',
|
||||
'subheading' => 'Community reinterpretations linked back to their original Cards.',
|
||||
'cards' => $this->presenter->cards($cards->items(), false, $request->user()),
|
||||
'pagination' => $cards,
|
||||
'featuredCards' => [],
|
||||
@@ -214,8 +214,8 @@ class NovaCardsController extends Controller
|
||||
|
||||
return view('cards.index', [
|
||||
'meta' => [
|
||||
'title' => 'Best Remixes - Skinbase Nova',
|
||||
'description' => 'Browse standout Nova Card remixes ranked by remix traction, saves, and likes.',
|
||||
'title' => 'Best Remixes - Skinbase',
|
||||
'description' => 'Browse standout Card remixes ranked by remix traction, saves, and likes.',
|
||||
'canonical' => route('cards.remix-highlights'),
|
||||
'robots' => 'index,follow',
|
||||
],
|
||||
@@ -295,13 +295,13 @@ class NovaCardsController extends Controller
|
||||
|
||||
return view('cards.index', [
|
||||
'meta' => [
|
||||
'title' => 'Editorial Picks - Nova Cards - Skinbase Nova',
|
||||
'description' => 'Browse editorial Nova Cards picks, featured collections, and highlighted challenges.',
|
||||
'title' => 'Editorial Picks - Cards - Skinbase',
|
||||
'description' => 'Browse editorial Cards picks, featured collections, and highlighted challenges.',
|
||||
'canonical' => route('cards.editorial'),
|
||||
'robots' => 'index,follow',
|
||||
],
|
||||
'heading' => 'Editorial picks',
|
||||
'subheading' => 'Curated Nova Cards, featured collections, and standout challenge surfaces chosen for quality and cohesion.',
|
||||
'subheading' => 'Curated Cards, featured collections, and standout challenge surfaces chosen for quality and cohesion.',
|
||||
'cards' => $this->presenter->cards($cards->items(), false, $request->user()),
|
||||
'pagination' => $cards,
|
||||
'featuredCards' => [],
|
||||
@@ -329,13 +329,13 @@ class NovaCardsController extends Controller
|
||||
|
||||
return view('cards.index', [
|
||||
'meta' => [
|
||||
'title' => 'Seasonal Cards - Nova Cards - Skinbase Nova',
|
||||
'description' => 'Browse seasonal and event-aware Nova Cards grouped by recurring moods, holidays, and time-of-year themes.',
|
||||
'title' => 'Seasonal Cards - Cards - Skinbase',
|
||||
'description' => 'Browse seasonal and event-aware Cards grouped by recurring moods, holidays, and time-of-year themes.',
|
||||
'canonical' => route('cards.seasonal'),
|
||||
'robots' => 'index,follow',
|
||||
],
|
||||
'heading' => 'Seasonal cards',
|
||||
'subheading' => 'Discover Nova Cards grouped by recurring seasonal and campaign-style themes.',
|
||||
'subheading' => 'Discover Cards grouped by recurring seasonal and campaign-style themes.',
|
||||
'cards' => $this->presenter->cards($cards->items(), false, $request->user()),
|
||||
'pagination' => $cards,
|
||||
'featuredCards' => [],
|
||||
@@ -363,13 +363,13 @@ class NovaCardsController extends Controller
|
||||
|
||||
return view('cards.challenges', [
|
||||
'meta' => [
|
||||
'title' => 'Card Challenges - Skinbase Nova',
|
||||
'description' => 'Browse active and completed Nova Cards challenges, prompts, and winners.',
|
||||
'title' => 'Card Challenges - Skinbase',
|
||||
'description' => 'Browse active and completed Cards challenges, prompts, and winners.',
|
||||
'canonical' => route('cards.challenges'),
|
||||
'robots' => 'index,follow',
|
||||
],
|
||||
'heading' => 'Card challenges',
|
||||
'subheading' => 'Official prompts and community challenge runs for Nova Cards creators.',
|
||||
'subheading' => 'Official prompts and community challenge runs for Cards creators.',
|
||||
'challenges' => $challenges,
|
||||
]);
|
||||
}
|
||||
@@ -388,8 +388,8 @@ class NovaCardsController extends Controller
|
||||
|
||||
return view('cards.challenges', [
|
||||
'meta' => [
|
||||
'title' => $challenge->title . ' - Skinbase Nova',
|
||||
'description' => $challenge->description ?: 'Browse entries for this Nova Cards challenge.',
|
||||
'title' => $challenge->title . ' - Skinbase',
|
||||
'description' => $challenge->description ?: 'Browse entries for this Cards challenge.',
|
||||
'canonical' => route('cards.challenges.show', ['slug' => $challenge->slug]),
|
||||
'robots' => 'index,follow',
|
||||
],
|
||||
@@ -410,8 +410,8 @@ class NovaCardsController extends Controller
|
||||
{
|
||||
return view('cards.resources', [
|
||||
'meta' => [
|
||||
'title' => 'Template Packs - Skinbase Nova',
|
||||
'description' => 'Browse official Nova Cards template packs and starting points.',
|
||||
'title' => 'Template Packs - Skinbase',
|
||||
'description' => 'Browse official Cards template packs and starting points.',
|
||||
'canonical' => route('cards.templates'),
|
||||
'robots' => 'index,follow',
|
||||
],
|
||||
@@ -427,13 +427,13 @@ class NovaCardsController extends Controller
|
||||
{
|
||||
return view('cards.resources', [
|
||||
'meta' => [
|
||||
'title' => 'Asset Packs - Skinbase Nova',
|
||||
'description' => 'Browse official Nova Cards asset packs for decorative and editorial layouts.',
|
||||
'title' => 'Asset Packs - Skinbase',
|
||||
'description' => 'Browse official Cards asset packs for decorative and editorial layouts.',
|
||||
'canonical' => route('cards.assets'),
|
||||
'robots' => 'index,follow',
|
||||
],
|
||||
'heading' => 'Asset packs',
|
||||
'subheading' => 'Official decorative and editorial pack sets for the Nova Cards v2 editor.',
|
||||
'subheading' => 'Official decorative and editorial pack sets for the Cards v2 editor.',
|
||||
'packs' => collect($this->presenter->options()['asset_packs'] ?? []),
|
||||
'templates' => collect(),
|
||||
'resourceType' => 'asset',
|
||||
@@ -447,8 +447,8 @@ class NovaCardsController extends Controller
|
||||
|
||||
return view('cards.index', [
|
||||
'meta' => [
|
||||
'title' => '#' . $tag->name . ' Cards - Skinbase Nova',
|
||||
'description' => 'Browse Nova Cards tagged with #' . $tag->name . ' on Skinbase Nova.',
|
||||
'title' => '#' . $tag->name . ' Cards - Skinbase',
|
||||
'description' => 'Browse Cards tagged with #' . $tag->name . ' on Skinbase.',
|
||||
'canonical' => route('cards.tag', ['tagSlug' => $tag->slug]),
|
||||
'robots' => 'index,follow',
|
||||
],
|
||||
@@ -480,13 +480,13 @@ class NovaCardsController extends Controller
|
||||
|
||||
return view('cards.index', [
|
||||
'meta' => [
|
||||
'title' => $mood['label'] . ' Mood Cards - Skinbase Nova',
|
||||
'description' => 'Browse Nova Cards grouped into the ' . strtolower((string) $mood['label']) . ' mood family on Skinbase Nova.',
|
||||
'title' => $mood['label'] . ' Mood Cards - Skinbase',
|
||||
'description' => 'Browse Cards grouped into the ' . strtolower((string) $mood['label']) . ' mood family on Skinbase.',
|
||||
'canonical' => route('cards.mood', ['moodSlug' => $mood['key']]),
|
||||
'robots' => 'index,follow',
|
||||
],
|
||||
'heading' => $mood['label'],
|
||||
'subheading' => 'Discover Nova Cards grouped by a curated mood family using durable tag mappings.',
|
||||
'subheading' => 'Discover Cards grouped by a curated mood family using durable tag mappings.',
|
||||
'cards' => $this->presenter->cards($cards->items(), false, $request->user()),
|
||||
'pagination' => $cards,
|
||||
'featuredCards' => [],
|
||||
@@ -514,13 +514,13 @@ class NovaCardsController extends Controller
|
||||
|
||||
return view('cards.index', [
|
||||
'meta' => [
|
||||
'title' => $style['label'] . ' Style Cards - Skinbase Nova',
|
||||
'description' => 'Browse Nova Cards using the ' . strtolower((string) $style['label']) . ' style family on Skinbase Nova.',
|
||||
'title' => $style['label'] . ' Style Cards - Skinbase',
|
||||
'description' => 'Browse Cards using the ' . strtolower((string) $style['label']) . ' style family on Skinbase.',
|
||||
'canonical' => route('cards.style', ['styleSlug' => $style['key']]),
|
||||
'robots' => 'index,follow',
|
||||
],
|
||||
'heading' => $style['label'],
|
||||
'subheading' => 'Discover Nova Cards grouped by a shared visual style family.',
|
||||
'subheading' => 'Discover Cards grouped by a shared visual style family.',
|
||||
'cards' => $this->presenter->cards($cards->items(), false, $request->user()),
|
||||
'pagination' => $cards,
|
||||
'featuredCards' => [],
|
||||
@@ -548,13 +548,13 @@ class NovaCardsController extends Controller
|
||||
|
||||
return view('cards.index', [
|
||||
'meta' => [
|
||||
'title' => $palette['label'] . ' Palette Cards - Skinbase Nova',
|
||||
'description' => 'Browse Nova Cards using the ' . strtolower((string) $palette['label']) . ' palette family on Skinbase Nova.',
|
||||
'title' => $palette['label'] . ' Palette Cards - Skinbase',
|
||||
'description' => 'Browse Cards using the ' . strtolower((string) $palette['label']) . ' palette family on Skinbase.',
|
||||
'canonical' => route('cards.palette', ['paletteSlug' => $palette['key']]),
|
||||
'robots' => 'index,follow',
|
||||
],
|
||||
'heading' => $palette['label'],
|
||||
'subheading' => 'Discover Nova Cards grouped by shared palette families and color direction.',
|
||||
'subheading' => 'Discover Cards grouped by shared palette families and color direction.',
|
||||
'cards' => $this->presenter->cards($cards->items(), false, $request->user()),
|
||||
'pagination' => $cards,
|
||||
'featuredCards' => [],
|
||||
@@ -580,8 +580,8 @@ class NovaCardsController extends Controller
|
||||
|
||||
return view('cards.index', array_merge($this->creatorPagePayload($request, $user), [
|
||||
'meta' => [
|
||||
'title' => '@' . $user->username . ' Cards - Skinbase Nova',
|
||||
'description' => 'Browse Nova Cards created by @' . $user->username . ' on Skinbase Nova.',
|
||||
'title' => '@' . $user->username . ' Cards - Skinbase',
|
||||
'description' => 'Browse Cards created by @' . $user->username . ' on Skinbase.',
|
||||
'canonical' => route('cards.creator', ['username' => strtolower((string) $user->username)]),
|
||||
'robots' => 'index,follow',
|
||||
],
|
||||
@@ -602,13 +602,13 @@ class NovaCardsController extends Controller
|
||||
|
||||
return view('cards.index', array_merge($this->creatorPagePayload($request, $user), [
|
||||
'meta' => [
|
||||
'title' => '@' . $user->username . ' Portfolio - Skinbase Nova',
|
||||
'description' => 'Browse the dedicated Nova Cards portfolio page for @' . $user->username . ' on Skinbase Nova.',
|
||||
'title' => '@' . $user->username . ' Portfolio - Skinbase',
|
||||
'description' => 'Browse the dedicated Cards portfolio page for @' . $user->username . ' on Skinbase.',
|
||||
'canonical' => route('cards.creator.portfolio', ['username' => strtolower((string) $user->username)]),
|
||||
'robots' => 'index,follow',
|
||||
],
|
||||
'heading' => '@' . $user->username . ' Portfolio',
|
||||
'subheading' => 'A dedicated Nova Cards portfolio view for ' . ($user->name ?: '@' . $user->username) . '.',
|
||||
'subheading' => 'A dedicated Cards portfolio view for ' . ($user->name ?: '@' . $user->username) . '.',
|
||||
'context' => 'creator-portfolio',
|
||||
]));
|
||||
}
|
||||
@@ -695,8 +695,8 @@ class NovaCardsController extends Controller
|
||||
|
||||
return view('cards.collection', [
|
||||
'meta' => [
|
||||
'title' => $collection->name . ' - Nova Cards Collection - Skinbase Nova',
|
||||
'description' => $collection->description ?: 'Browse this curated Nova Cards collection.',
|
||||
'title' => $collection->name . ' - Cards Collection - Skinbase',
|
||||
'description' => $collection->description ?: 'Browse this curated Cards collection.',
|
||||
'canonical' => route('cards.collections.show', ['slug' => $collection->slug, 'id' => $collection->id]),
|
||||
'robots' => 'index,follow',
|
||||
],
|
||||
@@ -721,7 +721,7 @@ class NovaCardsController extends Controller
|
||||
|
||||
return view('cards.lineage', [
|
||||
'meta' => [
|
||||
'title' => $card->title . ' Lineage - Nova Cards - Skinbase Nova',
|
||||
'title' => $card->title . ' Lineage - Cards - Skinbase',
|
||||
'description' => 'Browse the remix lineage and related variants for this Nova Card.',
|
||||
'canonical' => route('cards.lineage', ['slug' => $card->slug, 'id' => $card->id]),
|
||||
'robots' => 'index,follow',
|
||||
@@ -767,7 +767,7 @@ class NovaCardsController extends Controller
|
||||
return view('cards.show', [
|
||||
'card' => $this->presenter->card($card, true, $request->user()),
|
||||
'meta' => [
|
||||
'title' => $card->title . ' - Nova Cards - Skinbase Nova',
|
||||
'title' => $card->title . ' - Cards - Skinbase',
|
||||
'description' => $card->description ?: $card->quote_text,
|
||||
'canonical' => route('cards.show', ['slug' => $card->slug, 'id' => $card->id]),
|
||||
'robots' => $card->visibility === NovaCard::VISIBILITY_PUBLIC ? 'index,follow' : 'noindex,follow',
|
||||
|
||||
@@ -18,7 +18,7 @@ final class ProfileHelpPageController extends Controller
|
||||
$seo = app(SeoFactory::class)
|
||||
->collectionPage(
|
||||
'Profile Help — Skinbase',
|
||||
'Learn how profiles work on Skinbase Nova, how they differ from Groups, and how to build a stronger personal identity with better setup, presentation, and creator-facing profile habits.',
|
||||
'Learn how profiles work on Skinbase, how they differ from Groups, and how to build a stronger personal identity with better setup, presentation, and creator-facing profile habits.',
|
||||
$canonical,
|
||||
)
|
||||
->toArray();
|
||||
|
||||
@@ -18,7 +18,7 @@ final class StudioHelpPageController extends Controller
|
||||
$seo = app(SeoFactory::class)
|
||||
->collectionPage(
|
||||
'Studio Help — Skinbase',
|
||||
'Learn how Studio works on Skinbase Nova, including drafts, publishing, personal versus Group context, artworks, cards, collections, and collaboration workflows.',
|
||||
'Learn how Studio works on Skinbase, including drafts, publishing, personal versus Group context, artworks, cards, collections, and collaboration workflows.',
|
||||
$canonical,
|
||||
)
|
||||
->toArray();
|
||||
@@ -27,7 +27,7 @@ final class StudioHelpPageController extends Controller
|
||||
|
||||
return Inertia::render('Help/StudioHelpPage', [
|
||||
'title' => 'Studio Help',
|
||||
'description' => 'Understand Studio as the creative control center of Skinbase Nova, with guidance for drafts, publishing, artworks, cards, collections, and Group workflows.',
|
||||
'description' => 'Understand Studio as the creative control center of Skinbase, with guidance for drafts, publishing, artworks, cards, collections, and Group workflows.',
|
||||
'seo' => $seo,
|
||||
'links' => [
|
||||
'help_home' => route('help'),
|
||||
|
||||
@@ -18,7 +18,7 @@ final class TroubleshootingHelpPageController extends Controller
|
||||
$seo = app(SeoFactory::class)
|
||||
->collectionPage(
|
||||
'Troubleshooting Help — Skinbase',
|
||||
'Use fast, support-oriented troubleshooting guidance for login issues, permissions confusion, publishing blockers, profile setup problems, and bug-report escalation on Skinbase Nova.',
|
||||
'Use fast, support-oriented troubleshooting guidance for login issues, permissions confusion, publishing blockers, profile setup problems, and bug-report escalation on Skinbase.',
|
||||
$canonical,
|
||||
)
|
||||
->toArray();
|
||||
|
||||
@@ -18,7 +18,7 @@ final class UploadHelpPageController extends Controller
|
||||
$seo = app(SeoFactory::class)
|
||||
->collectionPage(
|
||||
'Upload Help — Skinbase',
|
||||
'Learn how uploading works on Skinbase Nova, including draft creation, metadata review, previews, personal versus Group context, contributor credit, publishing, and troubleshooting.',
|
||||
'Learn how uploading works on Skinbase, including draft creation, metadata review, previews, personal versus Group context, contributor credit, publishing, and troubleshooting.',
|
||||
$canonical,
|
||||
)
|
||||
->toArray();
|
||||
@@ -27,7 +27,7 @@ final class UploadHelpPageController extends Controller
|
||||
|
||||
return Inertia::render('Help/UploadHelpPage', [
|
||||
'title' => 'Upload Help',
|
||||
'description' => 'Understand the full upload workflow on Skinbase Nova, from file submission and draft creation to metadata review, contributor credit, and final publish.',
|
||||
'description' => 'Understand the full upload workflow on Skinbase, from file submission and draft creation to metadata review, contributor credit, and final publish.',
|
||||
'seo' => $seo,
|
||||
'links' => [
|
||||
'help_home' => route('help'),
|
||||
|
||||
@@ -22,7 +22,7 @@ final class WorldController extends Controller
|
||||
{
|
||||
$payload = $this->worlds->publicIndexPayload($request->user());
|
||||
$seo = app(SeoFactory::class)->collectionListing(
|
||||
'Worlds — Skinbase Nova',
|
||||
'Worlds — Skinbase',
|
||||
$payload['description'],
|
||||
route('worlds.index'),
|
||||
)->toArray();
|
||||
@@ -45,8 +45,8 @@ final class WorldController extends Controller
|
||||
|
||||
$payload = $this->worlds->publicShowPayload($resolvedWorld, $request->user());
|
||||
$seo = app(SeoFactory::class)->collectionPage(
|
||||
$resolvedWorld->seo_title ?: ($resolvedWorld->title . ' — Skinbase Nova'),
|
||||
$resolvedWorld->seo_description ?: ($resolvedWorld->summary ?: $resolvedWorld->description ?: 'Seasonal and editorial discovery world on Skinbase Nova.'),
|
||||
$resolvedWorld->seo_title ?: ($resolvedWorld->title . ' — Skinbase'),
|
||||
$resolvedWorld->seo_description ?: ($resolvedWorld->summary ?: $resolvedWorld->description ?: 'Seasonal and editorial discovery world on Skinbase.'),
|
||||
$this->worlds->canonicalPublicUrl($resolvedWorld),
|
||||
$resolvedWorld->ogImageUrl(),
|
||||
)->toArray();
|
||||
@@ -69,8 +69,8 @@ final class WorldController extends Controller
|
||||
|
||||
$payload = $this->worlds->publicShowPayload($resolvedWorld, $request->user());
|
||||
$seo = app(SeoFactory::class)->collectionPage(
|
||||
$resolvedWorld->seo_title ?: ($resolvedWorld->title . ' — Skinbase Nova'),
|
||||
$resolvedWorld->seo_description ?: ($resolvedWorld->summary ?: $resolvedWorld->description ?: 'Seasonal and editorial discovery world on Skinbase Nova.'),
|
||||
$resolvedWorld->seo_title ?: ($resolvedWorld->title . ' — Skinbase'),
|
||||
$resolvedWorld->seo_description ?: ($resolvedWorld->summary ?: $resolvedWorld->description ?: 'Seasonal and editorial discovery world on Skinbase.'),
|
||||
$this->worlds->canonicalPublicUrl($resolvedWorld),
|
||||
$resolvedWorld->ogImageUrl(),
|
||||
)->toArray();
|
||||
|
||||
@@ -18,7 +18,7 @@ final class WorldsHelpPageController extends Controller
|
||||
$seo = app(SeoFactory::class)
|
||||
->collectionPage(
|
||||
'Worlds Help — Skinbase',
|
||||
'Learn how Worlds work on Skinbase Nova, including editorial purpose, attached content, section control, preview, publishing, recurrence, and homepage promotion.',
|
||||
'Learn how Worlds work on Skinbase, including editorial purpose, attached content, section control, preview, publishing, recurrence, and homepage promotion.',
|
||||
$canonical,
|
||||
)
|
||||
->toArray();
|
||||
@@ -27,7 +27,7 @@ final class WorldsHelpPageController extends Controller
|
||||
|
||||
return Inertia::render('Help/WorldsHelpPage', [
|
||||
'title' => 'Worlds Help',
|
||||
'description' => 'A complete guide to creating, attaching content to, previewing, and publishing Worlds on Skinbase Nova.',
|
||||
'description' => 'A complete guide to creating, attaching content to, previewing, and publishing Worlds on Skinbase.',
|
||||
'seo' => $seo,
|
||||
'links' => [
|
||||
'help_home' => route('help'),
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\ViewErrorBag;
|
||||
use Illuminate\View\Middleware\ShareErrorsFromSession;
|
||||
|
||||
class ConditionalShareErrorsFromSession extends ShareErrorsFromSession
|
||||
@@ -17,6 +18,8 @@ class ConditionalShareErrorsFromSession extends ShareErrorsFromSession
|
||||
}
|
||||
|
||||
if ($request->attributes->get('skinbase.session_skipped') === true || ! $request->hasSession()) {
|
||||
$this->view->share('errors', new ViewErrorBag());
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
|
||||
23
app/Http/Middleware/EnsureAdminRole.php
Normal file
23
app/Http/Middleware/EnsureAdminRole.php
Normal file
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
final class EnsureAdminRole
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
if (! $user || ! $user->isAdmin()) {
|
||||
abort(Response::HTTP_FORBIDDEN, 'Only admins can access this area.');
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,7 @@ final class EnsureStaffAccess
|
||||
abort(Response::HTTP_FORBIDDEN, 'Forbidden.');
|
||||
}
|
||||
|
||||
return redirect()->route('home')->with('error', 'You do not have access to this area.');
|
||||
return redirect()->route('index')->with('error', 'You do not have access to this area.');
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
|
||||
@@ -22,30 +22,48 @@ class RedirectLegacyProfileSubdomain
|
||||
return redirect()->to($this->targetUrl($request, $canonicalUsername), 301);
|
||||
}
|
||||
|
||||
if ($this->shouldRedirectToCanonicalHost($request)) {
|
||||
return redirect()->to($this->canonicalHostUrl($request), 301);
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
private function resolveCanonicalUsername(Request $request): ?string
|
||||
private function shouldRedirectToCanonicalHost(Request $request): bool
|
||||
{
|
||||
return $this->isSingleSubdomainOnConfiguredHost($request);
|
||||
}
|
||||
|
||||
private function isSingleSubdomainOnConfiguredHost(Request $request): bool
|
||||
{
|
||||
$configuredHost = parse_url((string) config('app.url'), PHP_URL_HOST);
|
||||
|
||||
if (! is_string($configuredHost) || $configuredHost === '') {
|
||||
return null;
|
||||
return false;
|
||||
}
|
||||
|
||||
$requestHost = strtolower($request->getHost());
|
||||
$configuredHost = strtolower($configuredHost);
|
||||
|
||||
if ($requestHost === $configuredHost || ! str_ends_with($requestHost, '.' . $configuredHost)) {
|
||||
return null;
|
||||
return false;
|
||||
}
|
||||
|
||||
$subdomain = substr($requestHost, 0, -strlen('.' . $configuredHost));
|
||||
|
||||
if ($subdomain === '' || str_contains($subdomain, '.')) {
|
||||
return $subdomain !== '' && ! str_contains($subdomain, '.');
|
||||
}
|
||||
|
||||
private function resolveCanonicalUsername(Request $request): ?string
|
||||
{
|
||||
if (! $this->isSingleSubdomainOnConfiguredHost($request)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$configuredHost = strtolower((string) parse_url((string) config('app.url'), PHP_URL_HOST));
|
||||
$requestHost = strtolower($request->getHost());
|
||||
$subdomain = substr($requestHost, 0, -strlen('.' . $configuredHost));
|
||||
|
||||
$candidate = UsernamePolicy::normalize($subdomain);
|
||||
|
||||
if ($candidate === '' || $this->isReservedSubdomain($candidate)) {
|
||||
@@ -103,4 +121,16 @@ class RedirectLegacyProfileSubdomain
|
||||
|
||||
return $target;
|
||||
}
|
||||
|
||||
private function canonicalHostUrl(Request $request): string
|
||||
{
|
||||
$target = rtrim((string) config('app.url'), '/') . $request->getPathInfo();
|
||||
$query = $request->getQueryString();
|
||||
|
||||
if (is_string($query) && $query !== '') {
|
||||
$target .= '?' . $query;
|
||||
}
|
||||
|
||||
return $target;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
namespace App\Http\Requests\Auth;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\AuthAuditLogger;
|
||||
use Illuminate\Auth\Events\Lockout;
|
||||
use Illuminate\Contracts\Validation\Validator;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
@@ -68,6 +70,16 @@ class LoginRequest extends FormRequest
|
||||
if (! $user || ! Hash::check($password, (string) $user->password)) {
|
||||
RateLimiter::hit($this->throttleKey());
|
||||
|
||||
app(AuthAuditLogger::class)->log(
|
||||
eventType: 'login',
|
||||
request: $this,
|
||||
status: 'failed',
|
||||
reason: 'invalid_credentials',
|
||||
identifier: $identifier,
|
||||
user: $user,
|
||||
metadata: ['via' => $authenticatedVia]
|
||||
);
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'email' => trans('auth.failed'),
|
||||
]);
|
||||
@@ -90,6 +102,20 @@ class LoginRequest extends FormRequest
|
||||
return $this->authenticatedVia === 'username';
|
||||
}
|
||||
|
||||
protected function failedValidation(Validator $validator): void
|
||||
{
|
||||
app(AuthAuditLogger::class)->log(
|
||||
eventType: 'login',
|
||||
request: $this,
|
||||
status: 'failed',
|
||||
reason: 'validation_failed',
|
||||
identifier: (string) $this->input('email'),
|
||||
metadata: ['fields' => array_keys($validator->errors()->toArray())]
|
||||
);
|
||||
|
||||
parent::failedValidation($validator);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the login request is not rate limited.
|
||||
*
|
||||
@@ -105,6 +131,15 @@ class LoginRequest extends FormRequest
|
||||
|
||||
$seconds = RateLimiter::availableIn($this->throttleKey());
|
||||
|
||||
app(AuthAuditLogger::class)->log(
|
||||
eventType: 'login',
|
||||
request: $this,
|
||||
status: 'failed',
|
||||
reason: 'rate_limited',
|
||||
identifier: (string) $this->input('email'),
|
||||
metadata: ['seconds' => $seconds]
|
||||
);
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'email' => trans('auth.throttle', [
|
||||
'seconds' => $seconds,
|
||||
|
||||
@@ -3,6 +3,7 @@ namespace App\Http\Resources;
|
||||
|
||||
use App\Models\WorldRelation;
|
||||
use App\Models\WorldSubmission;
|
||||
use App\Models\World;
|
||||
use App\Services\ArtworkEvolutionService;
|
||||
use App\Services\ContentSanitizer;
|
||||
use App\Services\Maturity\ArtworkMaturityService;
|
||||
@@ -336,16 +337,21 @@ class ArtworkResource extends JsonResource
|
||||
private function resolveWorldParticipation(): array
|
||||
{
|
||||
$items = collect();
|
||||
$participationWorlds = collect();
|
||||
|
||||
if (Schema::hasTable('world_relations') && Schema::hasTable('worlds')) {
|
||||
$items = $items->concat(
|
||||
WorldRelation::query()
|
||||
$relations = WorldRelation::query()
|
||||
->with('world')
|
||||
->where('related_type', WorldRelation::TYPE_ARTWORK)
|
||||
->where('related_id', (int) $this->id)
|
||||
->get()
|
||||
->filter(fn (WorldRelation $relation): bool => $relation->world !== null && $relation->world->isPubliclyVisible())
|
||||
->map(function (WorldRelation $relation): array {
|
||||
->values();
|
||||
|
||||
$participationWorlds = $participationWorlds->concat($relations->pluck('world')->filter());
|
||||
|
||||
$items = $items->concat(
|
||||
$relations->map(function (WorldRelation $relation): array {
|
||||
$world = $relation->world;
|
||||
|
||||
return [
|
||||
@@ -364,14 +370,19 @@ class ArtworkResource extends JsonResource
|
||||
}
|
||||
|
||||
if (Schema::hasTable('world_submissions')) {
|
||||
$items = $items->concat(
|
||||
$this->worldSubmissions
|
||||
$liveSubmissions = $this->worldSubmissions
|
||||
->filter(function (WorldSubmission $submission): bool {
|
||||
return (string) $submission->status === WorldSubmission::STATUS_LIVE
|
||||
&& $submission->world !== null
|
||||
&& $submission->world->isPubliclyVisible();
|
||||
})
|
||||
->map(function (WorldSubmission $submission): array {
|
||||
->values();
|
||||
|
||||
$participationWorlds = $participationWorlds->concat($liveSubmissions->pluck('world')->filter());
|
||||
World::primeCanonicalEditionIds($participationWorlds->pluck('recurrence_key')->all());
|
||||
|
||||
$items = $items->concat(
|
||||
$liveSubmissions->map(function (WorldSubmission $submission): array {
|
||||
$world = $submission->world;
|
||||
$isFeatured = (bool) $submission->is_featured;
|
||||
|
||||
@@ -388,6 +399,8 @@ class ArtworkResource extends JsonResource
|
||||
];
|
||||
})
|
||||
);
|
||||
} elseif ($participationWorlds->isNotEmpty()) {
|
||||
World::primeCanonicalEditionIds($participationWorlds->pluck('recurrence_key')->all());
|
||||
}
|
||||
|
||||
if (Schema::hasTable('world_reward_grants')) {
|
||||
|
||||
@@ -48,7 +48,7 @@ final class GenerateArtworkEmbeddingJob implements ShouldQueue
|
||||
public function handle(
|
||||
ArtworkEmbeddingClient $client,
|
||||
ArtworkVisionImageUrl $imageUrlBuilder,
|
||||
VectorService|ArtworkVectorIndexService $vectors,
|
||||
ArtworkVectorIndexService $vectors,
|
||||
): void
|
||||
{
|
||||
if (! (bool) config('recommendations.embedding.enabled', true)) {
|
||||
@@ -128,7 +128,7 @@ final class GenerateArtworkEmbeddingJob implements ShouldQueue
|
||||
}
|
||||
|
||||
private function upsertVectorIndex(
|
||||
VectorService|ArtworkVectorIndexService $vectors,
|
||||
ArtworkVectorIndexService $vectors,
|
||||
Artwork $artwork
|
||||
): void
|
||||
{
|
||||
|
||||
@@ -91,9 +91,9 @@ final class IngestUserDiscoveryEventJob implements ShouldQueue
|
||||
];
|
||||
|
||||
if (Schema::hasColumn('user_discovery_events', 'meta')) {
|
||||
$insertPayload['meta'] = $this->meta;
|
||||
$insertPayload['meta'] = $this->encodeMetaPayload();
|
||||
} elseif (Schema::hasColumn('user_discovery_events', 'metadata')) {
|
||||
$insertPayload['metadata'] = json_encode($this->meta, JSON_UNESCAPED_SLASHES);
|
||||
$insertPayload['metadata'] = $this->encodeMetaPayload();
|
||||
}
|
||||
|
||||
DB::table('user_discovery_events')->insertOrIgnore($insertPayload);
|
||||
@@ -129,4 +129,12 @@ final class IngestUserDiscoveryEventJob implements ShouldQueue
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
private function encodeMetaPayload(): string
|
||||
{
|
||||
return (string) json_encode(
|
||||
$this->meta,
|
||||
JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE | JSON_THROW_ON_ERROR
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,20 +28,16 @@ class RegistrationVerificationMail extends Mailable implements ShouldQueue
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
return new Envelope(
|
||||
subject: 'Verify your Skinbase email',
|
||||
subject: 'Welcome to Skinbase — confirm your email',
|
||||
);
|
||||
}
|
||||
|
||||
public function content(): Content
|
||||
{
|
||||
$appUrl = rtrim((string) config('app.url', 'http://localhost'), '/');
|
||||
|
||||
return new Content(
|
||||
view: 'emails.registration-verification',
|
||||
with: [
|
||||
'verificationUrl' => url('/verify/'.$this->token),
|
||||
'expiresInHours' => max(1, (int) config('registration.verify_token_ttl_hours', 24)),
|
||||
'supportUrl' => $appUrl . '/support',
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
35
app/Models/AuthAuditLog.php
Normal file
35
app/Models/AuthAuditLog.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class AuthAuditLog extends Model
|
||||
{
|
||||
protected $table = 'auth_audit_logs';
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $fillable = [
|
||||
'event_type',
|
||||
'identifier',
|
||||
'user_id',
|
||||
'ip',
|
||||
'user_agent',
|
||||
'status',
|
||||
'reason',
|
||||
'metadata',
|
||||
'created_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'metadata' => 'array',
|
||||
'created_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
@@ -546,6 +546,32 @@ class World extends Model
|
||||
return static::canonicalEditionIdForRecurrence((string) $this->recurrence_key) === (int) $this->id;
|
||||
}
|
||||
|
||||
public static function primeCanonicalEditionIds(iterable $recurrenceKeys): void
|
||||
{
|
||||
$keys = collect($recurrenceKeys)
|
||||
->map(static fn ($key): string => trim((string) $key))
|
||||
->filter()
|
||||
->unique()
|
||||
->reject(static fn (string $key): bool => array_key_exists($key, static::$canonicalRecurrenceEditionIds))
|
||||
->values();
|
||||
|
||||
if ($keys->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$editionsByRecurrence = static::query()
|
||||
->publiclyVisible()
|
||||
->whereIn('recurrence_key', $keys->all())
|
||||
->get()
|
||||
->groupBy('recurrence_key');
|
||||
|
||||
foreach ($keys as $key) {
|
||||
$canonical = static::selectCanonicalEdition(new EloquentCollection($editionsByRecurrence->get($key, collect())->all()));
|
||||
|
||||
static::$canonicalRecurrenceEditionIds[$key] = $canonical ? (int) $canonical->id : null;
|
||||
}
|
||||
}
|
||||
|
||||
public function sectionOrder(): array
|
||||
{
|
||||
$defaults = array_values(array_filter(config('worlds.default_section_order', []), 'is_string'));
|
||||
|
||||
@@ -25,7 +25,7 @@ final class AiBiographyPromptBuilder
|
||||
private const MIN_WORDS = 30;
|
||||
|
||||
private const SYSTEM_PROMPT = <<<'PROMPT'
|
||||
You are a concise writing assistant for Skinbase Nova, a digital art platform.
|
||||
You are a concise writing assistant for Skinbase, a digital art platform.
|
||||
|
||||
Write short creator biographies using only the facts provided. Use a polished, factual, and slightly editorial tone.
|
||||
|
||||
@@ -44,7 +44,7 @@ Rules:
|
||||
PROMPT;
|
||||
|
||||
private const SYSTEM_PROMPT_STRICT = <<<'PROMPT'
|
||||
You are a cautious writing assistant for Skinbase Nova, a digital art platform.
|
||||
You are a cautious writing assistant for Skinbase, a digital art platform.
|
||||
|
||||
Write a short, safe creator biography using only the facts provided. Be conservative.
|
||||
|
||||
@@ -59,7 +59,7 @@ Rules:
|
||||
PROMPT;
|
||||
|
||||
private const SYSTEM_PROMPT_SPARSE = <<<'PROMPT'
|
||||
You are a cautious writing assistant for Skinbase Nova, a digital art platform.
|
||||
You are a cautious writing assistant for Skinbase, a digital art platform.
|
||||
|
||||
Write a short, modest creator introduction using only the facts provided.
|
||||
|
||||
@@ -75,7 +75,7 @@ Rules:
|
||||
PROMPT;
|
||||
|
||||
private const SYSTEM_PROMPT_SPARSE_STRICT = <<<'PROMPT'
|
||||
You are a cautious writing assistant for Skinbase Nova, a digital art platform.
|
||||
You are a cautious writing assistant for Skinbase, a digital art platform.
|
||||
|
||||
Write a short, modest creator introduction using only the facts provided. Be conservative and precise.
|
||||
|
||||
|
||||
42
app/Services/Auth/AuthAuditLogger.php
Normal file
42
app/Services/Auth/AuthAuditLogger.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Auth;
|
||||
|
||||
use App\Models\AuthAuditLog;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class AuthAuditLogger
|
||||
{
|
||||
public function log(
|
||||
string $eventType,
|
||||
?Request $request,
|
||||
string $status,
|
||||
?string $reason = null,
|
||||
?string $identifier = null,
|
||||
User|int|null $user = null,
|
||||
array $metadata = [],
|
||||
): AuthAuditLog {
|
||||
$userId = $user instanceof User ? $user->getKey() : $user;
|
||||
$cleanMetadata = array_filter($metadata, static fn (mixed $value): bool => $value !== null && $value !== '');
|
||||
|
||||
return AuthAuditLog::query()->create([
|
||||
'event_type' => $eventType,
|
||||
'identifier' => $this->normalizeIdentifier($identifier),
|
||||
'user_id' => $userId,
|
||||
'ip' => $request?->ip(),
|
||||
'user_agent' => $request?->userAgent(),
|
||||
'status' => $status,
|
||||
'reason' => $reason,
|
||||
'metadata' => $cleanMetadata === [] ? null : $cleanMetadata,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
private function normalizeIdentifier(?string $identifier): ?string
|
||||
{
|
||||
$identifier = trim((string) $identifier);
|
||||
|
||||
return $identifier === '' ? null : mb_strtolower($identifier);
|
||||
}
|
||||
}
|
||||
@@ -75,7 +75,7 @@ class CollectionAiCurationService
|
||||
);
|
||||
|
||||
$seo = sprintf(
|
||||
'%s on Skinbase Nova: %d curated artworks%s.',
|
||||
'%s on Skinbase: %d curated artworks%s.',
|
||||
$this->draftString($collection, $draft, 'title') ?: $collection->title,
|
||||
$context['artworks_count'],
|
||||
$context['theme_sentence'] !== '' ? ' exploring ' . $context['theme_sentence'] : ''
|
||||
|
||||
@@ -142,7 +142,24 @@ class HomepageAnnouncementService
|
||||
return $backgroundImage;
|
||||
}
|
||||
|
||||
return Storage::disk('public')->url($backgroundImage);
|
||||
$disk = $this->backgroundImageDisk();
|
||||
$configuredBaseUrl = trim((string) config('filesystems.disks.' . $disk . '.url', ''), '/');
|
||||
|
||||
if ($configuredBaseUrl !== '') {
|
||||
return $configuredBaseUrl . '/' . ltrim($backgroundImage, '/');
|
||||
}
|
||||
|
||||
return Storage::disk($disk)->url($backgroundImage);
|
||||
}
|
||||
|
||||
public function backgroundImageDisk(): string
|
||||
{
|
||||
return (string) config('homepage.announcements.background_image.disk', config('uploads.object_storage.disk', 's3'));
|
||||
}
|
||||
|
||||
public function backgroundImagePrefix(): string
|
||||
{
|
||||
return trim((string) config('homepage.announcements.background_image.prefix', 'homepage-announcements'), '/');
|
||||
}
|
||||
|
||||
private function artworkUrl(int $artworkId): ?string
|
||||
|
||||
@@ -238,7 +238,7 @@ final class ArtworkSquareThumbnailBackfillService
|
||||
'timeout' => 30,
|
||||
'ignore_errors' => true,
|
||||
'header' => implode("\r\n", [
|
||||
'User-Agent: Skinbase Nova square-thumb backfill',
|
||||
'User-Agent: Skinbase square-thumb backfill',
|
||||
'Accept: image/*,*/*;q=0.8',
|
||||
'Accept-Encoding: identity',
|
||||
'Connection: close',
|
||||
|
||||
@@ -9,7 +9,7 @@ use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* ArtworkRankingService — Skinbase Nova Ranking Engine V2
|
||||
* ArtworkRankingService — Skinbase Ranking Engine V2
|
||||
*
|
||||
* Intelligent scoring system combining:
|
||||
* 1. Base engagement (views, downloads, favourites, comments, shares)
|
||||
|
||||
@@ -11,7 +11,7 @@ use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* RankingService — Skinbase Nova rank_v2
|
||||
* RankingService — Skinbase rank_v2
|
||||
*
|
||||
* Responsibilities:
|
||||
* 1. Score computation — turn raw artwork signals into three float scores.
|
||||
|
||||
@@ -33,7 +33,7 @@ final class GoogleNewsSitemapBuilder extends AbstractSitemapBuilder
|
||||
route('news.show', ['slug' => $article->slug]),
|
||||
trim((string) $article->title),
|
||||
$article->published_at,
|
||||
(string) \config('sitemaps.news.google_publication_name', 'Skinbase Nova'),
|
||||
(string) \config('sitemaps.news.google_publication_name', 'Skinbase'),
|
||||
(string) \config('sitemaps.news.google_language', 'en'),
|
||||
);
|
||||
})
|
||||
|
||||
@@ -92,7 +92,7 @@ final class SitemapCacheService
|
||||
{
|
||||
$prefix = trim((string) config('sitemaps.pre_generated.path', 'generated-sitemaps'), '/');
|
||||
$segments = $name === self::INDEX_DOCUMENT
|
||||
? [$prefix, 'sitemap.xml']
|
||||
? [$prefix, 'sitemaps', 'sitemap.xml']
|
||||
: [$prefix, 'sitemaps', $name . '.xml'];
|
||||
|
||||
return implode('/', array_values(array_filter($segments, static fn (string $segment): bool => $segment !== '')));
|
||||
|
||||
@@ -127,7 +127,7 @@ final class SitemapReleaseManager
|
||||
public function documentRelativePath(string $documentName): string
|
||||
{
|
||||
return $documentName === SitemapCacheService::INDEX_DOCUMENT
|
||||
? 'sitemap.xml'
|
||||
? 'sitemaps/sitemap.xml'
|
||||
: 'sitemaps/' . $documentName . '.xml';
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ final class StudioAiCategoryMapper
|
||||
$tokens = $this->tokenize($signals);
|
||||
$haystack = ' ' . implode(' ', $tokens) . ' ';
|
||||
|
||||
$contentTypes = ContentType::query()->with(['rootCategories.children'])->ordered()->get();
|
||||
$contentTypes = ContentType::query()->with(['rootCategories.children.parent'])->ordered()->get();
|
||||
$contentTypeScores = $contentTypes
|
||||
->map(fn (ContentType $contentType): array => $this->scoreContentType($contentType, $tokens, $haystack))
|
||||
->filter(fn (array $row): bool => $row['score'] > 0)
|
||||
|
||||
@@ -343,12 +343,22 @@ final class WorldRewardService
|
||||
|
||||
public function artworkRewardBadges(Artwork $artwork): array
|
||||
{
|
||||
return WorldRewardGrant::query()
|
||||
$grants = WorldRewardGrant::query()
|
||||
->with('world')
|
||||
->where('artwork_id', (int) $artwork->id)
|
||||
->orderByRaw($this->sortCaseSql())
|
||||
->orderByDesc('granted_at')
|
||||
->get()
|
||||
->values();
|
||||
|
||||
World::primeCanonicalEditionIds(
|
||||
$grants->pluck('world')
|
||||
->filter()
|
||||
->pluck('recurrence_key')
|
||||
->all()
|
||||
);
|
||||
|
||||
return $grants
|
||||
->map(function (WorldRewardGrant $grant): array {
|
||||
$world = $grant->world;
|
||||
$rewardType = $grant->reward_type;
|
||||
|
||||
@@ -1019,7 +1019,7 @@ final class WorldService
|
||||
'title' => (string) $world->title,
|
||||
'campaign_label' => (string) ($world->campaign_label ?: 'Live now'),
|
||||
'status_label' => $this->campaignStateLabel($world),
|
||||
'url' => $world->publicUrl(),
|
||||
'url' => $this->publicPathForWorld($world),
|
||||
];
|
||||
});
|
||||
}
|
||||
@@ -2532,6 +2532,25 @@ final class WorldService
|
||||
return route('worlds.show', ['world' => $recurrenceKey]);
|
||||
}
|
||||
|
||||
private function publicPathForWorld(World $world): string
|
||||
{
|
||||
$recurrenceKey = trim((string) ($world->recurrence_key ?? ''));
|
||||
|
||||
if (! $world->is_recurring || $recurrenceKey === '') {
|
||||
return route('worlds.show', ['world' => $world->slug], false);
|
||||
}
|
||||
|
||||
if ($this->isCanonicalSurfaceWorld($world)) {
|
||||
return route('worlds.show', ['world' => $recurrenceKey], false);
|
||||
}
|
||||
|
||||
if ($world->edition_year !== null) {
|
||||
return route('worlds.editions.show', ['world' => $recurrenceKey, 'year' => $world->edition_year], false);
|
||||
}
|
||||
|
||||
return route('worlds.show', ['world' => $recurrenceKey], false);
|
||||
}
|
||||
|
||||
private function familyUrlForWorld(World $world): ?string
|
||||
{
|
||||
$recurrenceKey = trim((string) ($world->recurrence_key ?? ''));
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
use App\Http\Middleware\ConditionalShareErrorsFromSession;
|
||||
use App\Http\Middleware\ConditionalStartSession;
|
||||
use App\Http\Middleware\ConditionalValidateCsrfToken;
|
||||
use App\Http\Middleware\EnsureAdminRole;
|
||||
use Illuminate\Auth\AuthenticationException;
|
||||
use Illuminate\Session\Middleware\StartSession;
|
||||
use Illuminate\Foundation\Http\Middleware\ValidateCsrfToken;
|
||||
@@ -47,6 +48,7 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
'artwork.maturity.access' => \App\Http\Middleware\EnsureArtworkMaturityAccess::class,
|
||||
'admin.moderation' => \App\Http\Middleware\EnsureAdminOrModerator::class,
|
||||
'admin.access' => \App\Http\Middleware\EnsureStaffAccess::class,
|
||||
'admin.role' => EnsureAdminRole::class,
|
||||
'creator.access' => \App\Http\Middleware\EnsureCreatorAccess::class,
|
||||
'ensure.email.login.upgrade'=> \App\Http\Middleware\EnsureEmailLoginUpgradeComplete::class,
|
||||
'ensure.onboarding.complete'=> \App\Http\Middleware\EnsureOnboardingComplete::class,
|
||||
|
||||
@@ -1997,6 +1997,7 @@
|
||||
"resources/js/Layouts/StudioLayout.jsx": [],
|
||||
"resources/js/Pages/Admin/AiBiography.jsx": [],
|
||||
"resources/js/Pages/Admin/Artworks.jsx": [],
|
||||
"resources/js/Pages/Admin/AuthAudit.jsx": [],
|
||||
"resources/js/Pages/Admin/Dashboard.jsx": [],
|
||||
"resources/js/Pages/Admin/FeaturedArtworks.jsx": [],
|
||||
"resources/js/Pages/Admin/HomepageAnnouncements/Form.jsx": [],
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -4,4 +4,10 @@ return [
|
||||
'cache_store' => env('HOMEPAGE_CACHE_STORE', 'homepage'),
|
||||
'guest_payload_key' => env('HOMEPAGE_GUEST_PAYLOAD_KEY', 'homepage.payload.guest'),
|
||||
'guest_payload_ttl_seconds' => (int) env('HOMEPAGE_GUEST_PAYLOAD_TTL_SECONDS', 1800),
|
||||
'announcements' => [
|
||||
'background_image' => [
|
||||
'disk' => env('HOMEPAGE_ANNOUNCEMENTS_BACKGROUND_DISK', env('ARTWORKS_OBJECT_DISK', 's3')),
|
||||
'prefix' => trim((string) env('HOMEPAGE_ANNOUNCEMENTS_BACKGROUND_PREFIX', 'homepage-announcements'), '/'),
|
||||
],
|
||||
],
|
||||
];
|
||||
@@ -3,7 +3,7 @@
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Ranking system configuration — Skinbase Nova rank_v1
|
||||
* Ranking system configuration — Skinbase rank_v1
|
||||
*
|
||||
* All weights, half-lives, and thresholds are tunable here.
|
||||
* Increment model_version when changing weights so caches expire gracefully.
|
||||
|
||||
@@ -107,6 +107,8 @@ return [
|
||||
'ws.skinbase.org',
|
||||
'skinbase.top',
|
||||
'www.skinbase.top',
|
||||
'skinbase.si',
|
||||
'www.skinbase.si',
|
||||
'skinbase26.test'
|
||||
],
|
||||
'ping_interval' => env('REVERB_APP_PING_INTERVAL', 60),
|
||||
|
||||
@@ -75,7 +75,7 @@ return [
|
||||
'news' => [
|
||||
'google_variant_enabled' => (bool) env('SITEMAPS_NEWS_GOOGLE_VARIANT', true),
|
||||
'google_variant_name' => 'news-google',
|
||||
'google_publication_name' => env('SITEMAPS_NEWS_GOOGLE_PUBLICATION', env('APP_NAME', 'Skinbase Nova')),
|
||||
'google_publication_name' => env('SITEMAPS_NEWS_GOOGLE_PUBLICATION', env('APP_NAME', 'Skinbase')),
|
||||
'google_language' => env('SITEMAPS_NEWS_GOOGLE_LANGUAGE', env('APP_LOCALE', 'en')),
|
||||
'google_lookback_hours' => (int) env('SITEMAPS_NEWS_GOOGLE_LOOKBACK_HOURS', 48),
|
||||
'google_max_items' => (int) env('SITEMAPS_NEWS_GOOGLE_MAX_ITEMS', 1000),
|
||||
|
||||
@@ -126,6 +126,11 @@ return new class extends Migration
|
||||
*/
|
||||
private function indexExists(string $table, string $indexName): bool
|
||||
{
|
||||
if (DB::getDriverName() === 'sqlite') {
|
||||
return collect(DB::select("PRAGMA index_list('{$table}')"))
|
||||
->contains(static fn (object $row): bool => ($row->name ?? null) === $indexName);
|
||||
}
|
||||
|
||||
return count(DB::select(
|
||||
"SHOW INDEX FROM `{$table}` WHERE Key_name = ?",
|
||||
[$indexName]
|
||||
|
||||
@@ -15,6 +15,10 @@ return new class extends Migration
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
if (Schema::getConnection()->getDriverName() === 'sqlite') {
|
||||
return;
|
||||
}
|
||||
|
||||
Schema::table('artworks', function (Blueprint $table) {
|
||||
$table->fullText(['title', 'description'], 'artworks_title_description_fulltext');
|
||||
});
|
||||
@@ -22,6 +26,10 @@ return new class extends Migration
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if (Schema::getConnection()->getDriverName() === 'sqlite') {
|
||||
return;
|
||||
}
|
||||
|
||||
Schema::table('artworks', function (Blueprint $table) {
|
||||
$table->dropFullText('artworks_title_description_fulltext');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration {
|
||||
public function up(): void
|
||||
{
|
||||
if (Schema::hasTable('auth_audit_logs')) {
|
||||
return;
|
||||
}
|
||||
|
||||
Schema::create('auth_audit_logs', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->string('event_type', 64);
|
||||
$table->string('identifier')->nullable();
|
||||
$table->foreignId('user_id')->nullable()->constrained('users')->nullOnDelete();
|
||||
$table->string('ip', 45)->nullable();
|
||||
$table->text('user_agent')->nullable();
|
||||
$table->string('status', 32);
|
||||
$table->string('reason', 64)->nullable();
|
||||
$table->json('metadata')->nullable();
|
||||
$table->timestamp('created_at')->useCurrent();
|
||||
|
||||
$table->index('event_type');
|
||||
$table->index('identifier');
|
||||
$table->index('user_id');
|
||||
$table->index('ip');
|
||||
$table->index(['event_type', 'status']);
|
||||
$table->index('created_at');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('auth_audit_logs');
|
||||
}
|
||||
};
|
||||
@@ -19,16 +19,16 @@ final class HomepageAnnouncementLaunchSeeder extends Seeder
|
||||
[
|
||||
'placement' => HomepageAnnouncement::PLACEMENT_HOMEPAGE_AFTER_FEATURED,
|
||||
'type' => HomepageAnnouncement::TYPE_LAUNCH,
|
||||
'title' => 'Skinbase Nova is live.',
|
||||
'title' => 'Skinbase is live.',
|
||||
],
|
||||
[
|
||||
'subtitle' => 'A new chapter for the Skinbase creative community.',
|
||||
'badge_text' => 'Launch Day · 1 May 2026',
|
||||
'content_html' => implode("\n", [
|
||||
'<p><strong>Today, 1 May 2026, Skinbase begins a new chapter.</strong></p>',
|
||||
'<p>Skinbase Nova is a modern reboot of our creative community for digital art, wallpapers, skins, photography, customization, and discovery.</p>',
|
||||
'<p>Skinbase is a modern reboot of our creative community for digital art, wallpapers, skins, photography, customization, and discovery.</p>',
|
||||
'<p>We are bringing the spirit of classic Skinbase into a faster, cleaner, and more modern experience — built for creators, fans, and the future.</p>',
|
||||
'<p>Welcome to <strong>Skinbase Nova</strong>.</p>',
|
||||
'<p>Welcome to <strong>Skinbase</strong>.</p>',
|
||||
]),
|
||||
'status' => HomepageAnnouncement::STATUS_PUBLISHED,
|
||||
'is_active' => true,
|
||||
|
||||
@@ -54,11 +54,11 @@ final class NewsLaunchSeeder extends Seeder
|
||||
$articles = [
|
||||
[
|
||||
'slug' => 'welcome-to-skinbase-nova',
|
||||
'title' => 'Welcome to Skinbase Nova',
|
||||
'title' => 'Welcome to Skinbase',
|
||||
'type' => NewsArticle::TYPE_PLATFORM_UPDATE,
|
||||
'category' => $categories['platform'],
|
||||
'excerpt' => 'A first look at the refreshed Skinbase experience and the editorial direction behind Nova.',
|
||||
'content' => "# Welcome to Skinbase Nova\n\nSkinbase Nova brings publishing, discovery, Groups, and editorial storytelling into a single platform experience.\n\n## What is new\n\n- a dedicated newsroom\n- stronger creator identity surfaces\n- deeper internal linking across Groups, releases, and profiles\n- cleaner editorial publishing tools inside Studio\n\nNova is designed to feel active, curated, and connected to the people making the work.",
|
||||
'content' => "# Welcome to Skinbase\n\nSkinbase brings publishing, discovery, Groups, and editorial storytelling into a single platform experience.\n\n## What is new\n\n- a dedicated newsroom\n- stronger creator identity surfaces\n- deeper internal linking across Groups, releases, and profiles\n- cleaner editorial publishing tools inside Studio\n\nNova is designed to feel active, curated, and connected to the people making the work.",
|
||||
'tags' => [$tags['nova'], $tags['platform-update']],
|
||||
'days_ago' => 10,
|
||||
'featured' => true,
|
||||
|
||||
@@ -32,7 +32,7 @@ class NovaCardDemoSeeder extends Seeder
|
||||
['email' => (string) Arr::get($userConfig, 'email', 'nova-cards-demo@skinbase.test')],
|
||||
[
|
||||
'username' => (string) Arr::get($userConfig, 'username', 'nova.cards'),
|
||||
'name' => (string) Arr::get($userConfig, 'name', 'Nova Cards'),
|
||||
'name' => (string) Arr::get($userConfig, 'name', 'Cards'),
|
||||
'password' => (string) Arr::get($userConfig, 'password', 'password'),
|
||||
'role' => 'user',
|
||||
]
|
||||
@@ -43,9 +43,9 @@ class NovaCardDemoSeeder extends Seeder
|
||||
'slug' => 'official-spark',
|
||||
'title' => 'Official Spark',
|
||||
'quote_text' => 'Small moments of focus turn into visible momentum.',
|
||||
'quote_author' => 'Skinbase Nova',
|
||||
'quote_author' => 'Skinbase',
|
||||
'quote_source' => 'Launch Collection',
|
||||
'description' => 'An official Nova Cards demo card for featured browse surfaces.',
|
||||
'description' => 'An official Cards demo card for featured browse surfaces.',
|
||||
'category_slug' => 'motivation',
|
||||
'template_slug' => 'neon-nova',
|
||||
'format' => NovaCard::FORMAT_SQUARE,
|
||||
@@ -61,9 +61,9 @@ class NovaCardDemoSeeder extends Seeder
|
||||
'slug' => 'soft-breath',
|
||||
'title' => 'Soft Breath',
|
||||
'quote_text' => 'Rest is not a pause from growth. It is part of it.',
|
||||
'quote_author' => 'Skinbase Nova',
|
||||
'quote_author' => 'Skinbase',
|
||||
'quote_source' => 'Healing Notes',
|
||||
'description' => 'A calm demo card showing the softer side of Nova Cards.',
|
||||
'description' => 'A calm demo card showing the softer side of Cards.',
|
||||
'category_slug' => 'healing',
|
||||
'template_slug' => 'soft-pastel',
|
||||
'format' => NovaCard::FORMAT_PORTRAIT,
|
||||
@@ -79,7 +79,7 @@ class NovaCardDemoSeeder extends Seeder
|
||||
'slug' => 'night-echo',
|
||||
'title' => 'Night Echo',
|
||||
'quote_text' => 'Not every quiet room is empty. Some are full of answers.',
|
||||
'quote_author' => 'Skinbase Nova',
|
||||
'quote_author' => 'Skinbase',
|
||||
'quote_source' => 'Dark Mood Study',
|
||||
'description' => 'A darker official demo card for mood-oriented discovery blocks.',
|
||||
'category_slug' => 'dark-mood',
|
||||
@@ -97,7 +97,7 @@ class NovaCardDemoSeeder extends Seeder
|
||||
'slug' => 'editorial-glow',
|
||||
'title' => 'Editorial Glow',
|
||||
'quote_text' => 'Design with restraint, then let one accent do the speaking.',
|
||||
'quote_author' => 'Skinbase Nova',
|
||||
'quote_author' => 'Skinbase',
|
||||
'quote_source' => 'Editorial Kit',
|
||||
'description' => 'A crisp editorial-format demo card for official collections.',
|
||||
'category_slug' => 'motivation',
|
||||
@@ -115,7 +115,7 @@ class NovaCardDemoSeeder extends Seeder
|
||||
'slug' => 'story-bloom',
|
||||
'title' => 'Story Bloom',
|
||||
'quote_text' => 'If the layout breathes, the words can reach further.',
|
||||
'quote_author' => 'Skinbase Nova',
|
||||
'quote_author' => 'Skinbase',
|
||||
'quote_source' => 'Story Vertical Pack',
|
||||
'description' => 'A vertical story-oriented demo card for public browsing and challenges.',
|
||||
'category_slug' => 'healing',
|
||||
@@ -133,7 +133,7 @@ class NovaCardDemoSeeder extends Seeder
|
||||
'slug' => 'remix-launch-variant',
|
||||
'title' => 'Remix Launch Variant',
|
||||
'quote_text' => 'Take the spark and give it a new rhythm.',
|
||||
'quote_author' => 'Skinbase Nova',
|
||||
'quote_author' => 'Skinbase',
|
||||
'quote_source' => 'Remix Lab',
|
||||
'description' => 'A seeded remix showing lineage in demo content.',
|
||||
'category_slug' => 'motivation',
|
||||
@@ -262,7 +262,7 @@ class NovaCardDemoSeeder extends Seeder
|
||||
['user_id' => $user->id, 'slug' => 'editorial-favorites'],
|
||||
[
|
||||
'name' => 'Editorial Favorites',
|
||||
'description' => 'Officially curated Nova Cards spotlighting launch visuals, remixes, and story-first layouts.',
|
||||
'description' => 'Officially curated Cards spotlighting launch visuals, remixes, and story-first layouts.',
|
||||
'visibility' => NovaCardCollection::VISIBILITY_PUBLIC,
|
||||
'official' => true,
|
||||
'featured' => true,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# ---------------------------------------------------------------------------
|
||||
# Nginx upstream / gateway error pages for Skinbase Nova
|
||||
# Nginx upstream / gateway error pages for Skinbase
|
||||
# ---------------------------------------------------------------------------
|
||||
# Purpose:
|
||||
# Serve a Nova-styled static HTML page for nginx-level upstream failures such
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# AI Biography
|
||||
|
||||
AI Biography is the Skinbase Nova feature that generates short, grounded creator biographies from public profile data. It is designed to be conservative: it prefers a safe, concise summary over a flashy or speculative one.
|
||||
AI Biography is the Skinbase feature that generates short, grounded creator biographies from public profile data. It is designed to be conservative: it prefers a safe, concise summary over a flashy or speculative one.
|
||||
|
||||
This document explains how the feature works, what commands are available, where output is stored, and where users can see it.
|
||||
|
||||
|
||||
@@ -195,7 +195,7 @@ To enable it on production:
|
||||
2. Add `fastcgi_intercept_errors on;` to every FastCGI location that should use the static fallback.
|
||||
3. Keep the static file available in the live public path so nginx can serve it without Laravel.
|
||||
|
||||
On the current `skinbase.top` vhost, the required FastCGI locations are:
|
||||
On the current `skinbase.org` vhost, the required FastCGI locations are:
|
||||
|
||||
- `location ^~ /api/uploads/`
|
||||
- `location = /index.php`
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Realtime Messaging
|
||||
|
||||
Skinbase Nova messaging now uses Laravel Reverb, Laravel Broadcasting, Laravel Echo, Redis-backed queues, and Laravel Horizon for queue visibility.
|
||||
Skinbase messaging now uses Laravel Reverb, Laravel Broadcasting, Laravel Echo, Redis-backed queues, and Laravel Horizon for queue visibility.
|
||||
|
||||
## v2 capabilities
|
||||
|
||||
|
||||
BIN
new_passwords.7z
Normal file
BIN
new_passwords.7z
Normal file
Binary file not shown.
BIN
projekti_2026_skinbase.7z
Normal file
BIN
projekti_2026_skinbase.7z
Normal file
Binary file not shown.
51
public/sitemap.xml
Normal file
51
public/sitemap.xml
Normal file
@@ -0,0 +1,51 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?><sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
<sitemap>
|
||||
<loc>http://skinbase26.test/sitemaps/artworks-index.xml</loc>
|
||||
<lastmod>2026-04-17T11:36:53+00:00</lastmod>
|
||||
</sitemap>
|
||||
<sitemap>
|
||||
<loc>http://skinbase26.test/sitemaps/users.xml</loc>
|
||||
<lastmod>2026-04-25T11:51:06+00:00</lastmod>
|
||||
</sitemap>
|
||||
<sitemap>
|
||||
<loc>http://skinbase26.test/sitemaps/tags.xml</loc>
|
||||
<lastmod>2026-04-17T11:38:50+00:00</lastmod>
|
||||
</sitemap>
|
||||
<sitemap>
|
||||
<loc>http://skinbase26.test/sitemaps/categories.xml</loc>
|
||||
<lastmod>2026-04-11T06:46:51+00:00</lastmod>
|
||||
</sitemap>
|
||||
<sitemap>
|
||||
<loc>http://skinbase26.test/sitemaps/collections.xml</loc>
|
||||
<lastmod>2026-04-09T18:59:35+00:00</lastmod>
|
||||
</sitemap>
|
||||
<sitemap>
|
||||
<loc>http://skinbase26.test/sitemaps/cards.xml</loc>
|
||||
<lastmod>2026-03-31T13:55:44+00:00</lastmod>
|
||||
</sitemap>
|
||||
<sitemap>
|
||||
<loc>http://skinbase26.test/sitemaps/stories.xml</loc>
|
||||
<lastmod>2026-04-09T12:27:18+00:00</lastmod>
|
||||
</sitemap>
|
||||
<sitemap>
|
||||
<loc>http://skinbase26.test/sitemaps/news.xml</loc>
|
||||
<lastmod>2026-04-10T19:54:16+00:00</lastmod>
|
||||
</sitemap>
|
||||
<sitemap>
|
||||
<loc>http://skinbase26.test/sitemaps/news-google.xml</loc>
|
||||
</sitemap>
|
||||
<sitemap>
|
||||
<loc>http://skinbase26.test/sitemaps/forum-index.xml</loc>
|
||||
</sitemap>
|
||||
<sitemap>
|
||||
<loc>http://skinbase26.test/sitemaps/forum-categories.xml</loc>
|
||||
<lastmod>2026-03-08T18:41:25+00:00</lastmod>
|
||||
</sitemap>
|
||||
<sitemap>
|
||||
<loc>http://skinbase26.test/sitemaps/forum-threads.xml</loc>
|
||||
<lastmod>2026-04-17T11:36:54+00:00</lastmod>
|
||||
</sitemap>
|
||||
<sitemap>
|
||||
<loc>http://skinbase26.test/sitemaps/static-pages.xml</loc>
|
||||
</sitemap>
|
||||
</sitemapindex>
|
||||
80002
public/sitemaps/artworks-0001.xml
Normal file
80002
public/sitemaps/artworks-0001.xml
Normal file
File diff suppressed because it is too large
Load Diff
79996
public/sitemaps/artworks-0002.xml
Normal file
79996
public/sitemaps/artworks-0002.xml
Normal file
File diff suppressed because it is too large
Load Diff
79983
public/sitemaps/artworks-0003.xml
Normal file
79983
public/sitemaps/artworks-0003.xml
Normal file
File diff suppressed because it is too large
Load Diff
79853
public/sitemaps/artworks-0004.xml
Normal file
79853
public/sitemaps/artworks-0004.xml
Normal file
File diff suppressed because it is too large
Load Diff
77942
public/sitemaps/artworks-0005.xml
Normal file
77942
public/sitemaps/artworks-0005.xml
Normal file
File diff suppressed because it is too large
Load Diff
17
public/sitemaps/artworks-index.xml
Normal file
17
public/sitemaps/artworks-index.xml
Normal file
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?><sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
<sitemap>
|
||||
<loc>http://skinbase26.test/sitemaps/artworks-0001.xml</loc>
|
||||
</sitemap>
|
||||
<sitemap>
|
||||
<loc>http://skinbase26.test/sitemaps/artworks-0002.xml</loc>
|
||||
</sitemap>
|
||||
<sitemap>
|
||||
<loc>http://skinbase26.test/sitemaps/artworks-0003.xml</loc>
|
||||
</sitemap>
|
||||
<sitemap>
|
||||
<loc>http://skinbase26.test/sitemaps/artworks-0004.xml</loc>
|
||||
</sitemap>
|
||||
<sitemap>
|
||||
<loc>http://skinbase26.test/sitemaps/artworks-0005.xml</loc>
|
||||
</sitemap>
|
||||
</sitemapindex>
|
||||
18
public/sitemaps/cards.xml
Normal file
18
public/sitemaps/cards.xml
Normal file
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1">
|
||||
<url>
|
||||
<loc>http://skinbase26.test/cards/nova-report-card-536447237-18</loc>
|
||||
<lastmod>2026-03-31T13:44:38+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>http://skinbase26.test/cards/nova-report-card-913253507-19</loc>
|
||||
<lastmod>2026-03-31T12:42:55+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>http://skinbase26.test/cards/sem-mali-zajcek-25</loc>
|
||||
<lastmod>2026-03-31T13:55:44+00:00</lastmod>
|
||||
<image:image>
|
||||
<image:loc>https://cdn.skinbase.org/cards/previews/1/2556205a-ce7f-45ef-867b-77f63450bde6-og.jpg</image:loc>
|
||||
<image:title>Sem mali zajček,</image:title>
|
||||
</image:image>
|
||||
</url>
|
||||
</urlset>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user