Save workspace changes
This commit is contained in:
122
app/Http/Controllers/Api/AiBiographyController.php
Normal file
122
app/Http/Controllers/Api/AiBiographyController.php
Normal file
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Jobs\GenerateAiBiographyJob;
|
||||
use App\Models\User;
|
||||
use App\Services\AiBiography\AiBiographyService;
|
||||
use App\Support\UsernamePolicy;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
/**
|
||||
* Creator-facing AI biography endpoints.
|
||||
*
|
||||
* All write routes require the authenticated user to be the profile owner.
|
||||
* Reads are restricted to the authenticated owner (public rendering is handled
|
||||
* via ProfileJourneyController / ProfileApiController payloads).
|
||||
*/
|
||||
final class AiBiographyController extends Controller
|
||||
{
|
||||
public function __construct(private readonly AiBiographyService $biographies)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/creator/profile/ai-biography/generate
|
||||
* Dispatch an async generation job for the authenticated user.
|
||||
*/
|
||||
public function generate(Request $request): JsonResponse
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = Auth::user();
|
||||
|
||||
GenerateAiBiographyJob::dispatch((int) $user->id, false)
|
||||
->onQueue((string) config('ai_biography.queue', 'default'));
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Biography generation queued.',
|
||||
], 202);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/creator/profile/ai-biography/regenerate
|
||||
* Force-regenerate (replaces existing non-user-edited biography).
|
||||
*/
|
||||
public function regenerate(Request $request): JsonResponse
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = Auth::user();
|
||||
|
||||
GenerateAiBiographyJob::dispatch((int) $user->id, true)
|
||||
->onQueue((string) config('ai_biography.queue', 'default'));
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Biography regeneration queued.',
|
||||
], 202);
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/creator/profile/ai-biography
|
||||
* Creator edits their biography text.
|
||||
*/
|
||||
public function update(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'text' => ['required', 'string', 'min:30', 'max:1200'],
|
||||
]);
|
||||
|
||||
/** @var User $user */
|
||||
$user = Auth::user();
|
||||
|
||||
$this->biographies->updateText($user, $validated['text']);
|
||||
|
||||
return response()->json(['message' => 'Biography updated.']);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/creator/profile/ai-biography/hide
|
||||
*/
|
||||
public function hide(Request $request): JsonResponse
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = Auth::user();
|
||||
|
||||
$this->biographies->hide($user);
|
||||
|
||||
return response()->json(['message' => 'Biography hidden.']);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/creator/profile/ai-biography/show
|
||||
*/
|
||||
public function show(Request $request): JsonResponse
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = Auth::user();
|
||||
|
||||
$this->biographies->show($user);
|
||||
|
||||
return response()->json(['message' => 'Biography made visible.']);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/creator/profile/ai-biography
|
||||
* Return the authenticated creator's current biography status and metadata.
|
||||
*/
|
||||
public function status(Request $request): JsonResponse
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = Auth::user();
|
||||
|
||||
$payload = $this->biographies->creatorStatusPayload($user);
|
||||
|
||||
return response()->json([
|
||||
'data' => $payload,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -39,4 +39,11 @@ final class LeaderboardController extends Controller
|
||||
$leaderboards->getLeaderboard(Leaderboard::TYPE_STORY, (string) $request->query('period', 'weekly'))
|
||||
);
|
||||
}
|
||||
|
||||
public function worlds(Request $request, LeaderboardService $leaderboards): JsonResponse
|
||||
{
|
||||
return response()->json(
|
||||
$leaderboards->getLeaderboard(Leaderboard::TYPE_WORLD, (string) $request->query('period', 'weekly'))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
47
app/Http/Controllers/Api/ProfileAiBiographyController.php
Normal file
47
app/Http/Controllers/Api/ProfileAiBiographyController.php
Normal file
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use App\Services\AiBiography\AiBiographyService;
|
||||
use App\Support\UsernamePolicy;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
/**
|
||||
* Public read endpoint for a creator's AI biography.
|
||||
*
|
||||
* GET /api/profile/{username}/ai-biography
|
||||
*
|
||||
* Returns null data if no visible biography exists (hidden, failed, or not yet generated).
|
||||
* Never triggers generation — only serves stored text.
|
||||
*/
|
||||
final class ProfileAiBiographyController extends Controller
|
||||
{
|
||||
public function __construct(private readonly AiBiographyService $biographies)
|
||||
{
|
||||
}
|
||||
|
||||
public function show(string $username): JsonResponse
|
||||
{
|
||||
$normalized = UsernamePolicy::normalize($username);
|
||||
|
||||
$user = User::query()
|
||||
->whereRaw('LOWER(username) = ?', [$normalized])
|
||||
->where('is_active', true)
|
||||
->whereNull('deleted_at')
|
||||
->firstOrFail();
|
||||
|
||||
$payload = $this->biographies->publicPayload($user);
|
||||
|
||||
return response()->json([
|
||||
'data' => $payload,
|
||||
'meta' => [
|
||||
'username' => (string) $user->username,
|
||||
'generated_at' => now()->toIso8601String(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,7 @@ use App\Uploads\Exceptions\DraftQuotaException;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\Group;
|
||||
use App\Services\GroupArtworkReviewService;
|
||||
use App\Services\Worlds\WorldSubmissionService;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
final class UploadController extends Controller
|
||||
@@ -559,7 +560,7 @@ final class UploadController extends Controller
|
||||
], Response::HTTP_OK);
|
||||
}
|
||||
|
||||
public function publish(string $id, Request $request, PublishService $publishService, ArtworkAttributionService $attribution, ArtworkMaturityService $maturity)
|
||||
public function publish(string $id, Request $request, PublishService $publishService, ArtworkAttributionService $attribution, ArtworkMaturityService $maturity, WorldSubmissionService $submissions)
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
@@ -584,6 +585,9 @@ final class UploadController extends Controller
|
||||
'contributor_credits.*.user_id' => ['required', 'integer', 'min:1'],
|
||||
'contributor_credits.*.credit_role' => ['nullable', 'string', 'max:80'],
|
||||
'contributor_credits.*.is_primary' => ['nullable', 'boolean'],
|
||||
'world_submissions' => ['nullable', 'array', 'max:12'],
|
||||
'world_submissions.*.world_id' => ['required', 'integer', 'exists:worlds,id'],
|
||||
'world_submissions.*.note' => ['nullable', 'string', 'max:1000'],
|
||||
]);
|
||||
|
||||
$mode = $validated['mode'] ?? 'now';
|
||||
@@ -660,6 +664,7 @@ final class UploadController extends Controller
|
||||
$artwork->save();
|
||||
$maturity->applyUploaderDeclaration($artwork, (bool) $artwork->is_mature);
|
||||
$artwork = $attribution->apply($artwork->fresh(['group.members']), $user, $validated);
|
||||
$submissions->syncForArtwork($artwork->fresh(), $user, (array) ($validated['world_submissions'] ?? []));
|
||||
|
||||
if ($mode === 'schedule' && $publishAt) {
|
||||
// Scheduled: store publish_at but don't make public yet
|
||||
@@ -754,7 +759,7 @@ final class UploadController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
public function submitForReview(string $id, Request $request, GroupArtworkReviewService $reviews)
|
||||
public function submitForReview(string $id, Request $request, GroupArtworkReviewService $reviews, WorldSubmissionService $submissions)
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
@@ -776,6 +781,9 @@ final class UploadController extends Controller
|
||||
'contributor_credits.*.user_id' => ['required', 'integer', 'min:1'],
|
||||
'contributor_credits.*.credit_role' => ['nullable', 'string', 'max:80'],
|
||||
'contributor_credits.*.is_primary' => ['nullable', 'boolean'],
|
||||
'world_submissions' => ['nullable', 'array', 'max:12'],
|
||||
'world_submissions.*.world_id' => ['required', 'integer', 'exists:worlds,id'],
|
||||
'world_submissions.*.note' => ['nullable', 'string', 'max:1000'],
|
||||
]);
|
||||
|
||||
if (! ctype_digit($id)) {
|
||||
@@ -797,6 +805,7 @@ final class UploadController extends Controller
|
||||
}
|
||||
|
||||
$artwork = $reviews->submit($group, $artwork, $user, $validated);
|
||||
$submissions->syncForArtwork($artwork->fresh(), $user, (array) ($validated['world_submissions'] ?? []));
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
|
||||
@@ -11,6 +11,7 @@ use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Str;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
|
||||
final class ArtworkDownloadController extends Controller
|
||||
@@ -155,10 +156,31 @@ final class ArtworkDownloadController extends Controller
|
||||
$name = 'artwork';
|
||||
}
|
||||
|
||||
if (strtolower((string) pathinfo($name, PATHINFO_EXTENSION)) !== $ext) {
|
||||
$name .= '.' . $ext;
|
||||
$baseName = pathinfo($name, PATHINFO_FILENAME);
|
||||
$baseName = trim((string) $baseName, ". \t\n\r\0\x0B");
|
||||
if ($baseName === '') {
|
||||
$baseName = 'artwork';
|
||||
}
|
||||
|
||||
return $name;
|
||||
$brandSuffix = $this->downloadBrandSuffix();
|
||||
|
||||
if ($brandSuffix !== '' && ! Str::contains(Str::lower($baseName), Str::lower($brandSuffix))) {
|
||||
$baseName .= ' (' . $brandSuffix . ')';
|
||||
}
|
||||
|
||||
return $baseName . '.' . $ext;
|
||||
}
|
||||
|
||||
private function downloadBrandSuffix(): string
|
||||
{
|
||||
$host = (string) parse_url((string) config('app.url'), PHP_URL_HOST);
|
||||
$host = strtolower(trim($host));
|
||||
$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 $host;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +37,16 @@ class AuthenticatedSessionController extends Controller
|
||||
|
||||
$request->session()->regenerate();
|
||||
|
||||
$user = $request->authenticatedUser();
|
||||
if ($user && $request->authenticatedViaUsername() && ! $user->hasCompletedOnboarding()) {
|
||||
$request->session()->put('username_login_upgrade', true);
|
||||
|
||||
return redirect()->route('setup.email.create')
|
||||
->with('status', 'Add and verify your email address to continue setup.');
|
||||
}
|
||||
|
||||
$request->session()->forget('username_login_upgrade');
|
||||
|
||||
return redirect()->intended(route('dashboard'));
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ use App\Services\Auth\DisposableEmailService;
|
||||
use App\Services\Auth\RegistrationVerificationTokenService;
|
||||
use App\Services\Security\CaptchaVerifier;
|
||||
use App\Services\Security\TurnstileVerifier;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
@@ -101,45 +102,47 @@ class RegisteredUserController extends Controller
|
||||
|
||||
$user = User::query()->where('email', $email)->first();
|
||||
|
||||
if ($user && $user->email_verified_at !== null) {
|
||||
$this->logEmailEvent($email, $ip, (int) $user->id, 'blocked', 'already-verified');
|
||||
|
||||
return $this->redirectToRegisterNotice($email);
|
||||
if ($user && $user->hasCompletedOnboarding()) {
|
||||
return back()
|
||||
->withInput($request->except('website'))
|
||||
->withErrors(['email' => 'An account with this email already exists.']);
|
||||
}
|
||||
|
||||
if (! $user) {
|
||||
$user = User::query()->create([
|
||||
$user = new User();
|
||||
$user->forceFill([
|
||||
'username' => null,
|
||||
'name' => Str::before($email, '@'),
|
||||
'email' => $email,
|
||||
'password' => Hash::make(Str::random(64)),
|
||||
'is_active' => false,
|
||||
'onboarding_step' => 'email',
|
||||
'is_active' => true,
|
||||
'email_verified_at' => null,
|
||||
'onboarding_step' => 'verified',
|
||||
'needs_password_reset' => true,
|
||||
'username_changed_at' => now(),
|
||||
'last_username_change_at' => now(),
|
||||
]);
|
||||
$user->save();
|
||||
} else {
|
||||
$user->forceFill([
|
||||
'email_verified_at' => $user->email_verified_at,
|
||||
'is_active' => true,
|
||||
'onboarding_step' => strtolower((string) ($user->onboarding_step ?? '')) === 'password' ? 'password' : 'verified',
|
||||
'needs_password_reset' => strtolower((string) ($user->onboarding_step ?? '')) === 'password'
|
||||
? (bool) $user->needs_password_reset
|
||||
: true,
|
||||
])->save();
|
||||
}
|
||||
|
||||
if ($this->isWithinEmailCooldown($user)) {
|
||||
$this->logEmailEvent($email, $ip, (int) $user->id, 'blocked', 'cooldown');
|
||||
Auth::login($user);
|
||||
|
||||
return $this->redirectToRegisterNotice($email);
|
||||
}
|
||||
$needsPasswordSetup = strtolower((string) ($user->onboarding_step ?? '')) !== 'password'
|
||||
|| (bool) $user->needs_password_reset;
|
||||
|
||||
$token = $this->verificationTokenService->createForUser((int) $user->id);
|
||||
$event = $this->logEmailEvent($email, $ip, (int) $user->id, 'queued', null);
|
||||
|
||||
SendVerificationEmailJob::dispatch(
|
||||
emailEventId: (int) $event->id,
|
||||
email: $email,
|
||||
token: $token,
|
||||
userId: (int) $user->id,
|
||||
ip: $ip
|
||||
);
|
||||
|
||||
$this->markVerificationEmailSent($user);
|
||||
|
||||
return $this->redirectToRegisterNotice($email);
|
||||
return redirect(route($needsPasswordSetup ? 'setup.password.create' : 'setup.username.create', absolute: false))
|
||||
->with('status', $needsPasswordSetup
|
||||
? 'Continue with password setup.'
|
||||
: 'Continue with username setup.');
|
||||
}
|
||||
|
||||
public function resendVerification(Request $request): RedirectResponse
|
||||
|
||||
72
app/Http/Controllers/Auth/SetupEmailController.php
Normal file
72
app/Http/Controllers/Auth/SetupEmailController.php
Normal file
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Mail\EmailChangedSecurityAlertMail;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class SetupEmailController extends Controller
|
||||
{
|
||||
public function create(Request $request): View|RedirectResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
if ($user->hasCompletedOnboarding()) {
|
||||
return redirect()->route('dashboard');
|
||||
}
|
||||
|
||||
return view('auth.setup-email', [
|
||||
'email' => User::isEmailLoginUpgradePlaceholder((string) $user->email)
|
||||
? ''
|
||||
: strtolower(trim((string) $user->email)),
|
||||
]);
|
||||
}
|
||||
|
||||
public function requestCode(Request $request): RedirectResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
$validated = $request->validate([
|
||||
'email' => [
|
||||
'required',
|
||||
'string',
|
||||
'lowercase',
|
||||
'email',
|
||||
'max:255',
|
||||
Rule::unique(User::class, 'email')->ignore((int) $user->id),
|
||||
function (string $attribute, mixed $value, \Closure $fail): void {
|
||||
if (User::isEmailLoginUpgradePlaceholder((string) $value)) {
|
||||
$fail('Please enter a real email address you can access.');
|
||||
}
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
$newEmail = strtolower(trim((string) $validated['email']));
|
||||
$oldEmail = strtolower((string) ($user->email ?? ''));
|
||||
$nextStep = 'password';
|
||||
|
||||
DB::transaction(function () use ($user, $newEmail, &$nextStep): void {
|
||||
$lockedUser = User::query()->whereKey((int) $user->id)->lockForUpdate()->firstOrFail();
|
||||
$nextStep = (bool) $lockedUser->needs_password_reset ? 'verified' : 'password';
|
||||
$lockedUser->email = $newEmail;
|
||||
$lockedUser->email_verified_at = null;
|
||||
$lockedUser->onboarding_step = $nextStep;
|
||||
$lockedUser->save();
|
||||
});
|
||||
|
||||
if ($oldEmail !== '' && $oldEmail !== $newEmail && ! User::isEmailLoginUpgradePlaceholder($oldEmail)) {
|
||||
Mail::to($oldEmail)->queue(new EmailChangedSecurityAlertMail($newEmail));
|
||||
}
|
||||
|
||||
return redirect()->route($nextStep === 'verified' ? 'setup.password.create' : 'setup.username.create')
|
||||
->with('status', $nextStep === 'verified'
|
||||
? 'Email saved. Continue with password setup.'
|
||||
: 'Email saved. Continue with username setup.');
|
||||
}
|
||||
}
|
||||
@@ -90,6 +90,8 @@ class SetupUsernameController extends Controller
|
||||
])->save();
|
||||
});
|
||||
|
||||
$request->session()->forget('username_login_upgrade');
|
||||
|
||||
return redirect('/@' . strtolower($candidate));
|
||||
}
|
||||
}
|
||||
|
||||
367
app/Http/Controllers/Settings/AiBiographyAdminController.php
Normal file
367
app/Http/Controllers/Settings/AiBiographyAdminController.php
Normal file
@@ -0,0 +1,367 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Settings;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\CreatorAiBiography;
|
||||
use App\Models\User;
|
||||
use App\Services\AiBiography\AiBiographyService;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Str;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
final class AiBiographyAdminController extends Controller
|
||||
{
|
||||
private const PER_PAGE = 20;
|
||||
|
||||
public function __construct(private readonly AiBiographyService $biographies)
|
||||
{
|
||||
}
|
||||
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$filters = $this->filters($request);
|
||||
|
||||
$records = $this->recordsQuery($filters)
|
||||
->paginate(self::PER_PAGE)
|
||||
->withQueryString()
|
||||
->through(fn (CreatorAiBiography $record): array => $this->mapRecord($record));
|
||||
|
||||
return Inertia::render('Moderation/AiBiographyAdmin', [
|
||||
'title' => 'AI Biography Review',
|
||||
'records' => $records,
|
||||
'filters' => $filters,
|
||||
'stats' => $this->stats(),
|
||||
'filterOptions' => [
|
||||
'status' => [
|
||||
['value' => 'all', 'label' => 'All statuses'],
|
||||
['value' => CreatorAiBiography::STATUS_GENERATED, 'label' => 'Generated'],
|
||||
['value' => CreatorAiBiography::STATUS_APPROVED, 'label' => 'Approved'],
|
||||
['value' => CreatorAiBiography::STATUS_EDITED, 'label' => 'Edited'],
|
||||
['value' => CreatorAiBiography::STATUS_NEEDS_REVIEW, 'label' => 'Needs review'],
|
||||
['value' => CreatorAiBiography::STATUS_FAILED, 'label' => 'Failed'],
|
||||
['value' => CreatorAiBiography::STATUS_SUPPRESSED, 'label' => 'Suppressed'],
|
||||
],
|
||||
'scope' => [
|
||||
['value' => 'all', 'label' => 'All records'],
|
||||
['value' => 'active', 'label' => 'Active only'],
|
||||
['value' => 'inactive', 'label' => 'Inactive only'],
|
||||
],
|
||||
'tier' => [
|
||||
['value' => 'all', 'label' => 'All tiers'],
|
||||
['value' => CreatorAiBiography::TIER_RICH, 'label' => 'Rich'],
|
||||
['value' => CreatorAiBiography::TIER_MEDIUM, 'label' => 'Medium'],
|
||||
['value' => CreatorAiBiography::TIER_SPARSE, 'label' => 'Sparse'],
|
||||
],
|
||||
'visibility' => [
|
||||
['value' => 'all', 'label' => 'All visibility'],
|
||||
['value' => 'visible', 'label' => 'Visible'],
|
||||
['value' => 'hidden', 'label' => 'Hidden'],
|
||||
],
|
||||
'review' => [
|
||||
['value' => 'all', 'label' => 'All review states'],
|
||||
['value' => 'needs_review', 'label' => 'Needs review'],
|
||||
['value' => 'failed', 'label' => 'Failed / errored'],
|
||||
['value' => 'user_edited', 'label' => 'User edited'],
|
||||
],
|
||||
],
|
||||
'endpoints' => [
|
||||
'index' => route('cp.ai-biography.index'),
|
||||
'rebuildPattern' => route('cp.ai-biography.rebuild', ['user' => '__USER__']),
|
||||
'approvePattern' => route('cp.ai-biography.approve', ['biography' => '__BIOGRAPHY__']),
|
||||
'flagPattern' => route('cp.ai-biography.flag', ['biography' => '__BIOGRAPHY__']),
|
||||
'hidePattern' => route('cp.ai-biography.hide', ['biography' => '__BIOGRAPHY__']),
|
||||
'showPattern' => route('cp.ai-biography.show', ['biography' => '__BIOGRAPHY__']),
|
||||
],
|
||||
])->rootView('moderation');
|
||||
}
|
||||
|
||||
public function rebuild(User $user): JsonResponse
|
||||
{
|
||||
if (! (bool) config('ai_biography.enabled', true)) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'AI Biography generation is currently disabled.',
|
||||
], 409);
|
||||
}
|
||||
|
||||
$active = $this->activeRecordForUser($user);
|
||||
|
||||
if ($active !== null && $active->is_user_edited) {
|
||||
$result = $this->biographies->generate($user, CreatorAiBiography::REASON_ADMIN_BATCH);
|
||||
} elseif ($active !== null) {
|
||||
$result = $this->biographies->regenerate($user, true, CreatorAiBiography::REASON_ADMIN_BATCH);
|
||||
} else {
|
||||
$result = $this->biographies->generate($user, CreatorAiBiography::REASON_ADMIN_BATCH);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => (bool) $result['success'],
|
||||
'message' => $this->rebuildMessage($result),
|
||||
'result' => $result,
|
||||
], $result['success'] ? 200 : 422);
|
||||
}
|
||||
|
||||
public function approve(CreatorAiBiography $biography): JsonResponse
|
||||
{
|
||||
$biography->update([
|
||||
'needs_review' => false,
|
||||
'status' => $biography->is_user_edited
|
||||
? CreatorAiBiography::STATUS_EDITED
|
||||
: CreatorAiBiography::STATUS_APPROVED,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Biography marked as reviewed.',
|
||||
]);
|
||||
}
|
||||
|
||||
public function flag(CreatorAiBiography $biography): JsonResponse
|
||||
{
|
||||
$biography->update([
|
||||
'needs_review' => true,
|
||||
'status' => $biography->is_user_edited
|
||||
? CreatorAiBiography::STATUS_EDITED
|
||||
: CreatorAiBiography::STATUS_NEEDS_REVIEW,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Biography flagged for review.',
|
||||
]);
|
||||
}
|
||||
|
||||
public function hide(CreatorAiBiography $biography): JsonResponse
|
||||
{
|
||||
if (! $biography->is_active) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Only active biographies can be hidden.',
|
||||
], 422);
|
||||
}
|
||||
|
||||
$this->biographies->hide($biography->user);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Biography hidden from public view.',
|
||||
]);
|
||||
}
|
||||
|
||||
public function show(CreatorAiBiography $biography): JsonResponse
|
||||
{
|
||||
if (! $biography->is_active) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Only active biographies can be made visible.',
|
||||
], 422);
|
||||
}
|
||||
|
||||
$this->biographies->show($biography->user);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Biography is public again.',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function filters(Request $request): array
|
||||
{
|
||||
return [
|
||||
'q' => trim((string) $request->query('q', '')),
|
||||
'status' => $this->enumFilter(
|
||||
(string) $request->query('status', 'all'),
|
||||
[
|
||||
'all',
|
||||
CreatorAiBiography::STATUS_GENERATED,
|
||||
CreatorAiBiography::STATUS_APPROVED,
|
||||
CreatorAiBiography::STATUS_EDITED,
|
||||
CreatorAiBiography::STATUS_NEEDS_REVIEW,
|
||||
CreatorAiBiography::STATUS_FAILED,
|
||||
CreatorAiBiography::STATUS_SUPPRESSED,
|
||||
],
|
||||
'all',
|
||||
),
|
||||
'scope' => $this->enumFilter((string) $request->query('scope', 'all'), ['all', 'active', 'inactive'], 'all'),
|
||||
'tier' => $this->enumFilter(
|
||||
(string) $request->query('tier', 'all'),
|
||||
['all', CreatorAiBiography::TIER_RICH, CreatorAiBiography::TIER_MEDIUM, CreatorAiBiography::TIER_SPARSE],
|
||||
'all',
|
||||
),
|
||||
'visibility' => $this->enumFilter((string) $request->query('visibility', 'all'), ['all', 'visible', 'hidden'], 'all'),
|
||||
'review' => $this->enumFilter((string) $request->query('review', 'all'), ['all', 'needs_review', 'failed', 'user_edited'], 'all'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $filters
|
||||
*/
|
||||
private function recordsQuery(array $filters): Builder
|
||||
{
|
||||
$query = CreatorAiBiography::query()
|
||||
->with('user:id,username,name,email,created_at')
|
||||
->latest('created_at')
|
||||
->latest('id');
|
||||
|
||||
if ($filters['q'] !== '') {
|
||||
$search = '%' . Str::lower($filters['q']) . '%';
|
||||
|
||||
$query->whereHas('user', function (Builder $userQuery) use ($search): void {
|
||||
$userQuery->where(function (Builder $matchQuery) use ($search): void {
|
||||
$matchQuery->whereRaw('LOWER(username) LIKE ?', [$search])
|
||||
->orWhereRaw('LOWER(COALESCE(name, "")) LIKE ?', [$search])
|
||||
->orWhereRaw('LOWER(COALESCE(email, "")) LIKE ?', [$search]);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if ($filters['status'] !== 'all') {
|
||||
$query->where('status', $filters['status']);
|
||||
}
|
||||
|
||||
if ($filters['scope'] === 'active') {
|
||||
$query->where('is_active', true);
|
||||
} elseif ($filters['scope'] === 'inactive') {
|
||||
$query->where('is_active', false);
|
||||
}
|
||||
|
||||
if ($filters['tier'] !== 'all') {
|
||||
$query->where('input_quality_tier', $filters['tier']);
|
||||
}
|
||||
|
||||
if ($filters['visibility'] === 'visible') {
|
||||
$query->where('is_hidden', false);
|
||||
} elseif ($filters['visibility'] === 'hidden') {
|
||||
$query->where('is_hidden', true);
|
||||
}
|
||||
|
||||
if ($filters['review'] === 'needs_review') {
|
||||
$query->where('needs_review', true);
|
||||
} elseif ($filters['review'] === 'failed') {
|
||||
$query->where(function (Builder $failedQuery): void {
|
||||
$failedQuery->where('status', CreatorAiBiography::STATUS_FAILED)
|
||||
->orWhereNotNull('last_error_code');
|
||||
});
|
||||
} elseif ($filters['review'] === 'user_edited') {
|
||||
$query->where('is_user_edited', true);
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, int>
|
||||
*/
|
||||
private function stats(): array
|
||||
{
|
||||
if (! Schema::hasTable('creator_ai_biographies')) {
|
||||
return [
|
||||
'total_records' => 0,
|
||||
'active_records' => 0,
|
||||
'needs_review' => 0,
|
||||
'hidden_active' => 0,
|
||||
'failed' => 0,
|
||||
'user_edited_active' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'total_records' => (int) CreatorAiBiography::query()->count(),
|
||||
'active_records' => (int) CreatorAiBiography::query()->where('is_active', true)->count(),
|
||||
'needs_review' => (int) CreatorAiBiography::query()->where('needs_review', true)->count(),
|
||||
'hidden_active' => (int) CreatorAiBiography::query()->where('is_active', true)->where('is_hidden', true)->count(),
|
||||
'failed' => (int) CreatorAiBiography::query()->where(function (Builder $query): void {
|
||||
$query->where('status', CreatorAiBiography::STATUS_FAILED)
|
||||
->orWhereNotNull('last_error_code');
|
||||
})->count(),
|
||||
'user_edited_active' => (int) CreatorAiBiography::query()->where('is_active', true)->where('is_user_edited', true)->count(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function mapRecord(CreatorAiBiography $record): array
|
||||
{
|
||||
$user = $record->user;
|
||||
$username = (string) ($user?->username ?? '');
|
||||
$isStale = $record->is_active && $user !== null ? $this->biographies->isStale($user) : false;
|
||||
|
||||
return [
|
||||
'id' => (int) $record->id,
|
||||
'user_id' => (int) $record->user_id,
|
||||
'user' => [
|
||||
'id' => $user?->id,
|
||||
'username' => $username,
|
||||
'display_name' => $user?->name ?: ($username !== '' ? '@' . $username : 'Unknown creator'),
|
||||
'email' => $user?->email,
|
||||
'profile_url' => $username !== '' ? route('profile.show', ['username' => Str::lower($username)]) : null,
|
||||
'gallery_url' => $username !== '' ? route('profile.gallery', ['username' => Str::lower($username)]) : null,
|
||||
],
|
||||
'text' => $record->text,
|
||||
'excerpt' => Str::limit((string) $record->text, 220),
|
||||
'status' => (string) $record->status,
|
||||
'is_active' => (bool) $record->is_active,
|
||||
'is_hidden' => (bool) $record->is_hidden,
|
||||
'is_user_edited' => (bool) $record->is_user_edited,
|
||||
'needs_review' => (bool) $record->needs_review,
|
||||
'is_stale' => $isStale,
|
||||
'source_hash' => $record->source_hash,
|
||||
'model' => $record->model,
|
||||
'prompt_version' => $record->prompt_version,
|
||||
'input_quality_tier' => $record->input_quality_tier,
|
||||
'generation_reason' => $record->generation_reason,
|
||||
'generated_at' => $record->generated_at?->toIso8601String(),
|
||||
'approved_at' => $record->approved_at?->toIso8601String(),
|
||||
'last_attempted_at' => $record->last_attempted_at?->toIso8601String(),
|
||||
'last_error_code' => $record->last_error_code,
|
||||
'last_error_reason' => $record->last_error_reason,
|
||||
'created_at' => $record->created_at?->toIso8601String(),
|
||||
'updated_at' => $record->updated_at?->toIso8601String(),
|
||||
];
|
||||
}
|
||||
|
||||
private function activeRecordForUser(User $user): ?CreatorAiBiography
|
||||
{
|
||||
return CreatorAiBiography::query()
|
||||
->where('user_id', (int) $user->id)
|
||||
->where('is_active', true)
|
||||
->latest('id')
|
||||
->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{success: bool, action: string, errors: list<string>} $result
|
||||
*/
|
||||
private function rebuildMessage(array $result): string
|
||||
{
|
||||
if ($result['success']) {
|
||||
return match ($result['action']) {
|
||||
'draft_stored' => 'New AI draft stored while preserving the active user-edited biography.',
|
||||
default => 'Biography rebuild completed.',
|
||||
};
|
||||
}
|
||||
|
||||
return $result['errors'][0] ?? 'Biography rebuild failed.';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $allowed
|
||||
*/
|
||||
private function enumFilter(string $value, array $allowed, string $fallback): string
|
||||
{
|
||||
$normalized = trim(Str::lower($value));
|
||||
|
||||
return in_array($normalized, $allowed, true) ? $normalized : $fallback;
|
||||
}
|
||||
}
|
||||
@@ -31,14 +31,24 @@ final class StudioArtworkAiAssistApiController extends Controller
|
||||
public function analyze(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$artwork = $request->user()->artworks()->with(['tags', 'categories.contentType'])->findOrFail($id);
|
||||
$direct = (bool) $request->boolean('direct');
|
||||
$intent = $request->validate([
|
||||
$payload = $request->validate([
|
||||
'direct' => ['sometimes', 'boolean'],
|
||||
'intent' => ['sometimes', 'nullable', 'string', 'in:analyze,title,description,tags,category,similar'],
|
||||
])['intent'] ?? null;
|
||||
'provider' => ['sometimes', 'nullable', 'string'],
|
||||
]);
|
||||
$direct = (bool) ($payload['direct'] ?? false);
|
||||
$intent = $payload['intent'] ?? null;
|
||||
$provider = $this->normalizeProviderOption($payload['provider'] ?? null);
|
||||
|
||||
if ($provider === null && array_key_exists('provider', $payload) && $payload['provider'] !== null) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Invalid provider. Supported values: lm_studio, together.',
|
||||
], 422);
|
||||
}
|
||||
|
||||
if ($direct) {
|
||||
$assist = $this->aiAssist->analyzeDirect($artwork, false, $intent);
|
||||
$assist = $this->aiAssist->analyzeDirect($artwork, false, $intent, $provider);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
@@ -48,7 +58,7 @@ final class StudioArtworkAiAssistApiController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
$assist = $this->aiAssist->queueAnalysis($artwork, false, $intent);
|
||||
$assist = $this->aiAssist->queueAnalysis($artwork, false, $intent, $provider);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
@@ -60,14 +70,24 @@ final class StudioArtworkAiAssistApiController extends Controller
|
||||
public function regenerate(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$artwork = $request->user()->artworks()->with(['tags', 'categories.contentType'])->findOrFail($id);
|
||||
$direct = (bool) $request->boolean('direct');
|
||||
$intent = $request->validate([
|
||||
$payload = $request->validate([
|
||||
'direct' => ['sometimes', 'boolean'],
|
||||
'intent' => ['sometimes', 'nullable', 'string', 'in:analyze,title,description,tags,category,similar'],
|
||||
])['intent'] ?? null;
|
||||
'provider' => ['sometimes', 'nullable', 'string'],
|
||||
]);
|
||||
$direct = (bool) ($payload['direct'] ?? false);
|
||||
$intent = $payload['intent'] ?? null;
|
||||
$provider = $this->normalizeProviderOption($payload['provider'] ?? null);
|
||||
|
||||
if ($provider === null && array_key_exists('provider', $payload) && $payload['provider'] !== null) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Invalid provider. Supported values: lm_studio, together.',
|
||||
], 422);
|
||||
}
|
||||
|
||||
if ($direct) {
|
||||
$assist = $this->aiAssist->analyzeDirect($artwork, true, $intent);
|
||||
$assist = $this->aiAssist->analyzeDirect($artwork, true, $intent, $provider);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
@@ -77,7 +97,7 @@ final class StudioArtworkAiAssistApiController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
$assist = $this->aiAssist->queueAnalysis($artwork, true, $intent);
|
||||
$assist = $this->aiAssist->queueAnalysis($artwork, true, $intent, $provider);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
@@ -114,4 +134,17 @@ final class StudioArtworkAiAssistApiController extends Controller
|
||||
|
||||
return response()->json(['success' => true], 201);
|
||||
}
|
||||
|
||||
private function normalizeProviderOption(mixed $value): ?string
|
||||
{
|
||||
if ($value === null || trim((string) $value) === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return match (strtolower(trim((string) $value))) {
|
||||
'lm_studio', 'lm-studio', 'local', 'home' => 'lm_studio',
|
||||
'together', 'together_ai' => 'together',
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ use App\Services\ArtworkVersioningService;
|
||||
use App\Services\Studio\StudioArtworkQueryService;
|
||||
use App\Services\Studio\StudioBulkActionService;
|
||||
use App\Services\Tags\TagDiscoveryService;
|
||||
use App\Services\Worlds\WorldSubmissionService;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -125,7 +126,7 @@ final class StudioArtworksApiController extends Controller
|
||||
* PUT /api/studio/artworks/{id}
|
||||
* Update artwork details (title, description, visibility).
|
||||
*/
|
||||
public function update(Request $request, int $id, ArtworkAttributionService $attribution): JsonResponse
|
||||
public function update(Request $request, int $id, ArtworkAttributionService $attribution, WorldSubmissionService $submissions): JsonResponse
|
||||
{
|
||||
$artwork = $request->user()->artworks()->findOrFail($id);
|
||||
$evolution = app(ArtworkEvolutionService::class);
|
||||
@@ -154,6 +155,9 @@ final class StudioArtworksApiController extends Controller
|
||||
'contributor_credits.*.user_id' => 'required|integer|min:1',
|
||||
'contributor_credits.*.credit_role' => 'nullable|string|max:80',
|
||||
'contributor_credits.*.is_primary' => 'nullable|boolean',
|
||||
'world_submissions' => 'sometimes|array|max:12',
|
||||
'world_submissions.*.world_id' => 'required|integer|exists:worlds,id',
|
||||
'world_submissions.*.note' => 'nullable|string|max:1000',
|
||||
'evolution_target_artwork_id' => 'sometimes|nullable|integer|min:1',
|
||||
'evolution_relation_type' => 'sometimes|nullable|string|in:remake_of,remaster_of,revision_of,inspired_by,variation_of',
|
||||
'evolution_note' => 'sometimes|nullable|string|max:1200',
|
||||
@@ -166,6 +170,7 @@ final class StudioArtworksApiController extends Controller
|
||||
$hasEvolutionUpdates = array_key_exists('evolution_target_artwork_id', $validated)
|
||||
|| array_key_exists('evolution_relation_type', $validated)
|
||||
|| array_key_exists('evolution_note', $validated);
|
||||
$worldSubmissionPayload = $validated['world_submissions'] ?? null;
|
||||
|
||||
$attributionPayload = [
|
||||
'group' => $validated['group'] ?? $artwork->group?->slug,
|
||||
@@ -208,7 +213,7 @@ final class StudioArtworksApiController extends Controller
|
||||
'relation_type' => $validated['evolution_relation_type'] ?? null,
|
||||
'note' => $validated['evolution_note'] ?? null,
|
||||
];
|
||||
unset($validated['tags'], $validated['category_id'], $validated['content_type_id'], $validated['visibility'], $validated['mode'], $validated['publish_at'], $validated['timezone'], $validated['group'], $validated['primary_author_user_id'], $validated['contributor_user_ids'], $validated['contributor_credits']);
|
||||
unset($validated['tags'], $validated['category_id'], $validated['content_type_id'], $validated['visibility'], $validated['mode'], $validated['publish_at'], $validated['timezone'], $validated['group'], $validated['primary_author_user_id'], $validated['contributor_user_ids'], $validated['contributor_credits'], $validated['world_submissions']);
|
||||
unset($validated['evolution_target_artwork_id'], $validated['evolution_relation_type'], $validated['evolution_note']);
|
||||
|
||||
$validated['visibility'] = $visibility;
|
||||
@@ -271,6 +276,14 @@ final class StudioArtworksApiController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
if ($worldSubmissionPayload !== null) {
|
||||
try {
|
||||
$submissions->syncForArtwork($artwork->fresh(), $request->user(), (array) $worldSubmissionPayload);
|
||||
} catch (ValidationException $exception) {
|
||||
return response()->json(['errors' => $exception->errors()], 422);
|
||||
}
|
||||
}
|
||||
|
||||
// Reindex in Meilisearch
|
||||
try {
|
||||
if ((bool) $artwork->is_public && (bool) $artwork->is_approved && $artwork->published_at) {
|
||||
@@ -316,6 +329,7 @@ final class StudioArtworksApiController extends Controller
|
||||
'category_source' => $artwork->category_source ?: 'manual',
|
||||
'evolution_relation' => $evolution->editorRelation($artwork, $request->user()),
|
||||
],
|
||||
'world_submission_options' => $submissions->artworkSubmissionOptions($artwork->fresh(['worldSubmissions.world', 'worldSubmissions.reviewer']), $request->user()),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ use App\Services\Studio\CreatorStudioPreferenceService;
|
||||
use App\Services\Studio\CreatorStudioChallengeService;
|
||||
use App\Services\Studio\CreatorStudioSearchService;
|
||||
use App\Services\Studio\CreatorStudioScheduledService;
|
||||
use App\Services\Worlds\WorldSubmissionService;
|
||||
use App\Support\CoverUrl;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -493,6 +494,7 @@ final class StudioController extends Controller
|
||||
'version_count' => (int) ($artwork->version_count ?? 1),
|
||||
'requires_reapproval' => (bool) $artwork->requires_reapproval,
|
||||
],
|
||||
'worldSubmissionOptions' => app(WorldSubmissionService::class)->artworkSubmissionOptions($artwork, $user),
|
||||
'contentTypes' => $this->getCategories(),
|
||||
'groupOptions' => $availableGroups,
|
||||
'contributorOptionsByGroup' => $contributorOptionsByGroup,
|
||||
|
||||
294
app/Http/Controllers/Studio/StudioWorldController.php
Normal file
294
app/Http/Controllers/Studio/StudioWorldController.php
Normal file
@@ -0,0 +1,294 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Studio;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Worlds\StoreWorldRequest;
|
||||
use App\Http\Requests\Worlds\UpdateWorldRequest;
|
||||
use App\Models\World;
|
||||
use App\Models\WorldSubmission;
|
||||
use App\Services\Worlds\WorldService;
|
||||
use App\Services\Worlds\WorldSubmissionService;
|
||||
use App\Support\Seo\SeoFactory;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
final class StudioWorldController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly WorldService $worlds,
|
||||
private readonly WorldSubmissionService $submissions,
|
||||
)
|
||||
{
|
||||
}
|
||||
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$this->authorize('manage', World::class);
|
||||
|
||||
return Inertia::render('Studio/StudioWorldsIndex', [
|
||||
'title' => 'Worlds',
|
||||
'description' => 'Create and manage seasonal, event, and campaign destinations across Skinbase Nova.',
|
||||
'listing' => $this->worlds->studioListing($request->only(['q', 'status', 'type', 'per_page'])),
|
||||
'statusOptions' => [
|
||||
['value' => World::STATUS_DRAFT, 'label' => 'Draft'],
|
||||
['value' => World::STATUS_PUBLISHED, 'label' => 'Published'],
|
||||
['value' => World::STATUS_ARCHIVED, 'label' => 'Archived'],
|
||||
],
|
||||
'typeOptions' => [
|
||||
['value' => World::TYPE_SEASONAL, 'label' => 'Seasonal'],
|
||||
['value' => World::TYPE_EVENT, 'label' => 'Event'],
|
||||
['value' => World::TYPE_CAMPAIGN, 'label' => 'Campaign'],
|
||||
['value' => World::TYPE_TRIBUTE, 'label' => 'Tribute'],
|
||||
],
|
||||
'createUrl' => route('studio.worlds.create'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function create(Request $request): Response|RedirectResponse
|
||||
{
|
||||
if (! $request->user()?->can('create', World::class)) {
|
||||
return redirect()->route('worlds.index');
|
||||
}
|
||||
|
||||
return Inertia::render('Studio/StudioWorldEditor', [
|
||||
'title' => 'Create world',
|
||||
'description' => 'Build a curated campaign destination with themed visuals, ordered sections, and explicit content attachments.',
|
||||
'world' => null,
|
||||
'themeOptions' => $this->worlds->themeOptions(),
|
||||
'sectionOptions' => $this->worlds->sectionOptions(),
|
||||
'relationTypeOptions' => $this->worlds->relationTypeOptions(),
|
||||
'typeOptions' => [
|
||||
['value' => World::TYPE_SEASONAL, 'label' => 'Seasonal'],
|
||||
['value' => World::TYPE_EVENT, 'label' => 'Event'],
|
||||
['value' => World::TYPE_CAMPAIGN, 'label' => 'Campaign'],
|
||||
['value' => World::TYPE_TRIBUTE, 'label' => 'Tribute'],
|
||||
],
|
||||
'statusOptions' => [
|
||||
['value' => World::STATUS_DRAFT, 'label' => 'Draft'],
|
||||
['value' => World::STATUS_PUBLISHED, 'label' => 'Published'],
|
||||
['value' => World::STATUS_ARCHIVED, 'label' => 'Archived'],
|
||||
],
|
||||
'storeUrl' => route('studio.worlds.store'),
|
||||
'entitySearchUrl' => route('studio.worlds.entity-search'),
|
||||
'duplicateActions' => null,
|
||||
'mediaSupport' => [
|
||||
'picker_available' => false,
|
||||
'helper_text' => 'Drop a cover or OG image here and Skinbase will optimize it and store it on the CDN automatically.',
|
||||
'upload_url' => route('api.studio.worlds.media.upload'),
|
||||
'delete_url' => route('api.studio.worlds.media.destroy'),
|
||||
'files_base_url' => rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/'),
|
||||
'accepted_mime_types' => ['image/jpeg', 'image/png', 'image/webp'],
|
||||
'max_file_size_mb' => 6,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(StoreWorldRequest $request): RedirectResponse
|
||||
{
|
||||
$this->authorize('create', World::class);
|
||||
|
||||
$world = $this->worlds->store($request->user(), $request->validated());
|
||||
|
||||
return redirect()->route('studio.worlds.edit', ['world' => $world])->with('success', 'World draft created.');
|
||||
}
|
||||
|
||||
public function edit(Request $request, World $world): Response
|
||||
{
|
||||
$this->authorize('update', $world);
|
||||
|
||||
return Inertia::render('Studio/StudioWorldEditor', [
|
||||
'title' => 'Edit world',
|
||||
'description' => 'Tune the world identity, adjust section order, and refine the curated attachments.',
|
||||
'world' => $this->worlds->mapStudioWorld($world, $request->user()),
|
||||
'themeOptions' => $this->worlds->themeOptions(),
|
||||
'sectionOptions' => $this->worlds->sectionOptions(),
|
||||
'relationTypeOptions' => $this->worlds->relationTypeOptions(),
|
||||
'typeOptions' => [
|
||||
['value' => World::TYPE_SEASONAL, 'label' => 'Seasonal'],
|
||||
['value' => World::TYPE_EVENT, 'label' => 'Event'],
|
||||
['value' => World::TYPE_CAMPAIGN, 'label' => 'Campaign'],
|
||||
['value' => World::TYPE_TRIBUTE, 'label' => 'Tribute'],
|
||||
],
|
||||
'statusOptions' => [
|
||||
['value' => World::STATUS_DRAFT, 'label' => 'Draft'],
|
||||
['value' => World::STATUS_PUBLISHED, 'label' => 'Published'],
|
||||
['value' => World::STATUS_ARCHIVED, 'label' => 'Archived'],
|
||||
],
|
||||
'updateUrl' => route('studio.worlds.update', ['world' => $world]),
|
||||
'previewUrl' => route('studio.worlds.preview', ['world' => $world]),
|
||||
'publishUrl' => route('studio.worlds.publish', ['world' => $world]),
|
||||
'archiveUrl' => route('studio.worlds.archive', ['world' => $world]),
|
||||
'entitySearchUrl' => route('studio.worlds.entity-search'),
|
||||
'duplicateActions' => [
|
||||
'duplicateUrl' => route('studio.worlds.duplicate', ['world' => $world]),
|
||||
'newEditionUrl' => route('studio.worlds.new-edition', ['world' => $world]),
|
||||
'canCreateEdition' => $this->worlds->canCreateNewEdition($world),
|
||||
],
|
||||
'mediaSupport' => [
|
||||
'picker_available' => false,
|
||||
'helper_text' => 'Drop a cover or OG image here and Skinbase will optimize it and store it on the CDN automatically.',
|
||||
'upload_url' => route('api.studio.worlds.media.upload'),
|
||||
'delete_url' => route('api.studio.worlds.media.destroy'),
|
||||
'files_base_url' => rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/'),
|
||||
'accepted_mime_types' => ['image/jpeg', 'image/png', 'image/webp'],
|
||||
'max_file_size_mb' => 6,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(UpdateWorldRequest $request, World $world): RedirectResponse
|
||||
{
|
||||
$this->authorize('update', $world);
|
||||
|
||||
$this->worlds->update($world, $request->user(), $request->validated());
|
||||
|
||||
return back()->with('success', 'World updated.');
|
||||
}
|
||||
|
||||
public function publish(Request $request, World $world): RedirectResponse
|
||||
{
|
||||
$this->authorize('update', $world);
|
||||
|
||||
$this->worlds->publish($world);
|
||||
|
||||
return back()->with('success', 'World published.');
|
||||
}
|
||||
|
||||
public function archive(Request $request, World $world): RedirectResponse
|
||||
{
|
||||
$this->authorize('update', $world);
|
||||
|
||||
$this->worlds->archive($world);
|
||||
|
||||
return back()->with('success', 'World archived.');
|
||||
}
|
||||
|
||||
public function duplicate(Request $request, World $world): RedirectResponse
|
||||
{
|
||||
$this->authorize('create', World::class);
|
||||
$this->authorize('update', $world);
|
||||
|
||||
$duplicate = $this->worlds->duplicate($world, $request->user(), false);
|
||||
|
||||
return redirect()->route('studio.worlds.edit', ['world' => $duplicate])->with('success', 'World duplicated into a new draft.');
|
||||
}
|
||||
|
||||
public function newEdition(Request $request, World $world): RedirectResponse
|
||||
{
|
||||
$this->authorize('create', World::class);
|
||||
$this->authorize('update', $world);
|
||||
|
||||
$edition = $this->worlds->duplicate($world, $request->user(), true);
|
||||
|
||||
return redirect()->route('studio.worlds.edit', ['world' => $edition])->with('success', 'Next edition draft created.');
|
||||
}
|
||||
|
||||
public function entitySearch(Request $request): JsonResponse
|
||||
{
|
||||
$this->authorize('manage', World::class);
|
||||
|
||||
$validated = $request->validate([
|
||||
'type' => ['required', 'string'],
|
||||
'q' => ['nullable', 'string', 'max:120'],
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'items' => $this->worlds->searchEntities((string) $validated['type'], (string) ($validated['q'] ?? ''), $request->user()),
|
||||
]);
|
||||
}
|
||||
|
||||
public function approveSubmission(Request $request, World $world, WorldSubmission $submission): RedirectResponse
|
||||
{
|
||||
return $this->transitionSubmission($request, $world, $submission, WorldSubmission::STATUS_LIVE, 'Submission approved and is now live.');
|
||||
}
|
||||
|
||||
public function removeSubmission(Request $request, World $world, WorldSubmission $submission): RedirectResponse
|
||||
{
|
||||
return $this->transitionSubmission($request, $world, $submission, WorldSubmission::STATUS_REMOVED, 'Submission removed from the world.');
|
||||
}
|
||||
|
||||
public function blockSubmission(Request $request, World $world, WorldSubmission $submission): RedirectResponse
|
||||
{
|
||||
return $this->transitionSubmission($request, $world, $submission, WorldSubmission::STATUS_BLOCKED, 'Submission blocked from this world.');
|
||||
}
|
||||
|
||||
public function unblockSubmission(Request $request, World $world, WorldSubmission $submission): RedirectResponse
|
||||
{
|
||||
return $this->transitionSubmission($request, $world, $submission, WorldSubmission::STATUS_REMOVED, 'Submission unblocked. It can now be restored or re-added later.');
|
||||
}
|
||||
|
||||
public function restoreSubmission(Request $request, World $world, WorldSubmission $submission): RedirectResponse
|
||||
{
|
||||
return $this->transitionSubmission($request, $world, $submission, WorldSubmission::STATUS_LIVE, 'Submission restored to live.');
|
||||
}
|
||||
|
||||
public function featureSubmission(Request $request, World $world, WorldSubmission $submission): RedirectResponse
|
||||
{
|
||||
return $this->toggleFeaturedSubmission($request, $world, $submission, true, 'Submission featured in the public community section.');
|
||||
}
|
||||
|
||||
public function unfeatureSubmission(Request $request, World $world, WorldSubmission $submission): RedirectResponse
|
||||
{
|
||||
return $this->toggleFeaturedSubmission($request, $world, $submission, false, 'Submission removed from featured community placement.');
|
||||
}
|
||||
|
||||
public function pendingSubmission(Request $request, World $world, WorldSubmission $submission): RedirectResponse
|
||||
{
|
||||
return $this->transitionSubmission($request, $world, $submission, WorldSubmission::STATUS_PENDING, 'Submission returned to pending.');
|
||||
}
|
||||
|
||||
public function preview(Request $request, World $world): \Inertia\Response
|
||||
{
|
||||
$this->authorize('update', $world);
|
||||
|
||||
$payload = $this->worlds->publicShowPayload($world, $request->user());
|
||||
$seo = app(SeoFactory::class)->collectionPage(
|
||||
$world->seo_title ?: ($world->title . ' — Skinbase Nova Preview'),
|
||||
$world->seo_description ?: ($world->summary ?: $world->description ?: 'Preview world page'),
|
||||
route('studio.worlds.preview', ['world' => $world]),
|
||||
$world->ogImageUrl(),
|
||||
false,
|
||||
)->toArray();
|
||||
|
||||
return Inertia::render('World/WorldShow', array_merge($payload, [
|
||||
'seo' => $seo,
|
||||
'previewMode' => true,
|
||||
]))->rootView('collections');
|
||||
}
|
||||
|
||||
private function transitionSubmission(Request $request, World $world, WorldSubmission $submission, string $status, string $flashMessage): RedirectResponse
|
||||
{
|
||||
$this->authorize('update', $world);
|
||||
|
||||
abort_unless((int) $submission->world_id === (int) $world->id, 404);
|
||||
|
||||
$validated = $request->validate([
|
||||
'review_note' => ['nullable', 'string', 'max:1000'],
|
||||
]);
|
||||
|
||||
$this->submissions->transition($submission, $request->user(), $status, $validated['review_note'] ?? null);
|
||||
|
||||
return back()->with('success', $flashMessage);
|
||||
}
|
||||
|
||||
private function toggleFeaturedSubmission(Request $request, World $world, WorldSubmission $submission, bool $featured, string $flashMessage): RedirectResponse
|
||||
{
|
||||
$this->authorize('update', $world);
|
||||
|
||||
abort_unless((int) $submission->world_id === (int) $world->id, 404);
|
||||
|
||||
$validated = $request->validate([
|
||||
'review_note' => ['nullable', 'string', 'max:1000'],
|
||||
]);
|
||||
|
||||
$this->submissions->setFeatured($submission, $request->user(), $featured, $validated['review_note'] ?? null);
|
||||
|
||||
return back()->with('success', $flashMessage);
|
||||
}
|
||||
}
|
||||
255
app/Http/Controllers/Studio/StudioWorldMediaApiController.php
Normal file
255
app/Http/Controllers/Studio/StudioWorldMediaApiController.php
Normal file
@@ -0,0 +1,255 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Studio;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\World;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Intervention\Image\Drivers\Gd\Driver as GdDriver;
|
||||
use Intervention\Image\Drivers\Imagick\Driver as ImagickDriver;
|
||||
use Intervention\Image\Encoders\WebpEncoder;
|
||||
use Intervention\Image\ImageManager;
|
||||
use RuntimeException;
|
||||
|
||||
final class StudioWorldMediaApiController extends Controller
|
||||
{
|
||||
private const ALLOWED_MIME_TYPES = ['image/jpeg', 'image/png', 'image/webp'];
|
||||
|
||||
private const MAX_FILE_SIZE_KB = 6144;
|
||||
|
||||
private const SLOT_CONFIG = [
|
||||
'cover' => [
|
||||
'max_width' => 2200,
|
||||
'max_height' => 1400,
|
||||
'min_width' => 1200,
|
||||
'min_height' => 630,
|
||||
],
|
||||
'og' => [
|
||||
'max_width' => 1600,
|
||||
'max_height' => 1000,
|
||||
'min_width' => 1200,
|
||||
'min_height' => 630,
|
||||
],
|
||||
];
|
||||
|
||||
private ?ImageManager $manager = null;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
try {
|
||||
$this->manager = extension_loaded('gd')
|
||||
? new ImageManager(new GdDriver())
|
||||
: new ImageManager(new ImagickDriver());
|
||||
} catch (\Throwable) {
|
||||
$this->manager = null;
|
||||
}
|
||||
}
|
||||
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'slot' => ['required', 'string', 'in:cover,og'],
|
||||
'image' => [
|
||||
'required',
|
||||
'file',
|
||||
'image',
|
||||
'max:' . self::MAX_FILE_SIZE_KB,
|
||||
'mimes:jpg,jpeg,png,webp',
|
||||
'mimetypes:image/jpeg,image/png,image/webp',
|
||||
],
|
||||
'world_id' => ['nullable', 'integer', 'exists:worlds,id'],
|
||||
]);
|
||||
|
||||
$world = isset($validated['world_id']) ? World::query()->findOrFail((int) $validated['world_id']) : null;
|
||||
|
||||
if ($world instanceof World) {
|
||||
$this->authorize('update', $world);
|
||||
} else {
|
||||
$this->authorize('create', World::class);
|
||||
}
|
||||
|
||||
/** @var UploadedFile $file */
|
||||
$file = $validated['image'];
|
||||
$slot = (string) $validated['slot'];
|
||||
|
||||
try {
|
||||
$stored = $this->storeMediaFile($file, $slot);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'slot' => $slot,
|
||||
'path' => $stored['path'],
|
||||
'url' => $this->publicUrlForPath($stored['path']),
|
||||
'width' => $stored['width'],
|
||||
'height' => $stored['height'],
|
||||
'mime_type' => 'image/webp',
|
||||
'size_bytes' => $stored['size_bytes'],
|
||||
]);
|
||||
} catch (RuntimeException $e) {
|
||||
return response()->json([
|
||||
'error' => 'Validation failed',
|
||||
'message' => $e->getMessage(),
|
||||
], 422);
|
||||
} catch (\Throwable $e) {
|
||||
logger()->error('World media upload failed', [
|
||||
'user_id' => (int) ($request->user()?->id ?? 0),
|
||||
'world_id' => $world?->id,
|
||||
'slot' => $slot,
|
||||
'message' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'error' => 'Upload failed',
|
||||
'message' => 'Could not upload image right now.',
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
public function destroy(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'path' => ['required', 'string', 'max:2048'],
|
||||
'world_id' => ['nullable', 'integer', 'exists:worlds,id'],
|
||||
]);
|
||||
|
||||
$world = isset($validated['world_id']) ? World::query()->findOrFail((int) $validated['world_id']) : null;
|
||||
|
||||
if ($world instanceof World) {
|
||||
$this->authorize('update', $world);
|
||||
} else {
|
||||
$this->authorize('create', World::class);
|
||||
}
|
||||
|
||||
$this->deleteMediaFile((string) $validated['path']);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{path:string,width:int,height:int,size_bytes:int}
|
||||
*/
|
||||
private function storeMediaFile(UploadedFile $file, string $slot): array
|
||||
{
|
||||
$this->assertImageManager();
|
||||
$this->assertStorageIsAllowed();
|
||||
|
||||
$config = self::SLOT_CONFIG[$slot] ?? self::SLOT_CONFIG['cover'];
|
||||
$uploadPath = (string) ($file->getRealPath() ?: $file->getPathname());
|
||||
|
||||
if ($uploadPath === '' || ! is_readable($uploadPath)) {
|
||||
throw new RuntimeException('Unable to resolve uploaded image path.');
|
||||
}
|
||||
|
||||
$raw = file_get_contents($uploadPath);
|
||||
if ($raw === false || $raw === '') {
|
||||
throw new RuntimeException('Unable to read uploaded image.');
|
||||
}
|
||||
|
||||
$finfo = new \finfo(FILEINFO_MIME_TYPE);
|
||||
$mime = strtolower((string) $finfo->buffer($raw));
|
||||
if (! in_array($mime, self::ALLOWED_MIME_TYPES, true)) {
|
||||
throw new RuntimeException('Unsupported image mime type.');
|
||||
}
|
||||
|
||||
$size = @getimagesizefromstring($raw);
|
||||
if (! is_array($size) || ($size[0] ?? 0) < 1 || ($size[1] ?? 0) < 1) {
|
||||
throw new RuntimeException('Uploaded file is not a valid image.');
|
||||
}
|
||||
|
||||
$width = (int) ($size[0] ?? 0);
|
||||
$height = (int) ($size[1] ?? 0);
|
||||
|
||||
if ($width < $config['min_width'] || $height < $config['min_height']) {
|
||||
throw new RuntimeException(sprintf(
|
||||
'Image is too small. Minimum required size is %dx%d.',
|
||||
$config['min_width'],
|
||||
$config['min_height'],
|
||||
));
|
||||
}
|
||||
|
||||
$image = $this->manager->read($raw)->scaleDown(width: $config['max_width'], height: $config['max_height']);
|
||||
$encoded = (string) $image->encode(new WebpEncoder(85));
|
||||
|
||||
$hash = hash('sha256', $encoded);
|
||||
$path = $this->mediaPath($slot, $hash);
|
||||
$disk = Storage::disk($this->mediaDiskName());
|
||||
|
||||
$written = $disk->put($path, $encoded, [
|
||||
'visibility' => 'public',
|
||||
'CacheControl' => 'public, max-age=31536000, immutable',
|
||||
'ContentType' => 'image/webp',
|
||||
]);
|
||||
|
||||
if ($written !== true) {
|
||||
throw new RuntimeException('Unable to store image in object storage.');
|
||||
}
|
||||
|
||||
return [
|
||||
'path' => $path,
|
||||
'width' => (int) $image->width(),
|
||||
'height' => (int) $image->height(),
|
||||
'size_bytes' => strlen($encoded),
|
||||
];
|
||||
}
|
||||
|
||||
private function mediaDiskName(): string
|
||||
{
|
||||
return (string) config('covers.disk', 's3');
|
||||
}
|
||||
|
||||
private function mediaPath(string $slot, string $hash): string
|
||||
{
|
||||
return sprintf(
|
||||
'worlds/media/%s/%s/%s/%s.webp',
|
||||
$slot,
|
||||
substr($hash, 0, 2),
|
||||
substr($hash, 2, 2),
|
||||
$hash,
|
||||
);
|
||||
}
|
||||
|
||||
private function publicUrlForPath(string $path): string
|
||||
{
|
||||
return rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/') . '/' . ltrim($path, '/');
|
||||
}
|
||||
|
||||
private function deleteMediaFile(string $path): void
|
||||
{
|
||||
$trimmed = ltrim(trim($path), '/');
|
||||
|
||||
if ($trimmed === '' || ! Str::startsWith($trimmed, 'worlds/media/')) {
|
||||
return;
|
||||
}
|
||||
|
||||
Storage::disk($this->mediaDiskName())->delete($trimmed);
|
||||
}
|
||||
|
||||
private function assertImageManager(): void
|
||||
{
|
||||
if ($this->manager !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw new RuntimeException('Image processing is not available on this environment.');
|
||||
}
|
||||
|
||||
private function assertStorageIsAllowed(): void
|
||||
{
|
||||
if (! app()->environment('production')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$diskName = $this->mediaDiskName();
|
||||
if (in_array($diskName, ['local', 'public'], true)) {
|
||||
throw new RuntimeException('Production world media storage must use object storage, not local/public disks.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -255,6 +255,7 @@ class ProfileController extends Controller
|
||||
public function editSettings(Request $request)
|
||||
{
|
||||
$user = $request->user()->loadMissing(['profile', 'country']);
|
||||
$emailLoginUpgradeRequired = $user->requiresEmailLoginUpgrade();
|
||||
$cooldownDays = $this->usernameCooldownDays();
|
||||
$lastUsernameChangeAt = $this->lastUsernameChangeAt($user);
|
||||
$usernameCooldownRemainingDays = 0;
|
||||
@@ -351,6 +352,8 @@ class ProfileController extends Controller
|
||||
'usernameCooldownDays' => $cooldownDays,
|
||||
'usernameCooldownRemainingDays' => $usernameCooldownRemainingDays,
|
||||
'usernameCooldownActive' => $usernameCooldownRemainingDays > 0,
|
||||
'emailLoginUpgradeRequired' => $emailLoginUpgradeRequired,
|
||||
'forcedSection' => $emailLoginUpgradeRequired ? 'account' : null,
|
||||
'countries' => $countries,
|
||||
'flash' => [
|
||||
'status' => session('status'),
|
||||
@@ -518,8 +521,13 @@ class ProfileController extends Controller
|
||||
|
||||
DB::transaction(function () use ($user, $change, $newEmail): void {
|
||||
$lockedUser = User::query()->whereKey((int) $user->id)->lockForUpdate()->firstOrFail();
|
||||
$completesLegacyUpgrade = $lockedUser->requiresEmailLoginUpgrade();
|
||||
|
||||
$lockedUser->email = $newEmail;
|
||||
$lockedUser->email_verified_at = now();
|
||||
if ($completesLegacyUpgrade) {
|
||||
$lockedUser->onboarding_step = 'complete';
|
||||
}
|
||||
$lockedUser->save();
|
||||
|
||||
DB::table('email_changes')
|
||||
|
||||
@@ -47,7 +47,11 @@ final class ArtworkPageController extends Controller
|
||||
return response(view('errors.410'), 410);
|
||||
}
|
||||
|
||||
if (! $raw->is_public || ! $raw->is_approved) {
|
||||
if (! $raw->is_public
|
||||
|| ! $raw->is_approved
|
||||
|| (string) ($raw->visibility ?? '') === Artwork::VISIBILITY_PRIVATE
|
||||
|| $raw->published_at === null
|
||||
|| $raw->published_at->isFuture()) {
|
||||
// Artwork exists but is private/unapproved → 403 Forbidden.
|
||||
// Show other public artworks by the same creator as recovery suggestions.
|
||||
$suggestions = app(ErrorSuggestionService::class);
|
||||
@@ -63,8 +67,7 @@ final class ArtworkPageController extends Controller
|
||||
->with('user')
|
||||
->where('user_id', $raw->user_id)
|
||||
->where('id', '!=', $raw->id)
|
||||
->public()
|
||||
->published()
|
||||
->catalogVisible()
|
||||
->limit(6)
|
||||
->get()
|
||||
->map(function (Artwork $a) {
|
||||
@@ -185,6 +188,9 @@ final class ArtworkPageController extends Controller
|
||||
'id' => (int) $item->id,
|
||||
'title' => html_entity_decode((string) $item->title, ENT_QUOTES | ENT_HTML5, 'UTF-8'),
|
||||
'author' => html_entity_decode((string) ($item->group?->name ?: $item->user?->name ?: $item->user?->username ?: 'Artist'), ENT_QUOTES | ENT_HTML5, 'UTF-8'),
|
||||
'author_id' => (int) ($item->user?->id ?? 0),
|
||||
'publisher_type' => $item->group ? 'group' : 'user',
|
||||
'publisher_id' => $item->group ? (int) $item->group->id : (int) ($item->user?->id ?? 0),
|
||||
'url' => route('art.show', ['id' => $item->id, 'slug' => $itemSlug]),
|
||||
'thumb' => $md['url'] ?? null,
|
||||
'thumb_srcset' => ($md['url'] ?? '') . ' 640w, ' . ($lg['url'] ?? '') . ' 1280w',
|
||||
|
||||
@@ -88,12 +88,12 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
|
||||
$ttl = self::SORT_TTL_MAP[$sort] ?? 300;
|
||||
|
||||
$artworks = Cache::remember(
|
||||
"browse.all.{$sort}.{$page}",
|
||||
"browse.all.catalog-visible.v2.{$sort}.{$page}",
|
||||
$ttl,
|
||||
fn () => Artwork::search('')->options([
|
||||
fn () => $this->search->searchWithThumbnailPreference([
|
||||
'filter' => 'is_public = true AND is_approved = true',
|
||||
'sort' => self::SORT_MAP[$sort] ?? ['created_at:desc'],
|
||||
])->paginate($perPage)
|
||||
], $perPage, false, $page)
|
||||
);
|
||||
$artworks->getCollection()->transform(fn ($a) => $this->presentArtwork($a));
|
||||
$seo = $this->buildPaginationSeo($request, url('/browse'), $artworks);
|
||||
@@ -150,12 +150,12 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
|
||||
$normalizedPath = trim((string) $path, '/');
|
||||
if ($normalizedPath === '') {
|
||||
$artworks = Cache::remember(
|
||||
"gallery.ct.{$contentSlug}.{$sort}.{$page}",
|
||||
"gallery.ct.catalog-visible.v2.{$contentSlug}.{$sort}.{$page}",
|
||||
$ttl,
|
||||
fn () => Artwork::search('')->options([
|
||||
fn () => $this->search->searchWithThumbnailPreference([
|
||||
'filter' => 'is_public = true AND is_approved = true AND content_type = "' . $contentSlug . '"',
|
||||
'sort' => self::SORT_MAP[$sort] ?? ['created_at:desc'],
|
||||
])->paginate($perPage)
|
||||
], $perPage, false, $page)
|
||||
);
|
||||
$artworks->getCollection()->transform(fn ($a) => $this->presentArtwork($a));
|
||||
$seo = $this->buildPaginationSeo($request, url('/' . $contentSlug), $artworks);
|
||||
@@ -197,12 +197,12 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
|
||||
->implode(' OR ');
|
||||
|
||||
$artworks = Cache::remember(
|
||||
'gallery.cat.' . md5($contentSlug . '|' . implode('|', $categorySlugs)) . ".{$sort}.{$page}",
|
||||
'gallery.cat.catalog-visible.v2.' . md5($contentSlug . '|' . implode('|', $categorySlugs)) . ".{$sort}.{$page}",
|
||||
$ttl,
|
||||
fn () => Artwork::search('')->options([
|
||||
fn () => $this->search->searchWithThumbnailPreference([
|
||||
'filter' => 'is_public = true AND is_approved = true AND (' . $categoryFilter . ')',
|
||||
'sort' => self::SORT_MAP[$sort] ?? ['created_at:desc'],
|
||||
])->paginate($perPage)
|
||||
], $perPage, false, $page)
|
||||
);
|
||||
$artworks->getCollection()->transform(fn ($a) => $this->presentArtwork($a));
|
||||
$seo = $this->buildPaginationSeo($request, url('/' . $contentSlug . '/' . strtolower($category->full_slug_path)), $artworks);
|
||||
|
||||
@@ -171,8 +171,7 @@ final class DiscoverController extends Controller
|
||||
$today = now();
|
||||
|
||||
$artworks = Artwork::query()
|
||||
->public()
|
||||
->published()
|
||||
->catalogVisible()
|
||||
->with([
|
||||
'user:id,name',
|
||||
'user.profile:user_id,avatar_hash',
|
||||
@@ -209,16 +208,14 @@ final class DiscoverController extends Controller
|
||||
|
||||
if ($hasStats) {
|
||||
$sub = Artwork::query()
|
||||
->public()
|
||||
->published()
|
||||
->catalogVisible()
|
||||
->join('artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id')
|
||||
->where('artworks.published_at', '>=', now()->subDays(90))
|
||||
->selectRaw('artworks.user_id, SUM(artwork_stats.views) as recent_views, MAX(artworks.published_at) as latest_published')
|
||||
->groupBy('artworks.user_id');
|
||||
} else {
|
||||
$sub = Artwork::query()
|
||||
->public()
|
||||
->published()
|
||||
->catalogVisible()
|
||||
->where('published_at', '>=', now()->subDays(90))
|
||||
->selectRaw('user_id, COUNT(*) as recent_views, MAX(published_at) as latest_published')
|
||||
->groupBy('user_id');
|
||||
@@ -346,8 +343,7 @@ final class DiscoverController extends Controller
|
||||
|
||||
$artworks = Cache::remember($cacheKey, 60, function () use ($user, $followingIds, $perPage): \Illuminate\Pagination\LengthAwarePaginator {
|
||||
return Artwork::query()
|
||||
->public()
|
||||
->published()
|
||||
->catalogVisible()
|
||||
->with(['user:id,name,username', 'categories:id,name,slug,content_type_id,parent_id,sort_order'])
|
||||
->whereIn('user_id', $followingIds)
|
||||
->orderMissingThumbnailsLast()
|
||||
@@ -414,8 +410,7 @@ final class DiscoverController extends Controller
|
||||
private function fallbackFreshFromDatabase(int $perPage)
|
||||
{
|
||||
return Artwork::query()
|
||||
->public()
|
||||
->published()
|
||||
->catalogVisible()
|
||||
->with([
|
||||
'user:id,name,username',
|
||||
'user.profile:user_id,avatar_hash',
|
||||
@@ -434,8 +429,7 @@ final class DiscoverController extends Controller
|
||||
$cutoff = now()->subDays($windowDays)->startOfDay();
|
||||
|
||||
return Artwork::query()
|
||||
->public()
|
||||
->published()
|
||||
->catalogVisible()
|
||||
->with([
|
||||
'user:id,name,username',
|
||||
'user.profile:user_id,avatar_hash',
|
||||
@@ -460,8 +454,7 @@ final class DiscoverController extends Controller
|
||||
$cutoff = now()->subDays($windowDays)->startOfDay();
|
||||
|
||||
return Artwork::query()
|
||||
->public()
|
||||
->published()
|
||||
->catalogVisible()
|
||||
->with([
|
||||
'user:id,name,username',
|
||||
'user.profile:user_id,avatar_hash',
|
||||
@@ -488,8 +481,7 @@ final class DiscoverController extends Controller
|
||||
$recentActivity = $this->risingRecentActivitySubquery();
|
||||
|
||||
return Artwork::query()
|
||||
->public()
|
||||
->published()
|
||||
->catalogVisible()
|
||||
->with([
|
||||
'user:id,name,username',
|
||||
'user.profile:user_id,avatar_hash',
|
||||
@@ -552,6 +544,7 @@ final class DiscoverController extends Controller
|
||||
}
|
||||
|
||||
$byId = Artwork::query()
|
||||
->catalogVisible()
|
||||
->whereIn('id', $ids)
|
||||
->with([
|
||||
'user:id,name,username',
|
||||
@@ -571,6 +564,10 @@ final class DiscoverController extends Controller
|
||||
return $this->presentArtwork($full);
|
||||
}
|
||||
|
||||
if ($id > 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (object) [
|
||||
'id' => $item->id ?? 0,
|
||||
'name' => $item->title ?? $item->name ?? 'Untitled',
|
||||
@@ -588,7 +585,7 @@ final class DiscoverController extends Controller
|
||||
'width' => isset($item->width) && $item->width ? (int) $item->width : null,
|
||||
'height' => isset($item->height) && $item->height ? (int) $item->height : null,
|
||||
];
|
||||
})
|
||||
})->filter()->values()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -680,8 +677,7 @@ final class DiscoverController extends Controller
|
||||
}
|
||||
|
||||
return Artwork::query()
|
||||
->public()
|
||||
->published()
|
||||
->catalogVisible()
|
||||
->with(['user:id,name,username', 'user.profile:user_id,avatar_hash', 'stats:artwork_id,views,favorites,comments_count,heat_score'])
|
||||
->whereIn('user_id', $followingIds)
|
||||
->where('published_at', '>=', now()->subDays(30))
|
||||
|
||||
@@ -6,6 +6,8 @@ namespace App\Http\Controllers\Web;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\Group;
|
||||
use App\Models\User;
|
||||
use App\Services\ArtworkSearchService;
|
||||
use App\Services\ContentTypes\ContentTypeSlugResolver;
|
||||
use App\Services\EarlyGrowth\EarlyGrowth;
|
||||
@@ -77,10 +79,12 @@ final class ExploreController extends Controller
|
||||
$page = max(1, (int) $request->query('page', 1));
|
||||
$ttl = self::SORT_TTL[$sort] ?? 300;
|
||||
$cacheVersion = $this->cacheVersion();
|
||||
$filter = $this->buildExploreFilterExpression($request);
|
||||
$cacheSuffix = $this->requestCacheSuffix($request);
|
||||
|
||||
$artworks = Cache::remember("explore.all.v{$cacheVersion}.{$sort}.{$page}", $ttl, fn () =>
|
||||
$artworks = Cache::remember("explore.all.v{$cacheVersion}.{$cacheSuffix}.{$page}", $ttl, fn () =>
|
||||
$this->search->searchWithThumbnailPreference([
|
||||
'filter' => 'is_public = true AND is_approved = true',
|
||||
'filter' => $filter,
|
||||
'sort' => self::SORT_MAP[$sort] ?? ['created_at:desc'],
|
||||
], $perPage, false, $page)
|
||||
);
|
||||
@@ -148,13 +152,10 @@ final class ExploreController extends Controller
|
||||
$page = max(1, (int) $request->query('page', 1));
|
||||
$ttl = self::SORT_TTL[$sort] ?? 300;
|
||||
$cacheVersion = $this->cacheVersion();
|
||||
$filter = $this->buildExploreFilterExpression($request, $isAll ? null : $resolvedTypeSlug);
|
||||
$cacheSuffix = $this->requestCacheSuffix($request);
|
||||
|
||||
$filter = 'is_public = true AND is_approved = true';
|
||||
if (!$isAll) {
|
||||
$filter .= ' AND content_type = "' . $type . '"';
|
||||
}
|
||||
|
||||
$artworks = Cache::remember("explore.{$resolvedTypeSlug}.v{$cacheVersion}.{$sort}.{$page}", $ttl, fn () =>
|
||||
$artworks = Cache::remember("explore.{$resolvedTypeSlug}.v{$cacheVersion}.{$cacheSuffix}.{$page}", $ttl, fn () =>
|
||||
$this->search->searchWithThumbnailPreference([
|
||||
'filter' => $filter,
|
||||
'sort' => self::SORT_MAP[$sort] ?? ['created_at:desc'],
|
||||
@@ -288,11 +289,122 @@ final class ExploreController extends Controller
|
||||
return max(12, min($v, 80));
|
||||
}
|
||||
|
||||
private function requestCacheSuffix(Request $request): string
|
||||
{
|
||||
$query = $request->query();
|
||||
unset($query['grid'], $query['page']);
|
||||
ksort($query);
|
||||
|
||||
return md5(json_encode($query, JSON_THROW_ON_ERROR));
|
||||
}
|
||||
|
||||
private function cacheVersion(): int
|
||||
{
|
||||
return max(1, (int) Cache::get('explore.cache.version', 1));
|
||||
}
|
||||
|
||||
private function buildExploreFilterExpression(Request $request, ?string $contentType = null): string
|
||||
{
|
||||
$filterParts = [
|
||||
'is_public = true',
|
||||
'is_approved = true',
|
||||
];
|
||||
|
||||
if ($contentType !== null && $contentType !== '') {
|
||||
$filterParts[] = 'content_type = "' . addslashes($contentType) . '"';
|
||||
}
|
||||
|
||||
$orientation = strtolower(trim((string) $request->query('orientation', '')));
|
||||
if (in_array($orientation, ['landscape', 'portrait', 'square'], true)) {
|
||||
$filterParts[] = 'orientation = "' . addslashes($orientation) . '"';
|
||||
}
|
||||
|
||||
$resolution = $this->resolutionFilterValue((string) $request->query('resolution', ''));
|
||||
if ($resolution !== null) {
|
||||
$filterParts[] = 'resolution = "' . addslashes($resolution) . '"';
|
||||
}
|
||||
|
||||
$dateFrom = $this->normalizeDateQuery((string) $request->query('date_from', ''));
|
||||
if ($dateFrom !== null) {
|
||||
$filterParts[] = 'created_at >= "' . $dateFrom . '"';
|
||||
}
|
||||
|
||||
$dateTo = $this->normalizeDateQuery((string) $request->query('date_to', ''));
|
||||
if ($dateTo !== null) {
|
||||
$filterParts[] = 'created_at <= "' . $dateTo . '"';
|
||||
}
|
||||
|
||||
$authorFilter = $this->authorFilterExpression((string) $request->query('author', ''));
|
||||
if ($authorFilter !== null) {
|
||||
$filterParts[] = $authorFilter;
|
||||
}
|
||||
|
||||
return implode(' AND ', $filterParts);
|
||||
}
|
||||
|
||||
private function resolutionFilterValue(string $resolution): ?string
|
||||
{
|
||||
return match (strtolower(trim($resolution))) {
|
||||
'hd' => '1280x720',
|
||||
'fhd' => '1920x1080',
|
||||
'2k' => '2560x1440',
|
||||
'4k' => '3840x2160',
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
private function normalizeDateQuery(string $value): ?string
|
||||
{
|
||||
$value = trim($value);
|
||||
|
||||
if (! preg_match('/^\d{4}-\d{2}-\d{2}$/', $value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
private function authorFilterExpression(string $author): ?string
|
||||
{
|
||||
$author = trim($author);
|
||||
|
||||
if ($author === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$userIds = User::query()
|
||||
->where(function ($query) use ($author): void {
|
||||
$query->where('username', 'like', '%' . $author . '%')
|
||||
->orWhere('name', 'like', '%' . $author . '%');
|
||||
})
|
||||
->limit(20)
|
||||
->pluck('id');
|
||||
|
||||
$groupIds = Group::query()
|
||||
->where(function ($query) use ($author): void {
|
||||
$query->where('name', 'like', '%' . $author . '%')
|
||||
->orWhere('slug', 'like', '%' . $author . '%');
|
||||
})
|
||||
->limit(20)
|
||||
->pluck('id');
|
||||
|
||||
$clauses = [];
|
||||
|
||||
foreach ($userIds as $userId) {
|
||||
$clauses[] = '(author_id = ' . (int) $userId . ' AND published_as_type = "user")';
|
||||
}
|
||||
|
||||
foreach ($groupIds as $groupId) {
|
||||
$clauses[] = '(author_id = ' . (int) $groupId . ' AND published_as_type = "group")';
|
||||
}
|
||||
|
||||
if ($clauses === []) {
|
||||
return 'id = 0';
|
||||
}
|
||||
|
||||
return '(' . implode(' OR ', $clauses) . ')';
|
||||
}
|
||||
|
||||
private function filterBrowsableArtworks(AbstractPaginator $paginator): AbstractPaginator
|
||||
{
|
||||
$paginator->setCollection(
|
||||
|
||||
@@ -32,6 +32,7 @@ final class HelpCenterPageController extends Controller
|
||||
'links' => [
|
||||
'studio_help' => route('help.studio'),
|
||||
'upload_help' => route('help.upload'),
|
||||
'help_worlds' => route('help.worlds'),
|
||||
'groups_documentation' => route('help.groups'),
|
||||
'groups_quickstart' => route('help.groups.quickstart'),
|
||||
'groups_faq' => route('help.groups.faq'),
|
||||
@@ -42,10 +43,14 @@ final class HelpCenterPageController extends Controller
|
||||
'studio_home' => route('studio.index'),
|
||||
'studio_content' => route('studio.content'),
|
||||
'studio_artworks' => route('studio.artworks'),
|
||||
'studio_worlds' => route('studio.worlds.index'),
|
||||
'studio_worlds_create' => route('studio.worlds.create'),
|
||||
'studio_cards' => route('studio.cards.index'),
|
||||
'studio_drafts' => route('studio.drafts'),
|
||||
'cards_create' => route('studio.cards.create'),
|
||||
'upload' => route('upload'),
|
||||
'worlds_index' => route('worlds.index'),
|
||||
'create_world' => route('worlds.create.redirect'),
|
||||
'cards_index' => route('cards.index'),
|
||||
'help_cards' => route('help.cards'),
|
||||
'help_profile' => route('help.profile'),
|
||||
|
||||
@@ -21,6 +21,7 @@ class LeaderboardPageController extends Controller
|
||||
'artworks', Leaderboard::TYPE_ARTWORK => Leaderboard::TYPE_ARTWORK,
|
||||
'groups', Leaderboard::TYPE_GROUP => Leaderboard::TYPE_GROUP,
|
||||
'stories', Leaderboard::TYPE_STORY => Leaderboard::TYPE_STORY,
|
||||
'worlds', Leaderboard::TYPE_WORLD => Leaderboard::TYPE_WORLD,
|
||||
default => Leaderboard::TYPE_CREATOR,
|
||||
};
|
||||
|
||||
@@ -28,6 +29,7 @@ class LeaderboardPageController extends Controller
|
||||
Leaderboard::TYPE_GROUP => 'Top Groups Leaderboard — Skinbase',
|
||||
Leaderboard::TYPE_STORY => 'Top Stories Leaderboard — Skinbase',
|
||||
Leaderboard::TYPE_ARTWORK => 'Top Artworks Leaderboard — Skinbase',
|
||||
Leaderboard::TYPE_WORLD => 'Top Worlds Leaderboard — Skinbase',
|
||||
default => 'Top Creators & Artworks Leaderboard — Skinbase',
|
||||
};
|
||||
|
||||
@@ -35,7 +37,8 @@ class LeaderboardPageController extends Controller
|
||||
Leaderboard::TYPE_GROUP => 'Track the leading groups across Skinbase by daily, weekly, monthly, and all-time performance.',
|
||||
Leaderboard::TYPE_STORY => 'Track the leading stories across Skinbase by daily, weekly, monthly, and all-time performance.',
|
||||
Leaderboard::TYPE_ARTWORK => 'Track the leading artworks across Skinbase by daily, weekly, monthly, and all-time performance.',
|
||||
default => 'Track the leading creators, groups, artworks, and stories across Skinbase by daily, weekly, monthly, and all-time performance.',
|
||||
Leaderboard::TYPE_WORLD => 'Track the leading Worlds across Skinbase by daily, weekly, monthly, and all-time performance.',
|
||||
default => 'Track the leading creators, groups, artworks, stories, and Worlds across Skinbase by daily, weekly, monthly, and all-time performance.',
|
||||
};
|
||||
|
||||
return Inertia::render('Leaderboard/LeaderboardPage', [
|
||||
|
||||
@@ -5,8 +5,10 @@ declare(strict_types=1);
|
||||
namespace App\Http\Controllers\Web;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Resources\ArtworkListResource;
|
||||
use App\Services\ArtworkSearchService;
|
||||
use App\Services\GroupDiscoveryService;
|
||||
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
use cPad\Plugins\News\Models\NewsArticle;
|
||||
@@ -62,9 +64,18 @@ final class SearchController extends Controller
|
||||
$groupResultCount = $groupResults->count();
|
||||
$newsResultCount = $newsResults->count();
|
||||
$hasAnyResults = $resultCount > 0 || $groupResultCount > 0 || $newsResultCount > 0;
|
||||
$galleryArtworks = collect(method_exists($artworks, 'items') ? $artworks->items() : $artworks)
|
||||
->map(fn ($art) => $this->mapArtworkCard($art))
|
||||
->values();
|
||||
|
||||
$galleryItems = method_exists($artworks, 'getCollection')
|
||||
? $artworks->getCollection()
|
||||
: new EloquentCollection(collect($artworks)->all());
|
||||
|
||||
$galleryItems->loadMissing(['user.profile', 'group', 'categories.contentType']);
|
||||
|
||||
$galleryArtworks = $galleryItems
|
||||
->map(fn ($artwork) => (new ArtworkListResource($artwork))->resolve($request))
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$galleryNextPageUrl = method_exists($artworks, 'nextPageUrl') ? $artworks->nextPageUrl() : null;
|
||||
|
||||
return view('search.index', [
|
||||
@@ -87,28 +98,4 @@ final class SearchController extends Controller
|
||||
'page_robots' => 'noindex,follow',
|
||||
]);
|
||||
}
|
||||
|
||||
private function mapArtworkCard(mixed $artwork): array
|
||||
{
|
||||
return [
|
||||
'id' => $artwork->id ?? null,
|
||||
'name' => $artwork->name ?? null,
|
||||
'thumb' => $artwork->thumb_url ?? $artwork->thumb ?? null,
|
||||
'thumb_srcset' => $artwork->thumb_srcset ?? null,
|
||||
'uname' => $artwork->uname ?? '',
|
||||
'username' => $artwork->username ?? '',
|
||||
'avatar_url' => $artwork->avatar_url ?? null,
|
||||
'profile_url' => $artwork->profile_url ?? null,
|
||||
'published_as_type' => $artwork->published_as_type ?? null,
|
||||
'publisher' => $artwork->publisher ?? null,
|
||||
'category_name' => $artwork->category_name ?? '',
|
||||
'category_slug' => $artwork->category_slug ?? '',
|
||||
'slug' => $artwork->slug ?? '',
|
||||
'width' => $artwork->width ?? null,
|
||||
'height' => $artwork->height ?? null,
|
||||
'views' => $artwork->views ?? null,
|
||||
'likes' => $artwork->likes ?? null,
|
||||
'downloads' => $artwork->downloads ?? null,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
51
app/Http/Controllers/Web/WorldController.php
Normal file
51
app/Http/Controllers/Web/WorldController.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Web;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\World;
|
||||
use App\Services\Worlds\WorldService;
|
||||
use App\Support\Seo\SeoFactory;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
final class WorldController extends Controller
|
||||
{
|
||||
public function __construct(private readonly WorldService $worlds)
|
||||
{
|
||||
}
|
||||
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$payload = $this->worlds->publicIndexPayload($request->user());
|
||||
$seo = app(SeoFactory::class)->collectionListing(
|
||||
'Worlds — Skinbase Nova',
|
||||
$payload['description'],
|
||||
route('worlds.index'),
|
||||
)->toArray();
|
||||
|
||||
return Inertia::render('World/WorldIndex', array_merge($payload, [
|
||||
'seo' => $seo,
|
||||
]))->rootView('collections');
|
||||
}
|
||||
|
||||
public function show(Request $request, World $world): Response
|
||||
{
|
||||
abort_unless($world->isPubliclyVisible(), 404);
|
||||
|
||||
$payload = $this->worlds->publicShowPayload($world, $request->user());
|
||||
$seo = app(SeoFactory::class)->collectionPage(
|
||||
$world->seo_title ?: ($world->title . ' — Skinbase Nova'),
|
||||
$world->seo_description ?: ($world->summary ?: $world->description ?: 'Seasonal and editorial discovery world on Skinbase Nova.'),
|
||||
route('worlds.show', ['world' => $world->slug]),
|
||||
$world->ogImageUrl(),
|
||||
)->toArray();
|
||||
|
||||
return Inertia::render('World/WorldShow', array_merge($payload, [
|
||||
'seo' => $seo,
|
||||
]))->rootView('collections');
|
||||
}
|
||||
}
|
||||
51
app/Http/Controllers/Web/WorldsHelpPageController.php
Normal file
51
app/Http/Controllers/Web/WorldsHelpPageController.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Web;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Support\Seo\SeoFactory;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
final class WorldsHelpPageController extends Controller
|
||||
{
|
||||
public function __invoke(Request $request): Response
|
||||
{
|
||||
$canonical = route('help.worlds');
|
||||
$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.',
|
||||
$canonical,
|
||||
)
|
||||
->toArray();
|
||||
|
||||
$seo['og_type'] = 'article';
|
||||
|
||||
return Inertia::render('Help/WorldsHelpPage', [
|
||||
'title' => 'Worlds Help',
|
||||
'description' => 'A complete guide to creating, attaching content to, previewing, and publishing Worlds on Skinbase Nova.',
|
||||
'seo' => $seo,
|
||||
'links' => [
|
||||
'help_home' => route('help'),
|
||||
'studio_help' => route('help.studio'),
|
||||
'upload_help' => route('help.upload'),
|
||||
'help_cards' => route('help.cards'),
|
||||
'groups_help' => route('help.groups'),
|
||||
'worlds_index' => route('worlds.index'),
|
||||
'create_world' => route('worlds.create.redirect'),
|
||||
'studio_worlds' => route('studio.worlds.index'),
|
||||
'studio_worlds_create' => route('studio.worlds.create'),
|
||||
'open_studio' => route('studio.index'),
|
||||
'contact_support' => route('contact.show'),
|
||||
'report_issue' => route('bug-report'),
|
||||
],
|
||||
'auth' => [
|
||||
'signed_in' => $request->user() !== null,
|
||||
],
|
||||
])->rootView('collections');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user