Optimize academy

This commit is contained in:
2026-06-09 13:16:01 +02:00
parent f89ee937c0
commit 5af95f6533
109 changed files with 6862 additions and 719 deletions

View File

@@ -399,6 +399,12 @@ GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
GOOGLE_REDIRECT_URI=/auth/google/callback
# Optional light theme feature
# Set LIGHT_THEME_ENABLED=true to allow a light theme in the client-side toggle.
# Set LIGHT_THEME_SHOW_SWITCH=true to display the theme switch in the toolbar.
LIGHT_THEME_ENABLED=false
LIGHT_THEME_SHOW_SWITCH=false
# Discord — https://discord.com/developers/applications
DISCORD_CLIENT_ID=
DISCORD_CLIENT_SECRET=

View File

@@ -92,9 +92,9 @@ final class AcademyBillingHealthCommand extends Command
*/
private function buildReport(): array
{
$stripeKey = (string) config('cashier.key', '');
$stripeSecret = (string) config('cashier.secret', env('STRIPE_SECRET', ''));
$webhookSecret = (string) config('cashier.webhook.secret', env('STRIPE_WEBHOOK_SECRET', ''));
$stripeKey = $this->configuredString(config('cashier.key'));
$stripeSecret = $this->firstConfiguredString(config('cashier.secret'), env('STRIPE_SECRET'));
$webhookSecret = $this->firstConfiguredString(config('cashier.webhook.secret'), env('STRIPE_WEBHOOK_SECRET'));
$currency = trim((string) config('cashier.currency', env('CASHIER_CURRENCY', '')));
$currencyLocale = trim((string) config('cashier.currency_locale', env('CASHIER_CURRENCY_LOCALE', '')));
$academyEnabled = (bool) config('academy.enabled', true);
@@ -285,4 +285,21 @@ final class AcademyBillingHealthCommand extends Command
return self::SUCCESS;
}
private function firstConfiguredString(mixed ...$values): string
{
foreach ($values as $value) {
$value = $this->configuredString($value);
if ($value !== '') {
return $value;
}
}
return '';
}
private function configuredString(mixed $value): string
{
return is_string($value) ? trim($value) : '';
}
}

View File

@@ -12,7 +12,14 @@ use App\Support\AcademyAnalytics\AcademyAnalyticsContentType;
use App\Support\Seo\SeoFactory;
use Laravel\Cashier\Checkout;
use Laravel\Cashier\Subscription;
use Stripe\Exception\InvalidRequestException;
use Illuminate\Support\Facades\Log;
use App\Mail\AcademyAccessIssue;
use App\Models\StaffApplication;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
final class AcademyBillingController extends Controller
{
public function __construct(
@@ -48,6 +55,7 @@ final class AcademyBillingController extends Controller
'activePlanKey' => $activePlan['key'] ?? null,
'activePlanLabel' => $activePlan['label'] ?? null,
'catalog' => $this->catalog(),
'missingRemote' => $this->plans->missingRemotePriceIds(),
'links' => [
'login' => \route('login'),
'pricing' => \route('academy.pricing'),
@@ -64,7 +72,7 @@ final class AcademyBillingController extends Controller
'isGuest' => $user === null,
'isSubscriber' => $user?->hasAcademyCreatorAccess() || $user?->hasAcademyProAccess(),
],
])->rootView('collections');
])->rootView('academy');
}
public function checkout(\Illuminate\Http\Request $request): Checkout|\Illuminate\Http\JsonResponse|\Illuminate\Http\RedirectResponse
@@ -110,6 +118,46 @@ final class AcademyBillingController extends Controller
}
if ($this->access->hasActiveAcademySubscription($user)) {
// If the user already has an Academy subscription, allow an in-place upgrade
// (e.g. Creator -> Pro) by swapping the subscription to the requested price.
$subscription = $this->academySubscription($user);
$currentPlan = $this->activePlan($user);
// If current plan exists and the requested plan ranks higher, perform swap.
if ($currentPlan !== null && ($this->planRank((string) $plan['tier']) > $this->planRank((string) $currentPlan['tier']))) {
try {
if ($subscription instanceof Subscription) {
$subscription->swap((string) $plan['stripe_price_id']);
}
return \redirect()->route('academy.billing.account')->with('success', 'Subscription upgraded — your new plan is active.');
} catch (\Throwable $e) {
$context = [
'user_id' => $user->id ?? null,
'user_email' => $user->email ?? null,
'stripe_id' => $user->stripe_id ?? null,
'route' => 'academy.billing.checkout',
'attempt' => 'swap_subscription',
'plan_key' => $plan['key'] ?? null,
'plan_price_id' => $plan['stripe_price_id'] ?? null,
'request_ip' => $request->ip(),
'user_agent' => $request->header('User-Agent'),
'exception_class' => \get_class($e),
'exception_message' => $e->getMessage(),
'exception_code' => $e->getCode(),
'exception_trace' => \method_exists($e, 'getTraceAsString') ? $e->getTraceAsString() : null,
];
if (method_exists($e, 'getStripeCode')) {
$context['stripe_code'] = $e->getStripeCode();
}
Log::error('Academy billing: failed to swap subscription for upgrade', $context);
return $this->checkoutErrorResponse($request, $e);
}
}
return \redirect()->route('academy.billing.portal');
}
@@ -133,8 +181,91 @@ final class AcademyBillingController extends Controller
'academy_tier' => (string) $plan['tier'],
],
]);
} catch (InvalidRequestException $e) {
// Stripe returned a request error (e.g. missing/deleted customer). Try to recover once by
// clearing stored `stripe_id`, recreating the customer in Stripe and retrying the checkout.
if (str_contains($e->getMessage(), 'No such customer')) {
try {
$user->forceFill(['stripe_id' => null])->save();
// Create a fresh Stripe customer and persist the id
if (method_exists($user, 'createAsStripeCustomer')) {
$user->createAsStripeCustomer();
} else {
// fallback to createOrGet behavior
$user->createOrGetStripeCustomer();
}
return $user
->newSubscription($this->plans->subscriptionName(), (string) $plan['stripe_price_id'])
->withMetadata([
'skinbase_module' => 'academy',
'user_id' => (string) $user->id,
'academy_plan' => (string) $plan['key'],
'academy_tier' => (string) $plan['tier'],
])
->checkout([
'success_url' => \route('academy.billing.success').'?session_id={CHECKOUT_SESSION_ID}',
'cancel_url' => \route('academy.billing.cancel'),
'allow_promotion_codes' => true,
'metadata' => [
'skinbase_module' => 'academy',
'user_id' => (string) $user->id,
'academy_plan' => (string) $plan['key'],
'academy_tier' => (string) $plan['tier'],
],
]);
} catch (\Throwable $inner) {
$context = [
'user_id' => $user->id ?? null,
'user_email' => $user->email ?? null,
'stripe_id' => $user->stripe_id ?? null,
'route' => 'academy.billing.checkout',
'attempt' => 'recreate_customer_and_checkout',
'plan_key' => $plan['key'] ?? null,
'plan_price_id' => $plan['stripe_price_id'] ?? null,
'request_ip' => $request->ip(),
'user_agent' => $request->header('User-Agent'),
'exception_class' => \get_class($inner),
'exception_message' => $inner->getMessage(),
'exception_code' => $inner->getCode(),
'exception_trace' => \method_exists($inner, 'getTraceAsString') ? $inner->getTraceAsString() : null,
];
if (method_exists($inner, 'getStripeCode')) {
$context['stripe_code'] = $inner->getStripeCode();
}
Log::error('Academy billing: failed to recover Stripe customer and start checkout', $context);
return $this->checkoutErrorResponse($request, $inner);
}
}
// Not a recoverable customer-missing error; rethrow to be handled below
throw $e;
} catch (\Throwable $exception) {
\report($exception);
$context = [
'user_id' => $user->id ?? null,
'user_email' => $user->email ?? null,
'stripe_id' => $user->stripe_id ?? null,
'route' => 'academy.billing.checkout',
'attempt' => 'start_checkout',
'plan_key' => $plan['key'] ?? null,
'plan_price_id' => $plan['stripe_price_id'] ?? null,
'request_ip' => $request->ip(),
'user_agent' => $request->header('User-Agent'),
'exception_class' => \get_class($exception),
'exception_message' => $exception->getMessage(),
'exception_code' => $exception->getCode(),
'exception_trace' => \method_exists($exception, 'getTraceAsString') ? $exception->getTraceAsString() : null,
];
if (method_exists($exception, 'getStripeCode')) {
$context['stripe_code'] = $exception->getStripeCode();
}
Log::error('Academy billing: unexpected error starting checkout', $context);
return $this->checkoutErrorResponse($request, $exception);
}
@@ -161,7 +292,68 @@ final class AcademyBillingController extends Controller
return \redirect()->route('academy.billing.account')->with('error', 'No Stripe billing profile is connected to this account yet.');
}
try {
return $user->redirectToBillingPortal(\route('academy.billing.account'));
} catch (\Exception $e) {
// If the Stripe customer was deleted or invalid, attempt a recovery similar to checkout.
if ($e instanceof \Stripe\Exception\InvalidRequestException && str_contains($e->getMessage(), 'No such customer')) {
try {
$user->forceFill(['stripe_id' => null])->save();
if (method_exists($user, 'createAsStripeCustomer')) {
$user->createAsStripeCustomer();
} else {
$user->createOrGetStripeCustomer();
}
return $user->redirectToBillingPortal(\route('academy.billing.account'));
} catch (\Throwable $inner) {
$context = [
'user_id' => $user->id ?? null,
'user_email' => $user->email ?? null,
'stripe_id' => $user->stripe_id ?? null,
'route' => 'academy.billing.portal',
'attempt' => 'recreate_customer_and_redirect',
'request_ip' => $request->ip(),
'user_agent' => $request->header('User-Agent'),
'exception_class' => \get_class($inner),
'exception_message' => $inner->getMessage(),
'exception_code' => $inner->getCode(),
'exception_trace' => \method_exists($inner, 'getTraceAsString') ? $inner->getTraceAsString() : null,
];
if (method_exists($inner, 'getStripeCode')) {
$context['stripe_code'] = $inner->getStripeCode();
}
Log::error('Academy billing: failed to recover Stripe customer and open billing portal', $context);
return \redirect()->route('academy.billing.account')->with('error', 'Could not open the subscription manager. Please email academy@skinbase.org with your account details and checkout session id if available.');
}
}
$context = [
'user_id' => $user->id ?? null,
'user_email' => $user->email ?? null,
'stripe_id' => $user->stripe_id ?? null,
'route' => 'academy.billing.portal',
'attempt' => 'redirect_to_portal',
'request_ip' => $request->ip(),
'user_agent' => $request->header('User-Agent'),
'exception_class' => \get_class($e),
'exception_message' => $e->getMessage(),
'exception_code' => $e->getCode(),
'exception_trace' => \method_exists($e, 'getTraceAsString') ? $e->getTraceAsString() : null,
];
if (method_exists($e, 'getStripeCode')) {
$context['stripe_code'] = $e->getStripeCode();
}
Log::error('Academy billing: could not open Stripe billing portal', $context);
return \redirect()->route('academy.billing.account')->with('error', 'Could not open the subscription manager. Please email academy@skinbase.org with your account details and checkout session id if available.');
}
}
public function success(\Illuminate\Http\Request $request): \Inertia\Response
@@ -180,9 +372,10 @@ final class AcademyBillingController extends Controller
'pricing' => \route('academy.pricing'),
'account' => $user ? \route('academy.billing.account') : null,
'academy' => \route('academy.index'),
'reportIssue' => $user ? \route('academy.billing.report_issue') : null,
],
'sessionId' => $request->query('session_id'),
])->rootView('collections');
])->rootView('academy');
}
public function cancel(): \Inertia\Response
@@ -195,7 +388,103 @@ final class AcademyBillingController extends Controller
'pricing' => \route('academy.pricing'),
'academy' => \route('academy.index'),
],
])->rootView('collections');
])->rootView('academy');
}
public function reportIssue(\Illuminate\Http\Request $request): \Illuminate\Http\RedirectResponse
{
/** @var User|null $user */
$user = $request->user();
if (! $user instanceof User) {
return redirect()->route('login');
}
$validated = $request->validate([
'message' => ['nullable', 'string', 'max:2000'],
'session_id' => ['nullable', 'string'],
'issue_type' => ['nullable', 'string', 'in:billing,payment,upgrade,downgrade,cancel,access,other'],
'contact_email' => ['nullable', 'email:rfc', 'max:255'],
]);
$payload = [
'id' => (string) Str::uuid(),
'submitted_at' => now()->toISOString(),
'ip' => $request->ip(),
'user_agent' => $request->userAgent(),
'data' => [
'topic' => 'contact',
'name' => (string) ($user->name ?: $user->username ?: 'Academy billing user'),
'email' => (string) ($validated['contact_email'] ?? $user->email),
'message' => $validated['message'] ?? null,
'issue_type' => $validated['issue_type'] ?? 'billing',
'session_id' => $validated['session_id'] ?? $request->query('session_id'),
'source' => 'academy_billing',
'user_id' => (string) $user->id,
'account_email' => (string) $user->email,
'current_url' => $request->fullUrl(),
],
];
try {
try {
Storage::append('staff_applications.jsonl', json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
} catch (\Throwable $e) {
// best-effort store; do not fail the user when file storage is unavailable
}
$application = null;
try {
$application = StaffApplication::create([
'id' => $payload['id'],
'topic' => 'contact',
'name' => $payload['data']['name'],
'email' => $payload['data']['email'],
'role' => 'academy_billing_support',
'portfolio' => null,
'message' => $payload['data']['message'],
'payload' => $payload,
'ip' => $payload['ip'],
'user_agent' => $payload['user_agent'],
]);
} catch (\Throwable $e) {
// ignore DB errors and fall back to a lightweight model for mail
}
$to = config('mail.from.address');
if ($to) {
if (! $application) {
$application = new StaffApplication([
'topic' => 'contact',
'name' => $payload['data']['name'],
'email' => $payload['data']['email'],
'role' => 'academy_billing_support',
'message' => $payload['data']['message'],
'payload' => $payload,
'ip' => $payload['ip'],
'user_agent' => $payload['user_agent'],
]);
$application->id = $payload['id'];
$application->created_at = now();
}
Mail::to($to)->send(new AcademyAccessIssue(
$user,
$payload['data']['message'] ?? null,
$payload['data']['session_id'] ?? null,
$payload['data']['issue_type'] ?? null,
$payload['data']['email'] ?? null,
));
}
return redirect()->back()->with('success', 'Support request sent — we will verify and activate your access shortly.');
} catch (\Throwable $e) {
report($e);
return redirect()->back()->with('error', 'Could not send the support request. Please try again later or email academy@skinbase.org.');
}
}
public function account(\Illuminate\Http\Request $request): \Inertia\Response
@@ -230,8 +519,10 @@ final class AcademyBillingController extends Controller
'portal' => \route('academy.billing.portal'),
'pricing' => \route('academy.pricing'),
'academy' => \route('academy.index'),
'checkout' => \route('academy.billing.checkout'),
'reportIssue' => \route('academy.billing.report_issue'),
],
])->rootView('collections');
])->rootView('academy');
}
/**
@@ -279,6 +570,7 @@ final class AcademyBillingController extends Controller
'price_display' => $plan['price_display'],
'configured' => $plan['configured'],
'price_id_valid' => $plan['price_id_valid'],
'remote_price_exists' => $plan['remote_price_exists'] ?? false,
]] : [];
return [

View File

@@ -64,7 +64,7 @@ final class AcademyChallengeController extends Controller
'isGuest' => $request->user() === null,
'isSubscriber' => $request->user()?->hasAcademyCreatorAccess() || $request->user()?->hasAcademyProAccess(),
],
])->rootView('collections');
])->rootView('academy');
}
public function show(Request $request, string $slug): Response
@@ -126,6 +126,6 @@ final class AcademyChallengeController extends Controller
'isSubscriber' => $request->user()?->hasAcademyCreatorAccess() || $request->user()?->hasAcademyProAccess(),
'isLocked' => (bool) ($payload['locked'] ?? false),
],
])->rootView('collections');
])->rootView('academy');
}
}

View File

@@ -47,7 +47,7 @@ final class AcademyChallengeSubmissionController extends Controller
'published_at' => $artwork->published_at?->toISOString(),
])->values()->all(),
'submitUrl' => route('academy.challenges.submit.store', ['slug' => $challenge->slug]),
])->rootView('collections');
])->rootView('academy');
}
public function store(StoreAcademyChallengeSubmissionRequest $request, string $slug): RedirectResponse

View File

@@ -102,7 +102,7 @@ final class AcademyCourseController extends Controller
'isGuest' => $request->user() === null,
'isSubscriber' => $request->user()?->hasAcademyCreatorAccess() || $request->user()?->hasAcademyProAccess(),
],
])->rootView('collections');
])->rootView('academy');
}
public function show(Request $request, AcademyCourse $course): Response
@@ -218,6 +218,6 @@ final class AcademyCourseController extends Controller
'isSubscriber' => $request->user()?->hasAcademyCreatorAccess() || $request->user()?->hasAcademyProAccess(),
'isLocked' => false,
],
])->rootView('collections');
])->rootView('academy');
}
}

View File

@@ -119,6 +119,6 @@ final class AcademyCourseLessonController extends Controller
],
'outline' => $courseOutline,
],
])->rootView('collections');
])->rootView('academy');
}
}

View File

@@ -98,6 +98,6 @@ final class AcademyHomeController extends Controller
'isGuest' => $request->user() === null,
'isSubscriber' => $request->user()?->hasAcademyCreatorAccess() || $request->user()?->hasAcademyProAccess(),
],
])->rootView('collections');
])->rootView('academy');
}
}

View File

@@ -112,7 +112,7 @@ final class AcademyLessonController extends Controller
'isGuest' => $request->user() === null,
'isSubscriber' => $request->user()?->hasAcademyCreatorAccess() || $request->user()?->hasAcademyProAccess(),
],
])->rootView('collections');
])->rootView('academy');
}
public function show(Request $request, string $slug): Response
@@ -220,6 +220,6 @@ final class AcademyLessonController extends Controller
'isSubscriber' => $request->user()?->hasAcademyCreatorAccess() || $request->user()?->hasAcademyProAccess(),
'isLocked' => (bool) ($payload['locked'] ?? false),
],
])->rootView('collections');
])->rootView('academy');
}
}

View File

@@ -79,6 +79,6 @@ final class AcademyPricingController extends Controller
'isGuest' => $request->user() === null,
'isSubscriber' => $request->user()?->hasAcademyCreatorAccess() || $request->user()?->hasAcademyProAccess(),
],
])->rootView('collections');
])->rootView('academy');
}
}

View File

@@ -126,7 +126,7 @@ final class AcademyPromptController extends Controller
'isGuest' => $request->user() === null,
'isSubscriber' => $request->user()?->hasAcademyCreatorAccess() || $request->user()?->hasAcademyProAccess(),
],
])->rootView('collections');
])->rootView('academy');
}
public function popular(Request $request): Response
@@ -260,7 +260,7 @@ final class AcademyPromptController extends Controller
'isGuest' => $request->user() === null,
'isSubscriber' => $request->user()?->hasAcademyCreatorAccess() || $request->user()?->hasAcademyProAccess(),
],
])->rootView('collections');
])->rootView('academy');
}
/**
@@ -366,7 +366,7 @@ final class AcademyPromptController extends Controller
'isSubscriber' => $request->user()?->hasAcademyCreatorAccess() || $request->user()?->hasAcademyProAccess(),
'isLocked' => (bool) ($payload['locked'] ?? false),
],
])->rootView('collections');
])->rootView('academy');
}
/**

View File

@@ -64,7 +64,7 @@ final class AcademyPromptPackController extends Controller
'isGuest' => $request->user() === null,
'isSubscriber' => $request->user()?->hasAcademyCreatorAccess() || $request->user()?->hasAcademyProAccess(),
],
])->rootView('collections');
])->rootView('academy');
}
public function show(Request $request, string $slug): Response
@@ -110,6 +110,6 @@ final class AcademyPromptPackController extends Controller
'isSubscriber' => $request->user()?->hasAcademyCreatorAccess() || $request->user()?->hasAcademyProAccess(),
'isLocked' => (bool) ($payload['locked'] ?? false),
],
])->rootView('collections');
])->rootView('academy');
}
}

View File

@@ -30,6 +30,7 @@ final class ArtworkTagController extends Controller
$queueConnection = (string) config('queue.default', 'sync');
$visionEnabled = (bool) config('vision.enabled', true);
$autoTaggingEnabled = (bool) config('vision.auto_tagging.enabled', false);
$queuedCount = 0;
$failedCount = 0;
@@ -56,7 +57,7 @@ final class ArtworkTagController extends Controller
$triggered = false;
$shouldTrigger = request()->boolean('trigger', false);
if ($shouldTrigger && $visionEnabled && ! empty($artwork->hash) && $queuedCount === 0) {
if ($shouldTrigger && $visionEnabled && $autoTaggingEnabled && ! empty($artwork->hash) && $queuedCount === 0) {
AutoTagArtworkJob::dispatch((int) $artwork->id, (string) $artwork->hash);
$triggered = true;
$queuedCount = max(1, $queuedCount);
@@ -89,6 +90,7 @@ final class ArtworkTagController extends Controller
'queued_jobs' => $queuedCount,
'failed_jobs' => $failedCount,
'triggered' => $triggered,
'auto_tagging_enabled' => $autoTaggingEnabled,
'ai_tag_count' => (int) $tags->where('is_ai', true)->count(),
'total_tag_count' => (int) $tags->count(),
],

View File

@@ -239,10 +239,18 @@ final class UploadController extends Controller
}
// Derivatives are available now; dispatch AI auto-tagging.
if ((bool) config('vision.auto_tagging.enabled', false)) {
AutoTagArtworkJob::dispatch($artworkId, $validated->hash)->afterCommit();
}
if ((bool) config('vision.upload.maturity.enabled', false)) {
DetectArtworkMaturityJob::dispatch($artworkId, $validated->hash)->afterCommit();
}
if ((bool) config('vision.upload.embeddings.enabled', true)) {
GenerateArtworkEmbeddingJob::dispatch($artworkId, $validated->hash)->afterCommit();
}
if ((bool) config('vision.upload.ai_assist.enabled', false)) {
AnalyzeArtworkAiAssistJob::dispatch($artworkId)->afterCommit();
}
return UploadSessionStatus::PROCESSED;
});

View File

@@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Moderation;
use App\Http\Controllers\Controller;
use App\Models\StaffApplication;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
final class StaffApplicationsController extends Controller
{
public function index(Request $request): Response
{
$filters = [
'q' => trim((string) $request->query('q', '')),
'topic' => trim((string) $request->query('topic', 'all')),
];
$query = StaffApplication::query()->latest('created_at');
if ($filters['q'] !== '') {
$search = $filters['q'];
$query->where(function ($builder) use ($search): void {
$builder
->where('name', 'like', '%' . $search . '%')
->orWhere('email', 'like', '%' . $search . '%')
->orWhere('role', 'like', '%' . $search . '%')
->orWhere('message', 'like', '%' . $search . '%');
});
}
if ($filters['topic'] !== '' && $filters['topic'] !== 'all') {
$query->where('topic', $filters['topic']);
}
$items = $query
->paginate(20)
->withQueryString()
->through(fn (StaffApplication $application): array => $this->serializeApplication($application));
$stats = [
'total' => StaffApplication::query()->count(),
'applications' => StaffApplication::query()->where('topic', 'application')->count(),
'bug' => StaffApplication::query()->where('topic', 'bug')->count(),
'contact' => StaffApplication::query()->where('topic', 'contact')->count(),
'other' => StaffApplication::query()->whereNotIn('topic', ['application', 'bug', 'contact'])->count(),
];
$topics = StaffApplication::query()
->select('topic')
->distinct()
->orderBy('topic')
->pluck('topic')
->values()
->all();
return Inertia::render('Moderation/StaffApplications/Index', [
'title' => 'Staff Applications',
'items' => $items,
'filters' => $filters,
'stats' => $stats,
'topics' => $topics,
'endpoints' => [
'index' => route('admin.staff-applications.index'),
],
])->rootView('moderation');
}
public function show(StaffApplication $staffApplication): Response
{
return Inertia::render('Moderation/StaffApplications/Show', [
'title' => 'Staff Application',
'item' => $this->serializeApplication($staffApplication, true),
'backUrl' => route('admin.staff-applications.index'),
])->rootView('moderation');
}
private function serializeApplication(StaffApplication $application, bool $detailed = false): array
{
return [
'id' => (string) $application->id,
'topic' => (string) ($application->topic ?: 'contact'),
'name' => (string) ($application->name ?: 'Unknown'),
'email' => (string) ($application->email ?: ''),
'role' => $application->role,
'portfolio' => $application->portfolio,
'message' => $application->message,
'ip' => $application->ip,
'user_agent' => $application->user_agent,
'created_at' => optional($application->created_at)?->toIso8601String(),
'payload' => $detailed ? ($application->payload ?? []) : [],
'show_url' => route('admin.staff-applications.show', ['staffApplication' => $application]),
];
}
}

View File

@@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Moderation;
use App\Http\Controllers\Controller;
use App\Models\Story;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
final class StoriesController extends Controller
{
public function index(Request $request): Response
{
$filters = [
'q' => trim((string) $request->query('q', '')),
'status' => trim((string) $request->query('status', 'all')),
];
$query = Story::query()
->with('creator:id,name,username')
->latest('created_at')
->latest('id');
if ($filters['q'] !== '') {
$search = $filters['q'];
$query->where(function ($builder) use ($search): void {
$builder
->where('title', 'like', '%' . $search . '%')
->orWhere('slug', 'like', '%' . $search . '%')
->orWhereHas('creator', function ($creatorQuery) use ($search): void {
$creatorQuery
->where('name', 'like', '%' . $search . '%')
->orWhere('username', 'like', '%' . $search . '%');
});
});
}
if ($filters['status'] !== '' && $filters['status'] !== 'all') {
$query->where('status', $filters['status']);
}
$stories = $query
->paginate(24)
->withQueryString()
->through(fn (Story $story): array => $this->serializeStory($story));
$statsQuery = Story::query();
if ($filters['q'] !== '') {
$search = $filters['q'];
$statsQuery->where(function ($builder) use ($search): void {
$builder
->where('title', 'like', '%' . $search . '%')
->orWhere('slug', 'like', '%' . $search . '%')
->orWhereHas('creator', function ($creatorQuery) use ($search): void {
$creatorQuery
->where('name', 'like', '%' . $search . '%')
->orWhere('username', 'like', '%' . $search . '%');
});
});
}
$stats = [
'total' => (clone $statsQuery)->count(),
'published' => (clone $statsQuery)->where('status', 'published')->count(),
'draft' => (clone $statsQuery)->where('status', 'draft')->count(),
'scheduled' => (clone $statsQuery)->where('status', 'scheduled')->count(),
'pending_review' => (clone $statsQuery)->where('status', 'pending_review')->count(),
'archived' => (clone $statsQuery)->where('status', 'archived')->count(),
];
return Inertia::render('Moderation/Stories', [
'title' => 'Stories',
'stories' => $stories,
'filters' => $filters,
'stats' => $stats,
'endpoints' => [
'index' => route('admin.stories'),
],
])->rootView('moderation');
}
private function serializeStory(Story $story): array
{
return [
'id' => (int) $story->id,
'title' => (string) ($story->title ?: 'Untitled story'),
'slug' => (string) $story->slug,
'excerpt' => $story->excerpt,
'status' => (string) ($story->status ?: 'draft'),
'published_at' => optional($story->published_at)?->toIso8601String(),
'created_at' => optional($story->created_at)?->toIso8601String(),
'cover_url' => $story->coverUrl,
'public_url' => $story->url,
'open_url' => $story->status === 'published' ? $story->url : null,
'creator' => $story->creator ? [
'id' => (int) $story->creator->id,
'name' => (string) $story->creator->name,
'username' => (string) $story->creator->username,
] : null,
];
}
}

View File

@@ -0,0 +1,116 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Moderation;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Inertia\Inertia;
use Inertia\Response;
final class UsernameQueueController extends Controller
{
public function index(Request $request): Response
{
$filters = [
'q' => trim((string) $request->query('q', '')),
'status' => trim((string) $request->query('status', 'pending')),
];
$requestColumns = Schema::hasTable('username_approval_requests')
? Schema::getColumnListing('username_approval_requests')
: [];
$query = DB::table('username_approval_requests as requests')
->leftJoin('users', 'users.id', '=', 'requests.user_id')
->select([
'requests.id',
'requests.user_id',
'requests.requested_username',
'requests.status',
'requests.context',
'requests.similar_to',
'requests.review_note',
'requests.reviewed_at',
'requests.created_at',
'users.username as current_username',
'users.name as current_name',
])
->orderByDesc('requests.created_at');
if ($filters['status'] !== '' && $filters['status'] !== 'all') {
$query->where('requests.status', $filters['status']);
}
if ($filters['q'] !== '') {
$search = $filters['q'];
$query->where(function ($builder) use ($search): void {
$builder
->where('requests.requested_username', 'like', '%' . $search . '%')
->orWhere('requests.context', 'like', '%' . $search . '%')
->orWhere('users.username', 'like', '%' . $search . '%')
->orWhere('users.name', 'like', '%' . $search . '%');
});
}
$requests = $query->paginate(20)->withQueryString()->through(function ($row): array {
return [
'id' => (int) $row->id,
'user_id' => $row->user_id !== null ? (int) $row->user_id : null,
'requested_username' => (string) $row->requested_username,
'status' => (string) ($row->status ?? 'pending'),
'context' => $row->context ?? null,
'similar_to' => $row->similar_to ?? null,
'review_note' => $row->review_note ?? null,
'reviewed_at' => $this->serializeTimestamp($row->reviewed_at ?? null),
'created_at' => $this->serializeTimestamp($row->created_at ?? null),
'current_username' => $row->current_username,
'current_name' => $row->current_name,
'approve_url' => route('api.admin.usernames.approve', ['id' => $row->id]),
'reject_url' => route('api.admin.usernames.reject', ['id' => $row->id]),
];
});
$stats = [
'total' => Schema::hasTable('username_approval_requests') ? DB::table('username_approval_requests')->count() : 0,
'pending' => Schema::hasTable('username_approval_requests') ? DB::table('username_approval_requests')->where('status', 'pending')->count() : 0,
'approved' => Schema::hasTable('username_approval_requests') ? DB::table('username_approval_requests')->where('status', 'approved')->count() : 0,
'rejected' => Schema::hasTable('username_approval_requests') ? DB::table('username_approval_requests')->where('status', 'rejected')->count() : 0,
];
return Inertia::render('Moderation/UsernameQueue', [
'title' => 'Username Queue',
'requests' => $requests,
'stats' => $stats,
'filters' => $filters,
'options' => [
'statuses' => [
['value' => 'all', 'label' => 'All statuses'],
['value' => 'pending', 'label' => 'Pending'],
['value' => 'approved', 'label' => 'Approved'],
['value' => 'rejected', 'label' => 'Rejected'],
],
],
'endpoints' => [
'index' => route('admin.usernames'),
'refresh' => route('admin.usernames'),
],
])->rootView('moderation');
}
private function serializeTimestamp(mixed $value): ?string
{
if ($value === null || $value === '') {
return null;
}
try {
return \Illuminate\Support\Carbon::parse((string) $value)->toIso8601String();
} catch (\Throwable) {
return null;
}
}
}

View File

@@ -17,6 +17,7 @@ use App\Models\AcademyBadge;
use App\Models\AcademyCategory;
use App\Models\AcademyChallenge;
use App\Models\AcademyChallengeSubmission;
use App\Models\AcademyContentMetricDaily;
use App\Models\AcademyCourse;
use App\Models\AcademyCourseLesson;
use App\Models\AcademyCourseSection;
@@ -31,6 +32,7 @@ use App\Services\Academy\AcademyAdminBillingOverviewService;
use App\Services\Academy\AcademyCacheService;
use App\Services\Academy\AcademyCourseLessonOrderingService;
use App\Services\Academy\AcademyLessonMarkdownRenderer;
use App\Support\AcademyAnalytics\AcademyAnalyticsContentType;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
@@ -604,34 +606,48 @@ final class AcademyAdminController extends Controller
$meta = $this->resourceMeta($resource);
$search = trim((string) request()->query('search', ''));
$query = $meta['model']::query();
$filters = [
'search' => $search,
];
$summary = null;
if ($resource === 'courses') {
$query->withCount('courseLessons');
if ($search !== '') {
$query->where(function ($builder) use ($search): void {
$like = '%'.str_replace(['%', '_'], ['\\%', '\\_'], $search).'%';
$builder->where('title', 'like', $like)
->orWhere('slug', 'like', $like)
->orWhere('subtitle', 'like', $like)
->orWhere('excerpt', 'like', $like)
->orWhere('description', 'like', $like);
});
$this->applyCourseAdminSearch($query, $search);
}
$query->orderByDesc('is_featured')
->orderBy('order_num')
->orderByDesc('updated_at')
->orderByDesc('id');
} elseif ($resource === 'prompts') {
$query->with('category');
$query->withSum(['metrics as total_views' => function ($builder): void {
$builder->where('content_type', AcademyAnalyticsContentType::PROMPT);
}], 'views');
$promptFilters = [
'category' => (string) request()->query('category', 'all'),
'featured' => (string) request()->query('featured', 'all'),
'prompt_of_week' => (string) request()->query('prompt_of_week', 'all'),
'active' => (string) request()->query('active', 'all'),
'access_level' => (string) request()->query('access_level', 'all'),
'difficulty' => (string) request()->query('difficulty', 'all'),
'order' => (string) request()->query('order', 'updated_desc'),
];
$filters = array_merge($filters, $promptFilters);
$this->applyPromptAdminSearch($query, $search);
$this->applyPromptAdminFilters($query, $promptFilters);
$this->applyPromptAdminOrdering($query, $promptFilters['order']);
$summary = $this->promptAdminSummary($search, $promptFilters);
} else {
$query->latest('updated_at');
}
if ($resource === 'prompts') {
$query->with('category');
}
if ($resource === 'lessons') {
$query->with('courses:id,title');
}
@@ -646,48 +662,191 @@ final class AcademyAdminController extends Controller
'items' => $items,
'columns' => $meta['columns'],
'createUrl' => route($meta['route_base'].'.create'),
'filters' => [
'search' => $search,
'filters' => $filters,
'filterOptions' => $resource === 'prompts' ? [
'categories' => $this->promptAdminCategoryFilterOptions(),
'difficulty' => $this->filterOptionsWithAll($this->difficultyOptions(), 'All difficulties'),
'access' => $this->filterOptionsWithAll($this->accessOptions(), 'All access levels'),
'featured' => [
['value' => 'all', 'label' => 'Any featured state'],
['value' => 'yes', 'label' => 'Featured only'],
['value' => 'no', 'label' => 'Not featured'],
],
'promptOfWeek' => [
['value' => 'all', 'label' => 'Any weekly state'],
['value' => 'yes', 'label' => 'Prompt of the week'],
['value' => 'no', 'label' => 'Not prompt of the week'],
],
'active' => [
['value' => 'all', 'label' => 'Any visibility state'],
['value' => 'active', 'label' => 'Active only'],
['value' => 'inactive', 'label' => 'Inactive only'],
],
'order' => [
['value' => 'updated_desc', 'label' => 'Updated newest'],
['value' => 'updated_asc', 'label' => 'Updated oldest'],
['value' => 'views_desc', 'label' => 'Most viewed'],
['value' => 'title_asc', 'label' => 'Title A-Z'],
['value' => 'title_desc', 'label' => 'Title Z-A'],
['value' => 'access_asc', 'label' => 'Access'],
['value' => 'difficulty_asc', 'label' => 'Difficulty'],
['value' => 'featured_desc', 'label' => 'Featured first'],
],
] : null,
'summary' => $resource === 'courses' ? [
'total' => (int) $items->total(),
'published' => (int) (clone $meta['model']::query())->when($search !== '', function ($builder) use ($search): void {
$like = '%'.str_replace(['%', '_'], ['\\%', '\\_'], $search).'%';
$builder->where(function ($inner) use ($like): void {
$inner->where('title', 'like', $like)
->orWhere('slug', 'like', $like)
->orWhere('subtitle', 'like', $like)
->orWhere('excerpt', 'like', $like)
->orWhere('description', 'like', $like);
});
})->where('status', AcademyCourse::STATUS_PUBLISHED)->count(),
'featured' => (int) (clone $meta['model']::query())->when($search !== '', function ($builder) use ($search): void {
$like = '%'.str_replace(['%', '_'], ['\\%', '\\_'], $search).'%';
$builder->where(function ($inner) use ($like): void {
$inner->where('title', 'like', $like)
->orWhere('slug', 'like', $like)
->orWhere('subtitle', 'like', $like)
->orWhere('excerpt', 'like', $like)
->orWhere('description', 'like', $like);
});
})->where('is_featured', true)->count(),
'drafts' => (int) (clone $meta['model']::query())->when($search !== '', function ($builder) use ($search): void {
$like = '%'.str_replace(['%', '_'], ['\\%', '\\_'], $search).'%';
$builder->where(function ($inner) use ($like): void {
$inner->where('title', 'like', $like)
->orWhere('slug', 'like', $like)
->orWhere('subtitle', 'like', $like)
->orWhere('excerpt', 'like', $like)
->orWhere('description', 'like', $like);
});
})->where('status', AcademyCourse::STATUS_DRAFT)->count(),
] : null,
'published' => (int) (clone $meta['model']::query())->tap(fn ($builder) => $this->applyCourseAdminSearch($builder, $search))->where('status', AcademyCourse::STATUS_PUBLISHED)->count(),
'featured' => (int) (clone $meta['model']::query())->tap(fn ($builder) => $this->applyCourseAdminSearch($builder, $search))->where('is_featured', true)->count(),
'drafts' => (int) (clone $meta['model']::query())->tap(fn ($builder) => $this->applyCourseAdminSearch($builder, $search))->where('status', AcademyCourse::STATUS_DRAFT)->count(),
] : $summary,
]);
}
private function applyCourseAdminSearch($query, string $search): void
{
if ($search === '') {
return;
}
$like = '%'.str_replace(['%', '_'], ['\\%', '\\_'], $search).'%';
$query->where(function ($builder) use ($like): void {
$builder->where('title', 'like', $like)
->orWhere('slug', 'like', $like)
->orWhere('subtitle', 'like', $like)
->orWhere('excerpt', 'like', $like)
->orWhere('description', 'like', $like);
});
}
private function applyPromptAdminSearch($query, string $search): void
{
if ($search === '') {
return;
}
$like = '%'.str_replace(['%', '_'], ['\\%', '\\_'], $search).'%';
$query->where(function ($builder) use ($like): void {
$builder->where('title', 'like', $like)
->orWhere('slug', 'like', $like)
->orWhere('excerpt', 'like', $like)
->orWhere('prompt', 'like', $like)
->orWhere('negative_prompt', 'like', $like)
->orWhere('usage_notes', 'like', $like)
->orWhere('workflow_notes', 'like', $like)
->orWhereHas('category', function ($categoryQuery) use ($like): void {
$categoryQuery->where('name', 'like', $like);
});
});
}
private function applyPromptAdminFilters($query, array $filters, bool $includeAccessFilter = true): void
{
$category = (string) ($filters['category'] ?? 'all');
$featured = (string) ($filters['featured'] ?? 'all');
$promptOfWeek = (string) ($filters['prompt_of_week'] ?? 'all');
$active = (string) ($filters['active'] ?? 'all');
$accessLevel = (string) ($filters['access_level'] ?? 'all');
$difficulty = (string) ($filters['difficulty'] ?? 'all');
if ($category === 'uncategorized') {
$query->whereNull('category_id');
} elseif ($category !== '' && $category !== 'all' && ctype_digit($category)) {
$query->where('category_id', (int) $category);
}
if ($featured === 'yes') {
$query->where('featured', true);
} elseif ($featured === 'no') {
$query->where('featured', false);
}
if ($promptOfWeek === 'yes') {
$query->where('prompt_of_week', true);
} elseif ($promptOfWeek === 'no') {
$query->where('prompt_of_week', false);
}
if ($active === 'active') {
$query->where('active', true);
} elseif ($active === 'inactive') {
$query->where('active', false);
}
if ($includeAccessFilter && in_array($accessLevel, ['free', 'creator', 'pro'], true)) {
$query->where('access_level', $accessLevel);
}
if (in_array($difficulty, array_column($this->difficultyOptions(), 'value'), true)) {
$query->where('difficulty', $difficulty);
}
}
private function applyPromptAdminOrdering($query, string $order): void
{
match ($order) {
'updated_asc' => $query->orderBy('updated_at')->orderBy('id'),
'views_desc' => $query->orderByDesc('total_views')->orderByDesc('updated_at')->orderByDesc('id'),
'title_asc' => $query->orderBy('title')->orderByDesc('updated_at'),
'title_desc' => $query->orderByDesc('title')->orderByDesc('updated_at'),
'access_asc' => $query->orderByRaw("FIELD(access_level, 'free', 'creator', 'pro')")->orderBy('title'),
'difficulty_asc' => $query->orderBy('difficulty')->orderBy('title'),
'featured_desc' => $query->orderByDesc('featured')->orderByDesc('prompt_of_week')->orderBy('title'),
default => $query->orderByDesc('updated_at')->orderByDesc('id'),
};
}
private function promptAdminSummary(string $search, array $filters): array
{
$summaryQuery = AcademyPromptTemplate::query();
$accessSummaryQuery = AcademyPromptTemplate::query();
$this->applyPromptAdminSearch($summaryQuery, $search);
$this->applyPromptAdminFilters($summaryQuery, $filters);
$this->applyPromptAdminSearch($accessSummaryQuery, $search);
$this->applyPromptAdminFilters($accessSummaryQuery, $filters, false);
return [
'total' => (int) $summaryQuery->count(),
'active' => (int) (clone $summaryQuery)->where('active', true)->count(),
'featured' => (int) (clone $summaryQuery)->where('featured', true)->count(),
'promptOfWeek' => (int) (clone $summaryQuery)->where('prompt_of_week', true)->count(),
'access' => [
'free' => (int) (clone $accessSummaryQuery)->where('access_level', 'free')->count(),
'creator' => (int) (clone $accessSummaryQuery)->where('access_level', 'creator')->count(),
'pro' => (int) (clone $accessSummaryQuery)->where('access_level', 'pro')->count(),
],
];
}
private function promptAdminCategoryFilterOptions(): array
{
return AcademyCategory::query()
->where('type', 'prompt')
->orderBy('order_num')
->orderBy('name')
->get()
->map(fn (AcademyCategory $category): array => ['value' => (string) $category->id, 'label' => $category->name])
->prepend(['value' => 'uncategorized', 'label' => 'Uncategorized'])
->prepend(['value' => 'all', 'label' => 'All categories'])
->values()
->all();
}
private function filterOptionsWithAll(array $options, string $allLabel): array
{
return collect($options)
->map(fn (array $option): array => [
'value' => (string) ($option['value'] ?? ''),
'label' => (string) ($option['label'] ?? $option['value'] ?? ''),
])
->prepend(['value' => 'all', 'label' => $allLabel])
->values()
->all();
}
private function renderForm(string $resource, Model $record): Response
{
$meta = $this->resourceMeta($resource);
@@ -893,7 +1052,7 @@ final class AcademyAdminController extends Controller
'singular' => 'prompt template',
'subtitle' => 'Manage prompt previews, premium prompts, and prompt of the week.',
'route_base' => 'admin.academy.prompts',
'columns' => ['title', 'difficulty', 'access_level', 'prompt_of_week', 'active'],
'columns' => ['title', 'category_name', 'difficulty', 'access_level', 'prompt_of_week', 'active'],
'fields' => [
['name' => 'category_id', 'label' => 'Category', 'type' => 'select', 'options' => $this->categoryOptions('prompt')],
['name' => 'title', 'label' => 'Title', 'type' => 'text'],
@@ -907,6 +1066,7 @@ final class AcademyAdminController extends Controller
['name' => 'placeholders', 'label' => 'Placeholders JSON', 'type' => 'json'],
['name' => 'helper_prompts', 'label' => 'Helper Prompts JSON', 'type' => 'json'],
['name' => 'prompt_variants', 'label' => 'Prompt Variants JSON', 'type' => 'json'],
['name' => 'filled_examples', 'label' => 'Filled Examples JSON', 'type' => 'json'],
['name' => 'difficulty', 'label' => 'Difficulty', 'type' => 'select', 'options' => $this->difficultyOptions()],
['name' => 'access_level', 'label' => 'Access', 'type' => 'select', 'options' => $this->accessOptions()],
['name' => 'aspect_ratio', 'label' => 'Aspect Ratio', 'type' => 'text'],
@@ -1048,6 +1208,7 @@ final class AcademyAdminController extends Controller
'active' => (bool) $model->active,
'preview_image_url' => $this->resolvePromptPreviewImageUrl((string) ($model->preview_image ?? '')),
'comparisons_count' => count($this->serializePromptToolNotes((array) ($model->tool_notes ?? []))),
'views_count' => (int) ($model->total_views ?? 0),
'tags' => array_values(array_filter(array_map(static fn ($tag): string => trim((string) $tag), (array) ($model->tags ?? [])))),
'updated_at' => optional($model->updated_at)->toIso8601String(),
'preview_url' => route('academy.prompts.show', ['slug' => $model->slug]),
@@ -1167,6 +1328,7 @@ final class AcademyAdminController extends Controller
'placeholders' => $this->encodePrettyJsonForForm($record->placeholders),
'helper_prompts' => $this->encodePrettyJsonForForm($record->helper_prompts),
'prompt_variants' => $this->encodePrettyJsonForForm($record->prompt_variants),
'filled_examples' => $this->encodePrettyJsonForForm($record->filled_examples),
'difficulty' => (string) ($record->difficulty ?? 'beginner'),
'access_level' => (string) ($record->access_level ?? 'free'),
'aspect_ratio' => (string) ($record->aspect_ratio ?? ''),
@@ -2174,6 +2336,7 @@ final class AcademyAdminController extends Controller
$validated['placeholders'] = $this->normalizePromptPlaceholders($validated['placeholders'] ?? null);
$validated['helper_prompts'] = $this->normalizePromptHelperPrompts($validated['helper_prompts'] ?? null);
$validated['prompt_variants'] = $this->normalizePromptVariants($validated['prompt_variants'] ?? null);
$validated['filled_examples'] = $this->normalizePromptFilledExamples($validated['filled_examples'] ?? null);
$validated['tool_notes'] = $this->normalizePromptToolNotes((array) ($validated['tool_notes'] ?? []));
$previousToolNotes = $this->normalizePromptToolNotes((array) ($prompt?->tool_notes ?? []));
@@ -2382,6 +2545,56 @@ final class AcademyAdminController extends Controller
->all();
}
/**
* @return array<int, array<string, mixed>>
*/
private function normalizePromptFilledExamples(mixed $filledExamples): array
{
if (! is_array($filledExamples)) {
return [];
}
return collect($filledExamples)
->filter(static fn ($example): bool => is_array($example))
->map(function (array $example): array {
return [
'title' => $this->nullableTrimmedString($example['title'] ?? null),
'description' => $this->nullableTrimmedString($example['description'] ?? null),
'placeholder_values' => collect(is_array($example['placeholder_values'] ?? null) ? $example['placeholder_values'] : [])
->mapWithKeys(function ($value, $key): array {
$normalizedKey = trim((string) $key);
if ($normalizedKey === '') {
return [];
}
$normalizedValue = $this->normalizePromptJsonValue($value);
if ($normalizedValue === null || $normalizedValue === '' || $normalizedValue === []) {
return [];
}
return [$normalizedKey => $normalizedValue];
})
->all(),
'prompt' => $this->nullableTrimmedString($example['prompt'] ?? null),
'negative_prompt' => $this->nullableTrimmedString($example['negative_prompt'] ?? null),
];
})
->filter(function (array $example): bool {
return collect([
$example['title'] ?? null,
$example['description'] ?? null,
$example['prompt'] ?? null,
$example['negative_prompt'] ?? null,
$example['placeholder_values'] ?? null,
])->contains(fn ($item): bool => $item !== null && $item !== '' && $item !== []);
})
->take(5)
->values()
->all();
}
/**
* @return array<int, string>
*/

View File

@@ -27,8 +27,10 @@ class FeaturedArtworkAdminController extends Controller
{
$isAdminSurface = $request->routeIs('admin.artworks.featured.*');
$routePrefix = $isAdminSurface ? 'admin.artworks.featured.' : 'admin.cp.artworks.featured.';
$pageName = $isAdminSurface ? 'Moderation/FeaturedArtworks' : 'Collection/FeaturedArtworksAdmin';
$rootView = $isAdminSurface ? 'moderation' : 'collections';
return Inertia::render($isAdminSurface ? 'Admin/FeaturedArtworks' : 'Collection/FeaturedArtworksAdmin', array_merge(
return Inertia::render($pageName, array_merge(
$this->featuredArtworks->pageProps(),
[
'endpoints' => [
@@ -49,7 +51,7 @@ class FeaturedArtworkAdminController extends Controller
'robots' => 'index,follow',
],
],
))->rootView($isAdminSurface ? 'admin' : 'collections');
))->rootView($rootView);
}
public function search(Request $request): JsonResponse

View File

@@ -27,6 +27,7 @@ class UpsertAcademyPromptTemplateRequest extends FormRequest
'placeholders' => $this->normalizePlaceholders($this->input('placeholders')),
'helper_prompts' => $this->normalizeHelperPrompts($this->input('helper_prompts')),
'prompt_variants' => $this->normalizePromptVariants($this->input('prompt_variants')),
'filled_examples' => $this->normalizeFilledExamples($this->input('filled_examples')),
'tool_notes' => collect($this->input('tool_notes', []))
->filter(static fn ($note): bool => is_array($note) || is_string($note))
->map(function ($note): array|string {
@@ -59,8 +60,10 @@ class UpsertAcademyPromptTemplateRequest extends FormRequest
$promptId = $this->route('academyPromptTemplate')?->id;
return [
'category_id' => ['nullable', 'integer', 'exists:academy_categories,id'],
'new_category_name' => ['nullable', 'string', 'max:120'],
// Require either an existing category selection or a new category name.
'category_id' => ['nullable', 'integer', 'exists:academy_categories,id', 'required_without:new_category_name'],
'new_category_name' => ['nullable', 'string', 'max:120', 'required_without:category_id'],
'title' => ['required', 'string', 'max:180'],
'slug' => ['required', 'string', 'max:180', Rule::unique('academy_prompt_templates', 'slug')->ignore($promptId)],
'excerpt' => ['nullable', 'string'],
@@ -112,6 +115,12 @@ class UpsertAcademyPromptTemplateRequest extends FormRequest
'prompt_variants.*.risk_notes' => ['nullable', 'array'],
'prompt_variants.*.risk_notes.*' => ['nullable', 'string'],
'prompt_variants.*.active' => ['nullable', 'boolean'],
'filled_examples' => ['nullable', 'array', 'max:5'],
'filled_examples.*.title' => ['nullable', 'string', 'max:180'],
'filled_examples.*.description' => ['nullable', 'string'],
'filled_examples.*.placeholder_values' => ['nullable', 'array'],
'filled_examples.*.prompt' => ['required_with:filled_examples', 'string'],
'filled_examples.*.negative_prompt' => ['nullable', 'string'],
'difficulty' => ['required', 'string', Rule::in((array) config('academy.difficulty_levels', []))],
'access_level' => ['required', 'string', Rule::in(['free', 'creator', 'pro'])],
'aspect_ratio' => ['nullable', 'string', 'max:20'],
@@ -283,6 +292,53 @@ class UpsertAcademyPromptTemplateRequest extends FormRequest
->all();
}
private function normalizeFilledExamples(mixed $value): mixed
{
$value = $this->decodeStructuredInput($value);
if ($value === null) {
return [];
}
if (! is_array($value)) {
return $value;
}
$value = $this->normalizeStructuredObjectList($value, ['title', 'description', 'placeholder_values', 'prompt', 'negative_prompt']);
return collect($value)
->values()
->map(function ($example): mixed {
if (! is_array($example)) {
return $example;
}
return [
'title' => $this->normalizeOptionalString($example['title'] ?? null),
'description' => $this->normalizeOptionalString($example['description'] ?? null),
'placeholder_values' => is_array($example['placeholder_values'] ?? null) ? $example['placeholder_values'] : [],
'prompt' => $this->normalizeOptionalString($example['prompt'] ?? null),
'negative_prompt' => $this->normalizeOptionalString($example['negative_prompt'] ?? null),
];
})
->filter(function ($example): bool {
if (! is_array($example)) {
return true;
}
return collect([
$example['title'] ?? null,
$example['description'] ?? null,
$example['prompt'] ?? null,
$example['negative_prompt'] ?? null,
$example['placeholder_values'] ?? null,
])->contains(fn ($item): bool => $item !== null && $item !== '' && $item !== []);
})
->take(5)
->values()
->all();
}
private function normalizePromptVariants(mixed $value): mixed
{
$value = $this->decodeStructuredInput($value);

View File

@@ -54,6 +54,10 @@ final class AutoTagArtworkJob implements ShouldQueue
public function handle(TagService $tagService, TagNormalizer $normalizer, ?VisionService $vision = null): void
{
if (! (bool) config('vision.auto_tagging.enabled', false)) {
return;
}
$vision ??= app(VisionService::class);
if (! $vision->isEnabled()) {

View File

@@ -54,11 +54,19 @@ final class GenerateDerivativesJob implements ShouldQueue
}
// Auto-tagging is async and must never block publish.
if ((bool) config('vision.auto_tagging.enabled', false)) {
AutoTagArtworkJob::dispatch($this->artworkId, $this->hash)->afterCommit();
}
if ((bool) config('vision.upload.maturity.enabled', false)) {
DetectArtworkMaturityJob::dispatch($this->artworkId, $this->hash)->afterCommit();
}
if ((bool) config('vision.upload.embeddings.enabled', true)) {
GenerateArtworkEmbeddingJob::dispatch($this->artworkId, $this->hash)->afterCommit();
}
if ((bool) config('vision.upload.ai_assist.enabled', false)) {
AnalyzeArtworkAiAssistJob::dispatch($this->artworkId)->afterCommit();
}
}
public function failed(\Throwable $exception): void
{

View File

@@ -9,9 +9,11 @@ use App\Models\RecArtworkRec;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\Middleware\WithoutOverlapping;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
/**
* Compute tag-based (+ category boost) similarity for artworks.
@@ -30,6 +32,7 @@ final class RecComputeSimilarByTagsJob implements ShouldQueue
public function __construct(
private readonly ?int $artworkId = null,
private readonly int $batchSize = 200,
private readonly ?int $afterArtworkId = null,
) {
$queue = (string) config('recommendations.queue', 'default');
if ($queue !== '') {
@@ -37,6 +40,22 @@ final class RecComputeSimilarByTagsJob implements ShouldQueue
}
}
/**
* @return array<int, object>
*/
public function middleware(): array
{
if ($this->artworkId === null) {
return [];
}
return [
(new WithoutOverlapping('rec-similar-tags:'.$this->artworkId))
->expireAfter($this->timeout + 60)
->dontRelease(),
];
}
public function handle(): void
{
$modelVersion = (string) config('recommendations.similarity.model_version', 'sim_v1');
@@ -51,19 +70,68 @@ final class RecComputeSimilarByTagsJob implements ShouldQueue
->pluck('cnt', 'tag_id')
->all();
$query = Artwork::query()->public()->published()->select('id', 'user_id');
if ($this->artworkId !== null) {
$query->where('id', $this->artworkId);
$artwork = Artwork::query()->public()->published()->select('id', 'user_id')->find($this->artworkId);
if (! $artwork instanceof Artwork) {
return;
}
$query->chunkById($this->batchSize, function ($artworks) use (
$tagFreqs, $modelVersion, $candidatePool, $maxPerAuthor, $resultLimit
) {
foreach ($artworks as $artwork) {
$this->processArtwork($artwork, $tagFreqs, $modelVersion, $candidatePool, $maxPerAuthor, $resultLimit);
$this->processArtworkSafely($artwork, $tagFreqs, $modelVersion, $candidatePool, $maxPerAuthor, $resultLimit);
return;
}
$artworks = Artwork::query()
->public()
->published()
->select('id', 'user_id')
->when($this->afterArtworkId !== null, fn ($query) => $query->where('id', '>', $this->afterArtworkId))
->orderBy('id')
->limit($this->batchSize)
->get();
if ($artworks->isEmpty()) {
return;
}
foreach ($artworks as $artwork) {
$this->processArtworkSafely($artwork, $tagFreqs, $modelVersion, $candidatePool, $maxPerAuthor, $resultLimit);
}
if ($artworks->count() === $this->batchSize) {
static::dispatch(null, $this->batchSize, (int) $artworks->last()->id);
}
}
public function failed(\Throwable $exception): void
{
Log::error('[RecComputeSimilarByTags] Job failed permanently.', [
'artwork_id' => $this->artworkId,
'batch_size' => $this->batchSize,
'after_artwork_id' => $this->afterArtworkId,
'attempts' => $this->attempts(),
'exception_class' => $exception::class,
'exception_message' => $exception->getMessage(),
]);
}
private function processArtworkSafely(
Artwork $artwork,
array $tagFreqs,
string $modelVersion,
int $candidatePool,
int $maxPerAuthor,
int $resultLimit,
): void {
try {
$this->processArtwork($artwork, $tagFreqs, $modelVersion, $candidatePool, $maxPerAuthor, $resultLimit);
} catch (\Throwable $exception) {
Log::warning("[RecComputeSimilarByTags] Failed for artwork {$artwork->id}: {$exception->getMessage()}", [
'artwork_id' => $artwork->id,
'exception_class' => $exception::class,
]);
}
});
}
private function processArtwork(

View File

@@ -9,6 +9,7 @@ use App\Models\RecArtworkRec;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\Middleware\WithoutOverlapping;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\DB;
@@ -25,7 +26,10 @@ final class RecComputeSimilarHybridJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 2;
// This recompute is idempotent and already guards per-artwork execution.
// Keep retries to a minimum so transient failures do not turn into
// Horizon's max-attempt exception noise.
public int $tries = 1;
public int $timeout = 900;
public function __construct(
@@ -38,6 +42,24 @@ final class RecComputeSimilarHybridJob implements ShouldQueue
}
}
/**
* @return array<int, object>
*/
public function middleware(): array
{
if ($this->artworkId === null) {
return [];
}
return [
// Many artwork lifecycle events can queue this same recompute burstily.
// Keep only one in flight per artwork and drop overlapping duplicates.
(new WithoutOverlapping('rec-similar-hybrid:'.$this->artworkId))
->expireAfter($this->timeout + 60)
->dontRelease(),
];
}
public function handle(): void
{
$modelVersion = (string) config('recommendations.similarity.model_version', 'sim_v1');
@@ -50,26 +72,90 @@ final class RecComputeSimilarHybridJob implements ShouldQueue
? (array) config('recommendations.similarity.weights_with_vector')
: (array) config('recommendations.similarity.weights_without_vector');
$query = Artwork::query()->public()->published()->select('id', 'user_id');
if ($this->artworkId !== null) {
$query->where('id', $this->artworkId);
$artwork = Artwork::query()
->public()
->published()
->select('id', 'user_id')
->find($this->artworkId);
if (! $artwork instanceof Artwork) {
return;
}
$query->chunkById($this->batchSize, function ($artworks) use (
$this->processArtworkSafely(
collect([$artwork]),
$modelVersion,
$vectorEnabled,
$resultLimit,
$maxPerAuthor,
$minCatsTop12,
$weights,
);
return;
}
Artwork::query()
->public()
->published()
->select('id', 'user_id')
->chunkById($this->batchSize, function ($artworks) use (
$modelVersion, $vectorEnabled, $resultLimit, $maxPerAuthor, $minCatsTop12, $weights
) {
$this->processArtworkSafely(
$artworks,
$modelVersion,
$vectorEnabled,
$resultLimit,
$maxPerAuthor,
$minCatsTop12,
$weights,
);
});
}
public function failed(\Throwable $exception): void
{
Log::error('[RecComputeSimilarHybrid] Job failed permanently.', [
'artwork_id' => $this->artworkId,
'batch_size' => $this->batchSize,
'attempts' => $this->attempts(),
'exception_class' => $exception::class,
'exception_message' => $exception->getMessage(),
]);
}
/**
* @param iterable<Artwork> $artworks
*/
private function processArtworkSafely(
iterable $artworks,
string $modelVersion,
bool $vectorEnabled,
int $resultLimit,
int $maxPerAuthor,
int $minCatsTop12,
array $weights,
): void {
foreach ($artworks as $artwork) {
try {
$this->processArtwork(
$artwork, $modelVersion, $vectorEnabled, $resultLimit,
$maxPerAuthor, $minCatsTop12, $weights
$artwork,
$modelVersion,
$vectorEnabled,
$resultLimit,
$maxPerAuthor,
$minCatsTop12,
$weights,
);
} catch (\Throwable $e) {
Log::warning("[RecComputeSimilarHybrid] Failed for artwork {$artwork->id}: {$e->getMessage()}");
Log::warning("[RecComputeSimilarHybrid] Failed for artwork {$artwork->id}: {$e->getMessage()}", [
'artwork_id' => $artwork->id,
'exception_class' => $e::class,
]);
}
}
});
}
private function processArtwork(

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Mail;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels;
final class AcademyAccessIssue extends Mailable
{
use Queueable, SerializesModels;
public function __construct(
public readonly User $user,
public readonly ?string $message = null,
public readonly ?string $sessionId = null,
public readonly ?string $issueType = null,
public readonly ?string $contactEmail = null,
)
{
}
public function build(): self
{
$subject = 'Academy support request'.($this->issueType ? ' ['.$this->issueType.']' : '').' from '.$this->user->email;
$replyTo = trim((string) ($this->contactEmail ?: $this->user->email));
$mail = $this->subject($subject)
->view('emails.academy_access_issue')
->with([
'user' => $this->user,
'message' => $this->message,
'sessionId' => $this->sessionId,
'issueType' => $this->issueType,
'contactEmail' => $this->contactEmail,
]);
if ($replyTo !== '') {
$mail->replyTo($replyTo);
}
return $mail;
}
}

View File

@@ -28,6 +28,7 @@ class AcademyPromptTemplate extends Model
'placeholders',
'helper_prompts',
'prompt_variants',
'filled_examples',
'difficulty',
'access_level',
'aspect_ratio',
@@ -49,6 +50,7 @@ class AcademyPromptTemplate extends Model
'placeholders' => 'array',
'helper_prompts' => 'array',
'prompt_variants' => 'array',
'filled_examples' => 'array',
'featured' => 'boolean',
'prompt_of_week' => 'boolean',
'active' => 'boolean',
@@ -75,6 +77,11 @@ class AcademyPromptTemplate extends Model
return $this->hasMany(AcademySavedPrompt::class, 'prompt_template_id');
}
public function metrics(): HasMany
{
return $this->hasMany(AcademyContentMetricDaily::class, 'content_id');
}
public function packs(): BelongsToMany
{
return $this->belongsToMany(AcademyPromptPack::class, 'academy_prompt_pack_items', 'prompt_template_id', 'pack_id')

View File

@@ -344,7 +344,18 @@ final class AcademyAccessService
$previewImage = $this->promptPreviewImagePayload((string) ($prompt->preview_image ?? ''));
$documentation = $this->promptDocumentationPayload($prompt->documentation);
$placeholders = $this->promptPlaceholdersPayload((array) ($prompt->placeholders ?? []));
$allFilledExamples = $this->promptFilledExamplesPayload((array) ($prompt->filled_examples ?? []));
$filledExamplesTotal = count($allFilledExamples);
$hasFullFilledExamplesAccess = (bool) (($viewer?->hasAcademyProAccess() ?? false) || ($viewer?->hasStaffAccess() ?? false));
$hasPartialFilledExamplesAccess = (bool) ($viewer?->hasAcademyCreatorAccess() ?? false);
$visibleFilledExamples = match (true) {
! $includeFull => [],
$hasFullFilledExamplesAccess => $allFilledExamples,
$hasPartialFilledExamplesAccess => array_slice($allFilledExamples, 0, 2),
default => [],
};
$hasPlaceholderInputs = $this->promptHasPlaceholderInputs((string) $prompt->prompt, $placeholders);
$hasFilledExamples = $allFilledExamples !== [];
$hasHelperPrompts = $this->promptHelperPromptsPayload((array) ($prompt->helper_prompts ?? [])) !== [];
$hasPromptVariants = $this->promptVariantsPayload((array) ($prompt->prompt_variants ?? [])) !== [];
$helperPrompts = $authorized && $includeFull
@@ -367,6 +378,12 @@ final class AcademyAccessService
'documentation' => $documentation,
'placeholders' => $placeholders,
'has_placeholder_inputs' => $hasPlaceholderInputs,
'filled_examples' => $visibleFilledExamples,
'has_filled_examples' => $hasFilledExamples,
'filled_examples_total' => $filledExamplesTotal,
'can_access_filled_examples' => ($hasFullFilledExamplesAccess || $hasPartialFilledExamplesAccess) && $includeFull,
'has_more_filled_examples' => $filledExamplesTotal > count($visibleFilledExamples),
'has_full_filled_examples_access' => $hasFullFilledExamplesAccess,
'has_helper_prompts' => $hasHelperPrompts,
'has_prompt_variants' => $hasPromptVariants,
'helper_prompts' => $helperPrompts,
@@ -396,6 +413,47 @@ final class AcademyAccessService
];
}
/**
* @param array<int, mixed> $filledExamples
* @return array<int, array<string, mixed>>
*/
private function promptFilledExamplesPayload(array $filledExamples): array
{
return collect($filledExamples)
->filter(static fn ($example): bool => is_array($example))
->map(function (array $example): array {
return [
'title' => $this->nullableTrimmedString($example['title'] ?? null),
'description' => $this->nullableTrimmedString($example['description'] ?? null),
'placeholder_values' => collect(is_array($example['placeholder_values'] ?? null) ? $example['placeholder_values'] : [])
->mapWithKeys(function ($value, $key): array {
$normalizedKey = trim((string) $key);
if ($normalizedKey === '') {
return [];
}
return [$normalizedKey => $value];
})
->all(),
'prompt' => trim((string) ($example['prompt'] ?? '')),
'negative_prompt' => $this->nullableTrimmedString($example['negative_prompt'] ?? null),
];
})
->filter(function (array $example): bool {
return collect([
$example['title'] ?? null,
$example['description'] ?? null,
$example['prompt'] ?? null,
$example['negative_prompt'] ?? null,
$example['placeholder_values'] ?? null,
])->contains(fn ($item): bool => $item !== null && $item !== '' && $item !== []);
})
->take(5)
->values()
->all();
}
/**
* @param mixed $documentation
* @return array<string, mixed>

View File

@@ -5,8 +5,11 @@ declare(strict_types=1);
namespace App\Services\Academy;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Str;
use RuntimeException;
use Stripe\Exception\InvalidRequestException;
use Stripe\StripeClient;
final class AcademyBillingPlanService
{
@@ -64,6 +67,7 @@ final class AcademyBillingPlanService
$plan['stripe_price_id'] = trim((string) ($plan['stripe_price_id'] ?? ''));
$plan['configured'] = $plan['stripe_price_id'] !== '';
$plan['price_id_valid'] = $this->isValidPriceId($plan['stripe_price_id']);
$plan['remote_price_exists'] = $this->remotePriceExists($plan['stripe_price_id']);
$plan['price_display'] = $plan['amount'] !== '' ? $plan['amount'].' '.$plan['currency'] : null;
return $plan;
@@ -145,4 +149,86 @@ final class AcademyBillingPlanService
return preg_match('/^price_[A-Za-z0-9]+$/', $priceId) === 1;
}
public function remotePriceExists(?string $priceId): ?bool
{
$priceId = trim((string) $priceId);
if ($priceId === '') {
return false;
}
// Avoid calling Stripe in local/testing environments — assume exists there.
if (app()->environment(['local', 'testing'])) {
return true;
}
$cacheKey = 'academy.remote_price_exists:'.md5($priceId);
return Cache::remember($cacheKey, 300, function () use ($priceId): ?bool {
try {
$secret = $this->stripeSecret();
if ($secret === null) {
return null;
}
$client = new StripeClient($secret);
$price = $client->prices->retrieve($priceId, []);
// If Stripe returned an object with an id, it exists. Also ensure product exists where possible.
if (is_object($price) && ! empty($price->id)) {
return true;
}
return false;
} catch (InvalidRequestException $e) {
report($e);
return false;
} catch (\Throwable $e) {
report($e);
// Auth, network, or transient Stripe failures should not make
// public pricing look fully misconfigured.
return null;
}
});
}
private function stripeSecret(): ?string
{
foreach ([config('cashier.secret'), env('STRIPE_SECRET')] as $candidate) {
if (! is_string($candidate)) {
continue;
}
$candidate = trim($candidate);
if ($candidate !== '') {
return $candidate;
}
}
return null;
}
/**
* @return array<int, string>
*/
public function missingRemotePriceIds(?string $planKey = null): array
{
if ($planKey !== null) {
$plan = $this->plan($planKey);
return $plan !== null && $this->remotePriceExists($plan['stripe_price_id'] ?? '') === false
? [$this->normalizePlanKey($planKey)]
: [];
}
return collect(array_keys($this->plans()))
->filter(fn (string $key): bool => $this->remotePriceExists($this->plan($key)['stripe_price_id'] ?? '') === false)
->values()
->all();
}
}

View File

@@ -15,6 +15,7 @@ use App\Models\User;
use App\Services\Artworks\ArtworkDraftService;
use App\Services\Artworks\ArtworkPublicationService;
use App\Services\Maturity\ArtworkMaturityService;
use App\Services\Studio\StudioAiAssistService;
use App\Services\TagService;
use Carbon\CarbonInterface;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
@@ -189,7 +190,9 @@ final class UploadQueueService
$item = $this->itemQuery()->findOrFail($itemId);
$item->forceFill([
'processing_stage' => UploadBatchItem::STAGE_MATURITY_CHECK,
'processing_stage' => $this->uploadMaturityEnabled()
? UploadBatchItem::STAGE_MATURITY_CHECK
: UploadBatchItem::STAGE_FINALIZED,
'error_code' => null,
'error_message' => null,
'processed_at' => now(),
@@ -290,7 +293,7 @@ final class UploadQueueService
'apply_category' => $this->applyCategory($item, (int) ($params['category_id'] ?? 0)),
'apply_tags' => $this->applyTags($item, (array) ($params['tags'] ?? [])),
'set_visibility' => $this->setVisibility($item, (string) ($params['visibility'] ?? '')),
'generate_ai' => $this->retryProcessing($item),
'generate_ai' => $this->requestAiGeneration($item),
default => throw ValidationException::withMessages([
'action' => ['Unsupported upload queue action.'],
]),
@@ -341,16 +344,40 @@ final class UploadQueueService
$item->forceFill([
'status' => UploadBatchItem::STATUS_PROCESSING,
'processing_stage' => UploadBatchItem::STAGE_MATURITY_CHECK,
'processing_stage' => $this->uploadMaturityEnabled()
? UploadBatchItem::STAGE_MATURITY_CHECK
: UploadBatchItem::STAGE_FINALIZED,
'error_code' => null,
'error_message' => null,
'is_ready_to_publish' => false,
])->save();
if ((bool) config('vision.auto_tagging.enabled', false)) {
AutoTagArtworkJob::dispatch((int) $artwork->id, (string) $artwork->hash)->afterCommit();
}
if ($this->uploadMaturityEnabled()) {
DetectArtworkMaturityJob::dispatch((int) $artwork->id, (string) $artwork->hash)->afterCommit();
}
if ((bool) config('vision.upload.embeddings.enabled', true)) {
GenerateArtworkEmbeddingJob::dispatch((int) $artwork->id, (string) $artwork->hash)->afterCommit();
}
if ((bool) config('vision.upload.ai_assist.enabled', false)) {
AnalyzeArtworkAiAssistJob::dispatch((int) $artwork->id, true)->afterCommit();
}
return $this->refreshItem((int) $item->id);
}
private function requestAiGeneration(UploadBatchItem $item): UploadBatchItem
{
$artwork = $item->artwork;
if (! $artwork || trim((string) ($artwork->hash ?? '')) === '' || trim((string) ($artwork->file_path ?? '')) === '') {
throw ValidationException::withMessages([
'item' => ['This item cannot generate AI suggestions safely. Re-upload the original file instead.'],
]);
}
app(StudioAiAssistService::class)->queueAnalysis($artwork, true);
return $this->refreshItem((int) $item->id);
}
@@ -543,13 +570,13 @@ final class UploadQueueService
$maturityStatus = Str::lower((string) ($artwork?->maturity_status ?? ArtworkMaturityService::STATUS_CLEAR));
$maturityAiStatus = Str::lower((string) ($artwork?->maturity_ai_status ?? ArtworkMaturityService::AI_STATUS_NOT_REQUESTED));
$aiStatus = Str::lower((string) ($artwork?->ai_status ?? ''));
$visionEnabled = (bool) config('vision.enabled', true);
$uploadMaturityEnabled = $this->uploadMaturityEnabled();
$maturityPending = $visionEnabled && in_array($maturityAiStatus, [
$maturityPending = $uploadMaturityEnabled && in_array($maturityAiStatus, [
ArtworkMaturityService::AI_STATUS_PENDING,
ArtworkMaturityService::AI_STATUS_NOT_REQUESTED,
], true);
$maturityFailed = $visionEnabled && $maturityAiStatus === ArtworkMaturityService::AI_STATUS_FAILED;
$maturityFailed = $uploadMaturityEnabled && $maturityAiStatus === ArtworkMaturityService::AI_STATUS_FAILED;
$needsReview = $maturityStatus === ArtworkMaturityService::STATUS_SUSPECTED || $maturityFailed;
$needsMetadata = ! $hasTitle || ! $hasCategory;
$blockingUploadFailure = ! $hasProcessedMedia && ($this->nullableString($item->error_code) !== null || $this->nullableText($item->error_message) !== null);
@@ -634,9 +661,9 @@ final class UploadQueueService
}
if ($maturityStatus === ArtworkMaturityService::STATUS_SUSPECTED) {
$missing[] = 'Needs maturity review';
} elseif ((bool) config('vision.enabled', true) && in_array($maturityAiStatus, [ArtworkMaturityService::AI_STATUS_PENDING, ArtworkMaturityService::AI_STATUS_NOT_REQUESTED], true)) {
} elseif ($this->uploadMaturityEnabled() && in_array($maturityAiStatus, [ArtworkMaturityService::AI_STATUS_PENDING, ArtworkMaturityService::AI_STATUS_NOT_REQUESTED], true)) {
$missing[] = 'Maturity analysis pending';
} elseif ((bool) config('vision.enabled', true) && $maturityAiStatus === ArtworkMaturityService::AI_STATUS_FAILED) {
} elseif ($this->uploadMaturityEnabled() && $maturityAiStatus === ArtworkMaturityService::AI_STATUS_FAILED) {
$missing[] = 'Maturity check failed';
}
@@ -690,6 +717,12 @@ final class UploadQueueService
return (int) round((collect($checks)->filter()->count() / count($checks)) * 100);
}
private function uploadMaturityEnabled(): bool
{
return (bool) config('vision.enabled', true)
&& (bool) config('vision.upload.maturity.enabled', false);
}
private function normalizeDefaults(array $defaults): array
{
$visibility = (string) ($defaults['visibility'] ?? Artwork::VISIBILITY_PUBLIC);

View File

@@ -18,7 +18,7 @@ import "./vendor-tooltip-CIQaDNlG.js";
import "node:process";
import "node:path";
import "node:url";
import "./vendor-realtime-DYEIbD6w.js";
import "./vendor-realtime-Koiu-_pw.js";
import "buffer";
import "child_process";
import "net";

View File

@@ -1,17 +1,17 @@
import require$$0 from "util";
import stream from "stream";
import require$$4 from "https";
import require$$5 from "url";
import require$$6 from "fs";
import require$$1 from "crypto";
import require$$4$2 from "assert";
import require$$1$1 from "buffer";
import require$$2 from "child_process";
import require$$4$1 from "events";
import require$$8 from "net";
import require$$10 from "tls";
import { c as commonjsGlobal, g as getDefaultExportFromCjs } from "./vendor-tiptap-DRFaxGEb.js";
import require$$4$1 from "events";
import require$$3 from "http";
import require$$4 from "https";
class u {
constructor() {
this.notificationCreatedEvent = ".Illuminate\\Notifications\\Events\\BroadcastNotificationCreated";

View File

@@ -14,10 +14,10 @@
"\u0000D:/Sites/Skinbase26/node_modules/nprogress/nprogress.js?commonjs-es-import": [],
"\u0000D:/Sites/Skinbase26/node_modules/nprogress/nprogress.js?commonjs-module": [],
"\u0000D:/Sites/Skinbase26/node_modules/pusher-js/dist/node/pusher.js?commonjs-es-import": [
"/build/assets/vendor-realtime-DYEIbD6w.js"
"/build/assets/vendor-realtime-Koiu-_pw.js"
],
"\u0000D:/Sites/Skinbase26/node_modules/pusher-js/dist/node/pusher.js?commonjs-module": [
"/build/assets/vendor-realtime-DYEIbD6w.js"
"/build/assets/vendor-realtime-Koiu-_pw.js"
],
"\u0000D:/Sites/Skinbase26/node_modules/qs/lib/index.js?commonjs-es-import": [],
"\u0000D:/Sites/Skinbase26/node_modules/react-dom/cjs/react-dom-client.development.js?commonjs-exports": [],
@@ -97,46 +97,46 @@
"/build/assets/vendor-tiptap-DRFaxGEb.js"
],
"\u0000assert?commonjs-external": [
"/build/assets/vendor-realtime-DYEIbD6w.js"
"/build/assets/vendor-realtime-Koiu-_pw.js"
],
"\u0000buffer?commonjs-external": [
"/build/assets/vendor-realtime-DYEIbD6w.js"
"/build/assets/vendor-realtime-Koiu-_pw.js"
],
"\u0000child_process?commonjs-external": [
"/build/assets/vendor-realtime-DYEIbD6w.js"
"/build/assets/vendor-realtime-Koiu-_pw.js"
],
"\u0000commonjsHelpers.js": [
"/build/assets/vendor-tiptap-DRFaxGEb.js"
],
"\u0000crypto?commonjs-external": [
"/build/assets/vendor-realtime-DYEIbD6w.js"
"/build/assets/vendor-realtime-Koiu-_pw.js"
],
"\u0000events?commonjs-external": [
"/build/assets/vendor-realtime-DYEIbD6w.js"
"/build/assets/vendor-realtime-Koiu-_pw.js"
],
"\u0000fs?commonjs-external": [
"/build/assets/vendor-realtime-DYEIbD6w.js"
"/build/assets/vendor-realtime-Koiu-_pw.js"
],
"\u0000http?commonjs-external": [
"/build/assets/vendor-realtime-DYEIbD6w.js"
"/build/assets/vendor-realtime-Koiu-_pw.js"
],
"\u0000https?commonjs-external": [
"/build/assets/vendor-realtime-DYEIbD6w.js"
"/build/assets/vendor-realtime-Koiu-_pw.js"
],
"\u0000net?commonjs-external": [
"/build/assets/vendor-realtime-DYEIbD6w.js"
"/build/assets/vendor-realtime-Koiu-_pw.js"
],
"\u0000stream?commonjs-external": [
"/build/assets/vendor-realtime-DYEIbD6w.js"
"/build/assets/vendor-realtime-Koiu-_pw.js"
],
"\u0000tls?commonjs-external": [
"/build/assets/vendor-realtime-DYEIbD6w.js"
"/build/assets/vendor-realtime-Koiu-_pw.js"
],
"\u0000url?commonjs-external": [
"/build/assets/vendor-realtime-DYEIbD6w.js"
"/build/assets/vendor-realtime-Koiu-_pw.js"
],
"\u0000util?commonjs-external": [
"/build/assets/vendor-realtime-DYEIbD6w.js"
"/build/assets/vendor-realtime-Koiu-_pw.js"
],
"node_modules/@emoji-mart/data/sets/15/native.json": [
"/build/assets/emoji-data-4xGXbtDn.js"
@@ -1035,7 +1035,7 @@
"node_modules/inline-style-parser/cjs/index.js": [],
"node_modules/is-plain-obj/index.js": [],
"node_modules/laravel-echo/dist/echo.js": [
"/build/assets/vendor-realtime-DYEIbD6w.js"
"/build/assets/vendor-realtime-Koiu-_pw.js"
],
"node_modules/linkifyjs/dist/linkify.mjs": [
"/build/assets/vendor-tiptap-DRFaxGEb.js"
@@ -1935,7 +1935,7 @@
],
"node_modules/proxy-from-env/index.js": [],
"node_modules/pusher-js/dist/node/pusher.js": [
"/build/assets/vendor-realtime-DYEIbD6w.js"
"/build/assets/vendor-realtime-Koiu-_pw.js"
],
"node_modules/qs/lib/formats.js": [],
"node_modules/qs/lib/index.js": [],
@@ -2148,6 +2148,11 @@
"resources/js/Pages/Moderation/ArtworkMaturityQueue.jsx": [],
"resources/js/Pages/Moderation/Enhance/Index.jsx": [],
"resources/js/Pages/Moderation/Enhance/Show.jsx": [],
"resources/js/Pages/Moderation/FeaturedArtworks.jsx": [],
"resources/js/Pages/Moderation/StaffApplications/Index.jsx": [],
"resources/js/Pages/Moderation/StaffApplications/Show.jsx": [],
"resources/js/Pages/Moderation/Stories.jsx": [],
"resources/js/Pages/Moderation/UsernameQueue.jsx": [],
"resources/js/Pages/Moderation/WorldWebStoriesIndex.jsx": [],
"resources/js/Pages/Moderation/WorldWebStoryEditor.jsx": [],
"resources/js/Pages/News/NewsComments.jsx": [],
@@ -2252,7 +2257,7 @@
"resources/js/components/artwork/ArtworkRecommendationsRails.jsx": [],
"resources/js/components/artwork/ArtworkShareButton.jsx": [],
"resources/js/components/artwork/ArtworkShareModal.jsx": [
"/build/assets/ArtworkShareModal-BI8kkaqs.js"
"/build/assets/ArtworkShareModal-BPM8yel5.js"
],
"resources/js/components/artwork/ArtworkTags.jsx": [],
"resources/js/components/artwork/AuthorBioPopover.jsx": [],

File diff suppressed because one or more lines are too long

12
config/theme.php Normal file
View File

@@ -0,0 +1,12 @@
<?php
return [
// Whether the optional `light` theme is enabled for the site. When false,
// front-end theme toggle will only expose the default theme.
'enabled' => env('LIGHT_THEME_ENABLED', false),
// Whether the toolbar should render the light-theme switch. This is
// controlled separately so you can enable the theme without showing the
// global switch to visitors/admins.
'show_toolbar_switch' => env('LIGHT_THEME_SHOW_SWITCH', false),
];

View File

@@ -5,6 +5,22 @@ declare(strict_types=1);
return [
'enabled' => env('VISION_ENABLED', true),
'auto_tagging' => [
'enabled' => env('VISION_AUTO_TAGGING_ENABLED', false),
],
'upload' => [
'embeddings' => [
'enabled' => env('VISION_UPLOAD_EMBEDDINGS_ENABLED', true),
],
'maturity' => [
'enabled' => env('VISION_UPLOAD_MATURITY_ENABLED', false),
],
'ai_assist' => [
'enabled' => env('VISION_UPLOAD_AI_ASSIST_ENABLED', false),
],
],
'queue' => env('VISION_QUEUE', 'default'),
'clip' => [

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('academy_prompt_templates', function (Blueprint $table): void {
if (! Schema::hasColumn('academy_prompt_templates', 'filled_examples')) {
$table->json('filled_examples')->nullable()->after('prompt_variants');
}
});
}
public function down(): void
{
Schema::table('academy_prompt_templates', function (Blueprint $table): void {
if (Schema::hasColumn('academy_prompt_templates', 'filled_examples')) {
$table->dropColumn('filled_examples');
}
});
}
};

275
env Normal file
View File

@@ -0,0 +1,275 @@
APP_NAME=SkinbaseNova
APP_ENV=local
APP_KEY=base64:TAMmcAnL05vnhSV7wBoDoSc/Pv42LNQtX6B6lGc3HBk=
APP_DEBUG=true
APP_URL=http://skinbase26.test
DEBUGBAR_ENABLED=true
APP_LOCALE=en
APP_FALLBACK_LOCALE=en
APP_FAKER_LOCALE=en_US
APP_MAINTENANCE_DRIVER=file
# APP_MAINTENANCE_STORE=database
# PHP_CLI_SERVER_WORKERS=4
BCRYPT_ROUNDS=12
LOG_CHANNEL=stack
LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug
DB_CONNECTION=mysql
#DB_HOST=10.255.255.254
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=projekti_2026_skinbase
DB_USERNAME=projekti
DB_PASSWORD=2Xf5TM3P1IeNTfhs
LEGACY_DB_HOST=127.0.0.1
LEGACY_DB_DATABASE=projekti_old_skinbase
SESSION_DRIVER=database
SESSION_LIFETIME=3600
SESSION_ENCRYPT=false
SESSION_PATH=/
SESSION_DOMAIN=null
BROADCAST_CONNECTION=reverb
FILESYSTEM_DISK=local
QUEUE_CONNECTION=redis
CACHE_STORE=database
# CACHE_PREFIX=
MEMCACHED_HOST=127.0.0.1
REDIS_CLIENT=predis
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
MAIL_MAILER=smtp
MAIL_HOST=smtp-pulse.com
MAIL_PORT=587
MAIL_USERNAME=info@skinbase.org
MAIL_PASSWORD=ML2BBL958fdCMMc
MAIL_ENCRYPTION=tls
MAIL_FROM_ADDRESS='info@skinbase.org'
MAIL_FROM_NAME="Skinbase"
AWS_ACCESS_KEY_ID=9d9292110fb4f68b2e4bc1fa55d6b2a3
AWS_SECRET_ACCESS_KEY=0a1d8d8a38eb9a15ff23eac0c5e993c1
AWS_DEFAULT_REGION=eu2
AWS_BUCKET=skinbase
AWS_USE_PATH_STYLE_ENDPOINT=true
AWS_ENDPOINT=https://eu2.contabostorage.com
VISION_VECTOR_GATEWAY_ENABLED=true
VISION_VECTOR_GATEWAY_URL=https://vision.klevze.net
VISION_VECTOR_GATEWAY_API_KEY=jQZ96c2B2QRjsFZiPZXMYCid6lVdsyxF
VISION_VECTOR_GATEWAY_COLLECTION=images
VISION_VECTOR_GATEWAY_TIMEOUT=20
VISION_VECTOR_GATEWAY_CONNECT_TIMEOUT=5
VISION_VECTOR_GATEWAY_RETRIES=1
VISION_VECTOR_GATEWAY_RETRY_DELAY_MS=250
VITE_APP_NAME="${APP_NAME}"
SKINBASE_STORAGE_ROOT=D:/Sites/Skinbase26/public/files/thumb
ARTWORKS_LOCAL_ORIGINALS_ROOT=D:/Sites/Skinbase26/public/files/originals
SCOUT_DRIVER=meilisearch
MEILISEARCH_HOST=https://meili.klevze.si
MEILISEARCH_KEY=0d0df27b-dd25-4855-b6a6-3786755475c6
#MEILISEARCH_KEY=a474f24de92941aac24441b4d7ee71ce4feb8e7a3157d4f6e6a42877cb2a563c
MEILI_PREFIX=skinbase_prod_
# Discovery rollout profile (Phase 8 lock)
DISCOVERY_ALGO_VERSION=clip-cosine-v1
DISCOVERY_V2_ENABLED=true
DISCOVERY_V2_ALGO_VERSION=clip-cosine-v2-adaptive
DISCOVERY_V2_CACHE_VERSION=cache-v2
DISCOVERY_V2_CACHE_TTL_MINUTES=15
DISCOVERY_V2_ROLLOUT_PERCENTAGE=10
DISCOVERY_RANKING_WEIGHTS_VERSION_CLIP_COSINE_V2=rank-w-v2-prod-1
DISCOVERY_RANKING_W1_CLIP_COSINE_V2=0.52
DISCOVERY_RANKING_W2_CLIP_COSINE_V2=0.23
DISCOVERY_RANKING_W3_CLIP_COSINE_V2=0.15
DISCOVERY_RANKING_W4_CLIP_COSINE_V2=0.10
DISCOVERY_ROLLOUT_ENABLED=true
DISCOVERY_ROLLOUT_BASELINE_ALGO_VERSION=clip-cosine-v1
DISCOVERY_ROLLOUT_CANDIDATE_ALGO_VERSION=clip-cosine-v2
DISCOVERY_ROLLOUT_ACTIVE_GATE=g10
DISCOVERY_ROLLOUT_GATE_10_PERCENT=10
DISCOVERY_ROLLOUT_GATE_50_PERCENT=50
DISCOVERY_ROLLOUT_GATE_100_PERCENT=100
DISCOVERY_FORCE_ALGO_VERSION=
DISCOVERY_EVAL_SAVE_RATE_INFORMATIONAL=true
# Emergency rollback preset (uncomment to force baseline immediately)
# DISCOVERY_FORCE_ALGO_VERSION=clip-cosine-v1
# DISCOVERY_ROLLOUT_ACTIVE_GATE=g10
# DISCOVERY_ROLLOUT_ENABLED=true
UPLOAD_SCAN_ENABLED=false
UPLOAD_SCAN_COMMAND=clamscan
IMAGE_DRIVER=gd
SKINBASE_UPLOADS_V2=true
# Vision / AI auto-tagging (local defaults)
VISION_ENABLED=true
VISION_QUEUE=default
VISION_IMAGE_VARIANT=lg
VISION_API_KEY=${VISION_VECTOR_GATEWAY_API_KEY}
CLIP_BASE_URL=https://vision.klevze.net
CLIP_ANALYZE_ENDPOINT=/analyze/clip
YOLO_BASE_URL=https://vision.klevze.net
YOLO_ANALYZE_ENDPOINT=/analyze/yolo
VISION_GATEWAY_URL=https://vision.klevze.net
VISION_GATEWAY_API_KEY=${VISION_VECTOR_GATEWAY_API_KEY}
VISION_GATEWAY_TIMEOUT=60
VISION_GATEWAY_CONNECT_TIMEOUT=5
SCOUT_QUEUE_CONNECTION=database
SCOUT_QUEUE_NAME=default
# ─── Early-Stage Growth System ───────────────────────────────────────────────
# Set NOVA_EARLY_GROWTH_ENABLED=false to instantly revert to normal behaviour.
# NOVA_EARLY_GROWTH_MODE: off | light | aggressive
NOVA_EARLY_GROWTH_ENABLED=true
NOVA_EARLY_GROWTH_MODE=aggressive
# Module toggles (only active when NOVA_EARLY_GROWTH_ENABLED=true)
NOVA_EGS_ADAPTIVE_WINDOW=true
NOVA_EGS_GRID_FILLER=true
NOVA_EGS_SPOTLIGHT=true
NOVA_EGS_ACTIVITY_LAYER=false
# AdaptiveTimeWindow thresholds
NOVA_EGS_UPLOADS_PER_DAY_NARROW=10
NOVA_EGS_UPLOADS_PER_DAY_WIDE=3
NOVA_EGS_WINDOW_NARROW_DAYS=7
NOVA_EGS_WINDOW_MEDIUM_DAYS=30
NOVA_EGS_WINDOW_WIDE_DAYS=90
# GridFiller minimum items per page
NOVA_EGS_GRID_MIN_RESULTS=12
# Auto-disable when site reaches organic scale
NOVA_EGS_AUTO_DISABLE=false
NOVA_EGS_AUTO_DISABLE_UPLOADS=50
NOVA_EGS_AUTO_DISABLE_USERS=500
# Cache TTLs (seconds)
NOVA_EGS_SPOTLIGHT_TTL=3600
NOVA_EGS_BLEND_TTL=300
NOVA_EGS_WINDOW_TTL=600
NOVA_EGS_ACTIVITY_TTL=1800
GOOGLE_CLIENT_ID="252720311278-fgjgrv3bue9upgqfp91ihbpunoqlpjvf.apps.googleusercontent.com"
GOOGLE_CLIENT_SECRET="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"
GOOGLE_REDIRECT_URI=https://skinbase26.test/auth/google/callback
# Discord — https://discord.com/developers/applications
DISCORD_CLIENT_ID=1478852108869570731
DISCORD_CLIENT_SECRET=k9OgyZrwNqT_UwZgwvHTRdEw8DXStKLN
DISCORD_REDIRECT_URI=https://skinbase26.test/auth/discord/callback
CP_ENABLE_CORS=false
BROADCAST_CONNECTION=reverb
REVERB_APP_ID=376489
REVERB_APP_KEY=jm0pq3ikcu3yequsbioc
REVERB_APP_SECRET=68sq4tc5lqhxuavxgqlt
# internal Reverb server bind
REVERB_SERVER_HOST=127.0.0.1
REVERB_SERVER_PORT=8080
# public host behind Cloudflare / Apache
REVERB_HOST=ws.skinbase.org
REVERB_PORT=443
REVERB_SCHEME=https
VITE_REVERB_APP_KEY="${REVERB_APP_KEY}"
VITE_REVERB_HOST="${REVERB_HOST}"
VITE_REVERB_PORT="${REVERB_PORT}"
VITE_REVERB_SCHEME="${REVERB_SCHEME}"
MESSAGING_REALTIME=true
CLOUDFLARE_ZONE_ID=2fead03fca715d3f44f567c671dc554d
CLOUDFLARE_API_TOKEN=cfut_Bd7THUxtJTHvOb66xDXhzp4uEm8IoaHZAkkOBnbVbcda524d
NOVA_CARDS_PUBLIC_DISK=s3
NOVA_CARDS_PLAYWRIGHT_RENDER=true
AWS_URL=https://cdn.skinbase.org
SEO_META_KEYWORDS=false
#SENTRY_LARAVEL_DSN=https://f3774714982b12b53cfc3e70e1883595@o106088.ingest.us.sentry.io/4511307411816448
#SENTRY_SEND_DEFAULT_PII=true
#SENTRY_TRACES_SAMPLE_RATE=1.0
SKINBASE_ACADEMY_ENABLED=true
SKINBASE_ACADEMY_PAYMENTS_ENABLED=true
SKINBASE_ACADEMY_CHALLENGES_ENABLED=true
SKINBASE_ACADEMY_BADGES_ENABLED=true
# Stripe / Cashier
STRIPE_KEY=pk_test_51TYk1SBlXOyRoJYFUxa4PsycgqcfajPUMKCAFwXle5edjB2dIg7CvwO3upI6P83ya5blD4CvhSiStY0kP8jyJbAp00zn9cPlii
STRIPE_SECRET=sk_test_51TYk1SBlXOyRoJYFn0PvoXYvRa5KkGh5Q9PkMD3SgTKiBEibjnZsnZmKH098y38tQU8n14Fy1WyLrsuUAkgz1DtZ00MaOIwWBt
STRIPE_WEBHOOK_SECRET=whsec_IeFGaq7AK27RWwXXchyaWyPqSJ08cBsW
CASHIER_CURRENCY=eur
CASHIER_CURRENCY_LOCALE=sl_SI
# Academy billing price IDs
ACADEMY_CREATOR_MONTHLY_PRICE_ID=price_xxx
ACADEMY_PRO_MONTHLY_PRICE_ID=price_1TYmkTBlXOyRoJYFfY8al4j2
ACADEMY_BILLING_ENABLED=true
ACADEMY_STRIPE_SUBSCRIPTION_NAME=academy
# Registration anti-spam
REGISTRATION_IP_PER_MINUTE_LIMIT=3
REGISTRATION_IP_PER_DAY_LIMIT=20
REGISTRATION_EMAIL_PER_MINUTE_LIMIT=6
REGISTRATION_EMAIL_COOLDOWN_MINUTES=30
REGISTRATION_VERIFY_TOKEN_TTL_HOURS=24
REGISTRATION_ENABLE_TURNSTILE=true
REGISTRATION_DISPOSABLE_DOMAINS_ENABLED=true
REGISTRATION_TURNSTILE_SUSPICIOUS_ATTEMPTS=2
REGISTRATION_TURNSTILE_ATTEMPT_WINDOW_MINUTES=30
REGISTRATION_EMAIL_GLOBAL_SEND_PER_MINUTE=30
REGISTRATION_MONTHLY_EMAIL_LIMIT=10000
TURNSTILE_SITE_KEY=0x4AAAAAADI6Ruu4X2IpmLrF
TURNSTILE_SECRET_KEY=0x4AAAAAADI6RlHFGscerV8DhIUwykRcbgE
TURNSTILE_VERIFY_URL=https://challenges.cloudflare.com/turnstile/v0/siteverify
TURNSTILE_TIMEOUT=5
TURNSTILE_ENABLED=true
TURNSTILE_FAIL_OPEN=false
ENHANCE_DISK=public
ENHANCE_SOURCE_PREFIX=enhance/sources
ENHANCE_OUTPUT_PREFIX=enhance/outputs
ENHANCE_PREVIEW_PREFIX=enhance/previews
ENHANCE_ENGINE=stub
ENHANCE_MAX_UPLOAD_MB=20
ENHANCE_MAX_INPUT_WIDTH=4096
ENHANCE_MAX_INPUT_HEIGHT=4096
ENHANCE_MAX_OUTPUT_WIDTH=8192
ENHANCE_MAX_OUTPUT_HEIGHT=8192
ENHANCE_DAILY_LIMIT=10
ENHANCE_QUEUE=default
ENHANCE_WORKER_URL=
ENHANCE_WORKER_TIMEOUT=300
ENHANCE_WORKER_TOKEN=

View File

@@ -16,6 +16,7 @@ const buildAdminNavGroups = (isAdmin) => [
{ label: 'All Users', href: '/moderation/users', icon: 'fa-solid fa-users' },
{ label: 'Staff', href: '/moderation/users?role=admin', icon: 'fa-solid fa-shield-halved' },
{ label: 'Moderators', href: '/moderation/users?role=moderator', icon: 'fa-solid fa-user-shield' },
{ label: 'Staff Applications', href: '/moderation/staff-applications', icon: 'fa-solid fa-user-check' },
],
},
{
@@ -29,7 +30,6 @@ const buildAdminNavGroups = (isAdmin) => [
{ label: 'Homepage Announcements', href: '/moderation/homepage/announcements', icon: 'fa-solid fa-bullhorn' },
{ label: 'Upload Queue', href: '/moderation/uploads', icon: 'fa-solid fa-cloud-arrow-up' },
{ label: 'Username Queue', href: '/moderation/usernames/moderation', icon: 'fa-solid fa-id-badge' },
{ label: 'AI Biography', href: '/moderation/ai-biography', icon: 'fa-solid fa-wand-magic-sparkles' },
],
},
{

View File

@@ -1,5 +1,5 @@
import React from 'react'
import { Head, Link } from '@inertiajs/react'
import React, { useState, useRef, useEffect } from 'react'
import { Head, Link, useForm, usePage } from '@inertiajs/react'
import AccessBadge from '../../../components/academy/billing/AccessBadge'
function formatDate(iso) {
@@ -12,6 +12,72 @@ function formatDate(iso) {
}
export default function AcademyBillingAccount({ currentTier, isSubscribed, subscription, activePlan = null, links = {} }) {
const { flash, auth } = usePage().props
const { data, setData, post, processing } = useForm({
issue_type: 'billing',
contact_email: auth?.user?.email || '',
message: '',
session_id: null,
})
function IssueTypeDropdown({ value, onChange }) {
const options = [
{ value: 'billing', label: 'Billing question' },
{ value: 'payment', label: 'Payment problem' },
{ value: 'upgrade', label: 'Upgrade problem' },
{ value: 'downgrade', label: 'Downgrade problem' },
{ value: 'cancel', label: 'Cancellation problem' },
{ value: 'access', label: 'Access not updated' },
{ value: 'other', label: 'Other' },
]
const [open, setOpen] = useState(false)
const ref = useRef(null)
useEffect(() => {
function onDoc(e) {
if (ref.current && !ref.current.contains(e.target)) setOpen(false)
}
document.addEventListener('mousedown', onDoc)
return () => document.removeEventListener('mousedown', onDoc)
}, [])
const current = options.find((o) => o.value === value) || options[0]
return (
<div ref={ref} className="relative">
<button
type="button"
onClick={() => setOpen((s) => !s)}
className="w-full text-left rounded-xl border border-amber-300/20 bg-black/20 p-3 text-sm text-amber-50 flex items-center justify-between"
>
<span>{current.label}</span>
<svg className="ml-2 h-4 w-4 text-amber-100/70" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M6 8l4 4 4-4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/></svg>
</button>
{open ? (
<div className="absolute left-0 top-full mt-2 w-full rounded-xl border border-white/10 bg-[#10192e] shadow-2xl z-50 overflow-hidden">
{options.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => {
onChange(opt.value)
setOpen(false)
}}
className={`w-full text-left px-4 py-3 text-sm ${opt.value === value ? 'bg-white/[0.03] text-white' : 'text-slate-300 hover:bg-white/[0.02]'}`}
>
{opt.label}
</button>
))}
</div>
) : null}
</div>
)
}
function getCsrfToken() {
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''
}
const endsAt = formatDate(subscription?.endsAt)
const onGracePeriod = subscription?.onGracePeriod === true
const subscriptionActive = subscription?.active === true
@@ -21,6 +87,16 @@ export default function AcademyBillingAccount({ currentTier, isSubscribed, subsc
<Head title="Academy Subscription" />
<div className="mx-auto max-w-[1280px] space-y-8">
{flash?.error ? (
<section className="rounded-[20px] border border-rose-300/20 bg-rose-300/8 p-4">
<p className="font-semibold text-rose-100">{flash.error}</p>
</section>
) : null}
{flash?.success ? (
<section className="rounded-[20px] border border-emerald-300/20 bg-emerald-300/8 p-4">
<p className="font-semibold text-emerald-100">{flash.success}</p>
</section>
) : null}
{/* Header */}
<section className="rounded-[40px] border border-white/10 bg-[linear-gradient(135deg,rgba(7,17,31,0.95),rgba(12,24,45,0.9),rgba(15,23,42,0.96))] p-8 shadow-[0_32px_100px_rgba(2,6,23,0.42)] md:p-10">
<div className="flex flex-wrap items-center gap-3">
@@ -42,12 +118,12 @@ export default function AcademyBillingAccount({ currentTier, isSubscribed, subsc
<section className="rounded-[30px] border border-amber-300/25 bg-amber-300/[0.06] px-6 py-5">
<p className="font-semibold text-amber-100">Your subscription was cancelled and will end on {endsAt}.</p>
<p className="mt-2 text-sm leading-6 text-amber-100/75">You still have full access until that date. Open the subscription portal to resume your plan if you change your mind.</p>
<Link
<a
href={links.portal}
className="mt-4 inline-flex items-center rounded-full border border-amber-300/30 bg-amber-300/12 px-5 py-2.5 text-sm font-semibold text-amber-100 transition hover:bg-amber-300/20"
>
Resume subscription
</Link>
</a>
</section>
) : null}
@@ -123,20 +199,75 @@ export default function AcademyBillingAccount({ currentTier, isSubscribed, subsc
<aside className="space-y-3 rounded-[32px] border border-white/10 bg-black/20 p-6">
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-300">Manage</p>
<p className="text-xs leading-6 text-slate-400">
Use the subscription portal to upgrade, downgrade, or cancel. Changes take effect at your next billing date.
Use the subscription portal to cancel or manage billing details. Plan upgrades are handled here on Skinbase.
</p>
<Link
{/* Use a plain anchor to perform a full navigation to Stripe (avoid Inertia XHR/CORS) */}
<a
href={links.portal}
className="mt-2 inline-flex w-full items-center justify-center rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100 transition hover:border-sky-300/40 hover:bg-sky-300/18"
>
Upgrade, downgrade or cancel
</Link>
Open billing portal
</a>
<Link
href={links.pricing || '/academy/pricing'}
className="inline-flex w-full items-center justify-center rounded-full border border-white/10 bg-white/[0.05] px-5 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.08]"
>
Compare plans
</Link>
{/* Quick upgrade form: allow Creator -> Pro upgrade in one click (full POST, not Inertia) */}
{activePlan?.tier === 'creator' ? (
<form action={links.checkout} method="POST" data-no-inertia className="mt-2">
<input type="hidden" name="_token" value={getCsrfToken()} />
<input type="hidden" name="plan" value="pro_monthly" />
<button type="submit" className="inline-flex w-full items-center justify-center rounded-full border border-emerald-300/25 bg-emerald-300/10 px-5 py-3 text-sm font-semibold text-emerald-100 transition hover:bg-emerald-300/18">Upgrade to Pro now</button>
</form>
) : null}
{links.reportIssue ? (
<div className="mt-3 rounded-2xl border border-amber-300/20 bg-amber-300/8 p-4">
<p className="text-sm font-semibold text-amber-100">Need help with billing or access?</p>
<p className="mt-1 text-xs leading-5 text-amber-100/80">
Send a quick report here if payment, access, or subscription changes do not behave as expected.
</p>
<form
onSubmit={(event) => {
event.preventDefault()
post(links.reportIssue, { preserveScroll: true })
}}
className="mt-3 space-y-3"
>
<div className="grid gap-3">
<label className="space-y-1 relative">
<span className="text-xs font-medium text-amber-100/80">Issue type</span>
{/* Custom dropdown to avoid native browser option styling */}
<IssueTypeDropdown value={data.issue_type} onChange={(v) => setData('issue_type', v)} />
</label>
<label className="space-y-1">
<span className="text-xs font-medium text-amber-100/80">Reply email</span>
<input
type="email"
value={data.contact_email}
onChange={(event) => setData('contact_email', event.target.value)}
placeholder="you@example.com"
className="w-full rounded-xl border border-amber-300/20 bg-black/20 p-3 text-sm text-amber-50 placeholder:text-amber-100/40"
/>
</label>
</div>
<textarea
value={data.message}
onChange={(event) => setData('message', event.target.value)}
placeholder="Describe the issue you hit, what you expected, and anything already charged or missing"
className="min-h-[96px] w-full rounded-xl border border-amber-300/20 bg-black/20 p-3 text-sm text-amber-50 placeholder:text-amber-100/40"
/>
<button
type="submit"
disabled={processing}
className="inline-flex w-full items-center justify-center rounded-full border border-amber-300/30 bg-amber-300/12 px-5 py-3 text-sm font-semibold text-amber-100 transition hover:bg-amber-300/18 disabled:cursor-not-allowed disabled:opacity-60"
>
{processing ? 'Sending report...' : 'Send support report'}
</button>
</form>
</div>
) : null}
<Link
href={links.academy || '/academy'}
className="inline-flex w-full items-center justify-center rounded-full border border-white/10 bg-white/[0.05] px-5 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.08]"

View File

@@ -83,7 +83,7 @@ function SidePanel({ currentTier, isSubscribed, activePlanLabel, activePlanPrice
)
}
export default function AcademyBillingPricing({ seo, billingEnabled, currentTier, isSubscribed, activePlanKey = null, activePlanLabel = null, catalog = [], links = {}, analytics }) {
export default function AcademyBillingPricing({ seo, billingEnabled, currentTier, isSubscribed, activePlanKey = null, activePlanLabel = null, catalog = [], links = {}, analytics, missingRemote = [] }) {
const { auth, errors, flash } = usePage().props
useAcademyPageAnalytics(analytics)
@@ -151,6 +151,12 @@ export default function AcademyBillingPricing({ seo, billingEnabled, currentTier
{errors?.plan ? <p className="mt-4 text-sm font-medium text-rose-200">{errors.plan}</p> : null}
{flash?.error ? <p className="mt-4 rounded-2xl border border-rose-300/20 bg-rose-300/10 px-4 py-3 text-sm font-medium text-rose-100">{flash.error}</p> : null}
{flash?.success ? <p className="mt-4 rounded-2xl border border-emerald-300/20 bg-emerald-300/10 px-4 py-3 text-sm font-medium text-emerald-100">{flash.success}</p> : null}
{Array.isArray(missingRemote) && missingRemote.length > 0 ? (
<div className="mt-4 rounded-2xl border border-amber-300/20 bg-amber-300/8 px-4 py-3 text-sm font-medium text-amber-50">
<p className="font-semibold">Purchases temporarily disabled:</p>
<p className="mt-1 text-xs">The following plans could not be verified in Stripe: {missingRemote.join(', ')}</p>
</div>
) : null}
</div>
<SidePanel

View File

@@ -1,8 +1,12 @@
import React from 'react'
import { Head, Link } from '@inertiajs/react'
import { Head, Link, usePage, useForm } from '@inertiajs/react'
import AccessBadge from '../../../components/academy/billing/AccessBadge'
export default function AcademyBillingSuccess({ currentTier, isSubscribed, links = {} }) {
const { auth } = usePage().props
const sessionId = usePage().props.sessionId || null
const userEmail = auth?.user?.email ?? null
const { data, setData, post, processing } = useForm({ message: '', session_id: sessionId })
return (
<main className="flex min-h-screen items-center bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.14),_transparent_24%),radial-gradient(circle_at_bottom_right,_rgba(16,185,129,0.14),_transparent_24%),linear-gradient(180deg,_#07111f_0%,_#0f172a_45%,_#111827_100%)] px-4 py-8 sm:px-6 lg:px-8">
<Head title="Subscription Confirmed" />
@@ -21,6 +25,25 @@ export default function AcademyBillingSuccess({ currentTier, isSubscribed, links
? 'Your subscription is active and all premium content for your plan is now unlocked. Head to Academy and start exploring.'
: "Your payment was confirmed and your subscription is activating now. This usually takes just a moment. If you don't see your access right away, refresh the Academy page in a few seconds."}
</p>
{!isSubscribed ? (
<div className="mt-4 rounded-2xl border border-amber-300/20 bg-amber-300/8 px-4 py-3 text-sm text-amber-50">
<p className="font-semibold">If your access isn't updated automatically</p>
<p className="mt-1">If your Academy access doesn't appear within a few minutes, email <strong>academy@skinbase.org</strong> or click the button below to open a prefilled message. Include your account email{userEmail ? ` (${userEmail})` : ''} and the checkout session id{sessionId ? `: ${sessionId}` : '.'}</p>
<div className="mt-3 flex flex-wrap items-start gap-4">
<form onSubmit={(e) => { e.preventDefault(); post(links.reportIssue, { preserveScroll: true }) }} className="flex w-full max-w-lg items-start gap-2">
<textarea value={data.message} onChange={(e) => setData('message', e.target.value)} placeholder="Optional: Tell us what you expected to see or any useful details" className="flex-1 rounded-md bg-black/20 border border-amber-300/20 p-2 text-sm text-amber-50" rows={3} />
<button type="submit" disabled={processing} className="rounded-full border border-amber-300/30 bg-amber-300/12 px-4 py-2 text-sm font-semibold text-amber-900 hover:bg-amber-300/16">Send report</button>
</form>
<div className="text-xs text-amber-100">
<div>- Wait 23 minutes and refresh the Academy page.</div>
<div>- If you still lack access, use the form above or email academy@skinbase.org.</div>
</div>
</div>
</div>
) : null}
</section>
<div className="flex flex-wrap gap-3">

View File

@@ -595,6 +595,192 @@ function PromptPlaceholderCard({ placeholder }) {
)
}
function PromptFilledExampleCard({ example, analytics, contentId, index }) {
const placeholderEntries = Object.entries(example?.placeholder_values || {}).filter(([key, value]) => String(key || '').trim() && value != null && value !== '' && value !== false)
return (
<article className="rounded-[28px] border border-white/10 bg-black/20 p-5 shadow-[0_18px_42px_rgba(2,6,23,0.16)]">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-violet-200/75">Filled example {index + 1}</p>
<h3 className="mt-2 text-xl font-semibold tracking-[-0.03em] text-white">{example?.title || `Example ${index + 1}`}</h3>
{example?.description ? <p className="mt-3 text-sm leading-7 text-slate-300">{example.description}</p> : null}
</div>
{example?.prompt ? (
<PromptCopyButton
prompt={example.prompt}
label="Copy example"
analytics={analytics}
contentId={contentId}
eventType="academy_prompt_filled_example_copy"
metadata={{ copy_type: 'filled_example', filled_example_index: index, source: 'prompt_filled_examples' }}
/>
) : null}
</div>
{placeholderEntries.length ? (
<div className="mt-5 flex flex-wrap gap-2">
{placeholderEntries.map(([key, value]) => (
<span key={key} className="rounded-full border border-violet-300/20 bg-violet-300/10 px-3 py-1.5 text-xs font-semibold uppercase tracking-[0.16em] text-violet-100">
{key}: <span className="normal-case tracking-normal text-white">{String(value)}</span>
</span>
))}
</div>
) : null}
{example?.prompt ? <pre className="mt-5 whitespace-pre-wrap rounded-[24px] border border-white/10 bg-slate-950/80 p-4 text-sm leading-7 text-slate-100">{example.prompt}</pre> : null}
{example?.negative_prompt ? (
<div className="mt-4 rounded-[24px] border border-white/10 bg-slate-950/60 p-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-400">Negative prompt</p>
<PromptCopyButton
prompt={example.negative_prompt}
label="Copy negative"
analytics={analytics}
contentId={contentId}
eventType="academy_prompt_filled_example_negative_copy"
metadata={{ copy_type: 'filled_example_negative', filled_example_index: index, source: 'prompt_filled_examples' }}
/>
</div>
<pre className="mt-3 whitespace-pre-wrap text-sm leading-7 text-slate-200">{example.negative_prompt}</pre>
</div>
) : null}
</article>
)
}
function PromptFilledExamplesSection({ examples, analytics, contentId }) {
const visibleExamples = Array.isArray(examples) ? examples.filter((example) => example && typeof example === 'object') : []
const [activeExampleIndex, setActiveExampleIndex] = useState(0)
const examplesScrollRef = useRef(null)
const [canScrollExamplesLeft, setCanScrollExamplesLeft] = useState(false)
const [canScrollExamplesRight, setCanScrollExamplesRight] = useState(false)
useEffect(() => {
if (typeof window === 'undefined') {
return undefined
}
const updateExampleScrollState = () => {
const element = examplesScrollRef.current
if (!element) {
setCanScrollExamplesLeft(false)
setCanScrollExamplesRight(false)
return
}
const maxScrollLeft = Math.max(0, element.scrollWidth - element.clientWidth)
setCanScrollExamplesLeft(element.scrollLeft > 6)
setCanScrollExamplesRight(element.scrollLeft < maxScrollLeft - 6)
}
updateExampleScrollState()
const element = examplesScrollRef.current
if (!element) {
return undefined
}
element.addEventListener('scroll', updateExampleScrollState, { passive: true })
window.addEventListener('resize', updateExampleScrollState, { passive: true })
return () => {
element.removeEventListener('scroll', updateExampleScrollState)
window.removeEventListener('resize', updateExampleScrollState)
}
}, [visibleExamples.length])
useEffect(() => {
if (!visibleExamples.length) {
setActiveExampleIndex(0)
return
}
setActiveExampleIndex((current) => Math.max(0, Math.min(current, visibleExamples.length - 1)))
}, [visibleExamples.length])
if (!visibleExamples.length) return null
const activeExample = visibleExamples[activeExampleIndex] || visibleExamples[0]
const activeExampleLabel = String(activeExample?.title || '').trim() || `Example ${activeExampleIndex + 1}`
const activeExampleDescription = String(activeExample?.description || '').trim()
const scrollExamples = (direction) => {
const element = examplesScrollRef.current
if (!element) return
const amount = Math.max(220, Math.floor(element.clientWidth * 0.65))
element.scrollBy({
left: direction === 'left' ? -amount : amount,
behavior: 'smooth',
})
}
return (
<div className="mt-6 space-y-5">
<div className="rounded-[24px] border border-violet-300/15 bg-violet-300/10 p-5 md:p-6">
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-violet-100/80">Selected example</p>
<h3 className="mt-2 text-2xl font-semibold tracking-[-0.04em] text-white md:text-[2rem]">{activeExampleLabel}</h3>
{activeExampleDescription ? <p className="mt-3 text-sm leading-7 text-slate-200 md:text-base">{activeExampleDescription}</p> : null}
</div>
<div className="relative">
<div className={`pointer-events-none absolute inset-y-0 left-0 z-10 w-14 bg-gradient-to-r from-[#211c3a] via-[#211c3a]/85 to-transparent transition ${canScrollExamplesLeft ? 'opacity-100' : 'opacity-0'}`} aria-hidden="true" />
<div className={`pointer-events-none absolute inset-y-0 right-0 z-10 w-14 bg-gradient-to-l from-[#211c3a] via-[#211c3a]/85 to-transparent transition ${canScrollExamplesRight ? 'opacity-100' : 'opacity-0'}`} aria-hidden="true" />
<button
type="button"
aria-label="Scroll filled examples left"
onClick={() => scrollExamples('left')}
className={`absolute left-2 top-1/2 z-20 flex h-10 w-10 -translate-y-1/2 items-center justify-center rounded-full border border-white/12 bg-slate-950/80 text-white/80 shadow-[0_16px_36px_rgba(2,6,23,0.28)] backdrop-blur transition ${canScrollExamplesLeft ? 'opacity-100 hover:scale-105 hover:bg-slate-900/95' : 'pointer-events-none opacity-0'}`}
>
<i className="fa-solid fa-chevron-left text-sm" />
</button>
<button
type="button"
aria-label="Scroll filled examples right"
onClick={() => scrollExamples('right')}
className={`absolute right-2 top-1/2 z-20 flex h-10 w-10 -translate-y-1/2 items-center justify-center rounded-full border border-white/12 bg-slate-950/80 text-white/80 shadow-[0_16px_36px_rgba(2,6,23,0.28)] backdrop-blur transition ${canScrollExamplesRight ? 'opacity-100 hover:scale-105 hover:bg-slate-900/95' : 'pointer-events-none opacity-0'}`}
>
<i className="fa-solid fa-chevron-right text-sm" />
</button>
<div ref={examplesScrollRef} className="overflow-x-auto pb-1 scrollbar-none [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
<div className="inline-flex min-w-full gap-2.5 px-1 py-1">
{visibleExamples.map((example, index) => {
const isActive = index === activeExampleIndex
const exampleLabel = String(example?.title || '').trim() || `Example ${index + 1}`
return (
<button
key={`${example.title || 'filled-example-tab'}-${index}`}
type="button"
onClick={() => setActiveExampleIndex(index)}
aria-pressed={isActive}
title={exampleLabel}
className={`max-w-full whitespace-nowrap rounded-full border px-4 py-2.5 text-sm font-semibold uppercase tracking-[0.18em] transition ${isActive ? 'border-violet-300/30 bg-violet-300/18 text-white shadow-[0_12px_30px_rgba(76,29,149,0.24)]' : 'border-white/10 bg-white/[0.04] text-violet-100/80 hover:border-violet-300/20 hover:bg-violet-300/10 hover:text-white'}`}
>
<span className="block max-w-[240px] truncate">{exampleLabel}</span>
</button>
)
})}
</div>
</div>
</div>
<PromptFilledExampleCard
key={`${activeExample.title || 'filled-example-active'}-${activeExampleIndex}`}
example={activeExample}
analytics={analytics}
contentId={contentId}
index={activeExampleIndex}
/>
</div>
)
}
function PromptHelperPromptCard({ helperPrompt, analytics, contentId }) {
if (!helperPrompt || typeof helperPrompt !== 'object') return null
@@ -1088,11 +1274,25 @@ export default function AcademyShow({ pageType, item, relatedLessons = [], relat
|| promptDocumentation.data_accuracy_notes.length,
)
const hasPromptPlaceholders = Boolean(item?.has_placeholder_inputs) && promptPlaceholders.length > 0
const promptFilledExamples = Array.isArray(item?.filled_examples)
? item.filled_examples.filter((example) => example && typeof example === 'object' && [
example.title,
example.description,
example.prompt,
example.negative_prompt,
...(example.placeholder_values && typeof example.placeholder_values === 'object' ? Object.values(example.placeholder_values) : []),
].some((value) => value != null && value !== '' && value !== false))
: []
const hasPromptFilledExamples = promptFilledExamples.length > 0
const promptFilledExamplesTotal = Number(item?.filled_examples_total || promptFilledExamples.length || 0)
const promptHasMoreFilledExamples = Boolean(item?.has_more_filled_examples) || promptFilledExamplesTotal > promptFilledExamples.length
const promptHasFullFilledExamplesAccess = Boolean(item?.has_full_filled_examples_access)
const promptHasLockedFilledExamples = Boolean(item?.has_filled_examples) && (!Boolean(item?.can_access_filled_examples) || promptHasMoreFilledExamples)
const promptHasLockedHelperPrompts = Boolean(item?.has_helper_prompts) && !promptHasFullAccess
const promptHasLockedVariants = Boolean(item?.has_prompt_variants) && !promptHasFullAccess
const hasPromptHelperPrompts = promptHelperPrompts.length > 0
const hasPromptVariants = promptVariants.length > 0
const showPromptHelperPrompts = false
const showPromptHelperPrompts = true
const promptAccessRequirement = item?.access_requirement || promptRequirementText(item?.access_level)
const promptUnlockTitle = item?.unlock_heading || promptUnlockHeading(item?.access_level)
const promptUnlockDetails = item?.unlock_description || promptUnlockDescription(item?.access_level)
@@ -2103,6 +2303,45 @@ export default function AcademyShow({ pageType, item, relatedLessons = [], relat
</section>
) : null}
{hasPromptFilledExamples ? (
<section className="academy-paywalled-content rounded-[32px] border border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(167,139,250,0.12),transparent_30%),linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,0.92))] p-6 text-slate-200 shadow-[0_24px_80px_rgba(2,6,23,0.28)] md:p-8">
<div className="max-w-3xl">
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-violet-100/80">Filled examples</p>
<h2 className="mt-2 text-2xl font-semibold tracking-[-0.04em] text-white md:text-3xl">
{promptFilledExamplesTotal > 0 ? `${promptFilledExamplesTotal} ready-made prompt runs for real user inputs` : 'Ready-made prompt runs for real user inputs'}
</h2>
<p className="mt-3 text-sm leading-7 text-slate-300 md:text-base">
{promptHasMoreFilledExamples
? `You can view ${promptFilledExamples.length} example${promptFilledExamples.length === 1 ? '' : 's'} right now. Upgrade to Pro to unlock all ${promptFilledExamplesTotal} filled prompt runs and copy a closer starting point instead of filling everything from scratch.`
: 'These examples show how the prompt looks after swapping real placeholder values, so you can copy a closer starting point instead of filling everything from scratch.'}
</p>
</div>
<PromptFilledExamplesSection examples={promptFilledExamples} analytics={analytics} contentId={item.id} />
</section>
) : null}
{promptHasLockedFilledExamples ? (
<section className="academy-paywalled-content rounded-[32px] border border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(167,139,250,0.12),transparent_30%),linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,0.92))] p-6 text-slate-200 shadow-[0_24px_80px_rgba(2,6,23,0.28)] md:p-8">
<div className="max-w-3xl">
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-violet-100/80">Filled examples</p>
<h2 className="mt-2 text-2xl font-semibold tracking-[-0.04em] text-white md:text-3xl">
{promptHasMoreFilledExamples && hasPromptFilledExamples
? `${Math.max(promptFilledExamplesTotal - promptFilledExamples.length, 0)} more filled prompt example${promptFilledExamplesTotal - promptFilledExamples.length === 1 ? '' : 's'} are available`
: `${promptFilledExamplesTotal || 5} filled prompt examples are included`}
</h2>
<p className="mt-3 text-sm leading-7 text-slate-300 md:text-base">
{promptHasMoreFilledExamples && hasPromptFilledExamples
? 'Creator access includes a smaller set here. Upgrade to Academy Pro to unlock the remaining filled prompt runs.'
: 'This prompt ships with ready-made filled examples for different user inputs, but they unlock only for Academy Pro members.'}
</p>
</div>
<div className="mt-6">
<LockedPanel pricingUrl={pricingUrl} label="prompt" accessLevel="pro" onUpgrade={() => trackUpgradeClick(analytics, { source: 'prompt_filled_examples_locked_panel' })} />
</div>
</section>
) : null}
{showPromptHelperPrompts && hasPromptHelperPrompts ? (
<section className="academy-paywalled-content rounded-[32px] border border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(255,183,139,0.12),transparent_30%),linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,0.92))] p-6 text-slate-200 shadow-[0_24px_80px_rgba(2,6,23,0.28)] md:p-8">
<div className="max-w-3xl">

View File

@@ -52,6 +52,181 @@ function serializeStructuredJson(value) {
}
}
function parseStructuredJson(value) {
if (value == null || value === '') return null
if (typeof value === 'string') {
const trimmed = value.trim()
if (!trimmed) {
return null
}
return JSON.parse(trimmed)
}
return value
}
function toDisplayText(value) {
if (value == null) return ''
if (typeof value === 'string') return value.trim()
if (typeof value === 'number' || typeof value === 'boolean') return String(value)
if (Array.isArray(value)) return value.map((item) => toDisplayText(item)).filter(Boolean).join(', ')
try {
return JSON.stringify(value)
} catch {
return ''
}
}
function humanizePlaceholderKey(value) {
const normalized = String(value || '')
.replace(/[_-]+/g, ' ')
.replace(/\s+/g, ' ')
.trim()
if (!normalized) {
return 'Placeholder'
}
return normalized
.split(' ')
.map((part) => part ? `${part.charAt(0).toUpperCase()}${part.slice(1).toLowerCase()}` : '')
.join(' ')
}
function escapeRegExp(value) {
return String(value || '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
function buildPlaceholderSeedValues(placeholder, limit = 5) {
const readableLabel = humanizePlaceholderKey(placeholder?.label || placeholder?.key || 'Placeholder')
const seeded = [
placeholder?.example,
placeholder?.default,
...(Array.isArray(placeholder?.examples) ? placeholder.examples : []),
...(Array.isArray(placeholder?.options) ? placeholder.options : []),
...(Array.isArray(placeholder?.choices) ? placeholder.choices : []),
...(Array.isArray(placeholder?.values) ? placeholder.values : []),
]
.map((entry) => toDisplayText(entry))
.map((entry) => entry.trim())
.filter(Boolean)
const unique = Array.from(new Set(seeded))
while (unique.length < limit) {
unique.push(`${readableLabel} ${unique.length + 1}`)
}
return unique.slice(0, limit)
}
function normalizePromptPlaceholders(value) {
if (!Array.isArray(value)) return []
return value
.map((placeholder) => {
if (!placeholder || typeof placeholder !== 'object') return null
const key = String(placeholder.key || '').trim()
const label = String(placeholder.label || '').trim()
if (!key && !label) {
return null
}
return {
...placeholder,
key,
label,
}
})
.filter(Boolean)
}
function applyPlaceholderValuesToPrompt(template, placeholderValues, placeholders) {
let nextText = String(template || '')
let replacementCount = 0
placeholders.forEach((placeholder) => {
const key = String(placeholder?.key || '').trim()
if (!key) return
const replacement = toDisplayText(placeholderValues[key])
if (!replacement) return
const patterns = [
new RegExp(`\\[${escapeRegExp(key)}\\]`, 'g'),
new RegExp(`\\{\\{\\s*${escapeRegExp(key)}\\s*\\}\\}`, 'g'),
new RegExp(`\\{${escapeRegExp(key)}\\}`, 'g'),
new RegExp(`<${escapeRegExp(key)}>`, 'g'),
]
patterns.forEach((pattern) => {
nextText = nextText.replace(pattern, () => {
replacementCount += 1
return replacement
})
})
})
if (replacementCount === 0 && placeholders.length > 0) {
const placeholderSummary = placeholders
.map((placeholder) => {
const key = String(placeholder?.key || '').trim()
if (!key) return null
const readableLabel = humanizePlaceholderKey(placeholder.label || key)
const replacement = toDisplayText(placeholderValues[key])
return replacement ? `- ${readableLabel}: ${replacement}` : null
})
.filter(Boolean)
.join('\n')
if (placeholderSummary) {
nextText = `${nextText.trim()}\n\nPlaceholder values:\n${placeholderSummary}`.trim()
}
}
return nextText.trim()
}
function buildStarterFilledExamples({ title, excerpt, prompt, negativePrompt, placeholders }) {
const normalizedPlaceholders = normalizePromptPlaceholders(placeholders)
const exampleCount = Math.min(5, Math.max(1, normalizedPlaceholders.length ? 5 : 1))
const fallbackTitle = stripPlainText(title) || 'Prompt'
const fallbackDescription = stripPlainText(excerpt) || 'Starter example generated from the current placeholders. Review and refine before publishing.'
return Array.from({ length: exampleCount }, (_, index) => {
const placeholderValues = normalizedPlaceholders.reduce((accumulator, placeholder) => {
const key = String(placeholder?.key || '').trim()
if (!key) return accumulator
const seeds = buildPlaceholderSeedValues(placeholder, 5)
accumulator[key] = seeds[index % seeds.length]
return accumulator
}, {})
const titleParts = Object.values(placeholderValues)
.map((value) => stripPlainText(value))
.filter(Boolean)
.slice(0, 2)
return {
title: titleParts.length > 0
? `Example ${index + 1} · ${titleParts.join(' · ')}`.slice(0, 180)
: `Example ${index + 1} · ${fallbackTitle}`.slice(0, 180),
description: `${fallbackDescription} Starter ${index + 1} for editors.`.trim(),
placeholder_values: placeholderValues,
prompt: applyPlaceholderValuesToPrompt(prompt, placeholderValues, normalizedPlaceholders),
negative_prompt: negativePrompt ? applyPlaceholderValuesToPrompt(negativePrompt, placeholderValues, normalizedPlaceholders) : '',
}
})
}
function copyTextToClipboard(text) {
const source = String(text || '')
if (!source) return Promise.reject(new Error('Nothing to copy'))
@@ -229,6 +404,7 @@ const PROMPT_FIELD_TAB_MAP = {
placeholders: 'advanced',
helper_prompts: 'advanced',
prompt_variants: 'advanced',
filled_examples: 'advanced',
preview_image: 'media',
preview_image_file: 'media',
published_at: 'publish',
@@ -436,6 +612,7 @@ function parsePromptImport(rawText, categoryOptions) {
if (parsed.placeholders != null) apply('placeholders', serializeStructuredJson(parsed.placeholders))
if (parsed.helper_prompts != null) apply('helper_prompts', serializeStructuredJson(parsed.helper_prompts))
if (parsed.prompt_variants != null) apply('prompt_variants', serializeStructuredJson(parsed.prompt_variants))
if (parsed.filled_examples != null) apply('filled_examples', serializeStructuredJson(parsed.filled_examples))
if (parsed.preview_image != null) apply('preview_image', String(parsed.preview_image))
if (parsed.preview_image_url != null && parsed.preview_image == null) apply('preview_image', String(parsed.preview_image_url))
if (parsed.published_at != null) apply('published_at', String(parsed.published_at))
@@ -619,6 +796,30 @@ function PromptJsonImportDialog({ open, value, error, onChange, onClose, onApply
active: true,
},
],
filled_examples: [
{
title: 'Alpine sunrise travel poster',
description: 'A scenic poster version tuned for crisp mountain light and clean copy-safe composition.',
placeholder_values: {
LOCATION: 'Lake Bled, Slovenia',
SEASON: 'spring',
MOOD: 'calm sunrise',
},
prompt: 'Create a calm sunrise travel poster of Lake Bled in spring, with clear mountain reflections, light mist, soft golden light, and a clean editorial composition.',
negative_prompt: 'muddy light, cluttered foreground, oversharpening, distorted architecture',
},
{
title: 'Misty forest variant',
description: 'Leans into atmosphere and fog while keeping the same placeholder structure.',
placeholder_values: {
LOCATION: 'Triglav National Park',
SEASON: 'autumn',
MOOD: 'misty cinematic',
},
prompt: 'Create a cinematic autumn landscape in Triglav National Park with layered mist, warm foliage, soft directional light, and strong depth.',
negative_prompt: 'flat composition, weak fog, repetitive trees, blown highlights',
},
],
preview_image: 'https://files.skinbase.org/prompts/peaceful-fantasy-forest.webp',
featured: false,
prompt_of_week: false,
@@ -659,6 +860,7 @@ Recommended fields:
- placeholders: array of prompt variable objects
- helper_prompts: array of supporting prompts used before or after the main prompt
- prompt_variants: array of alternative prompt versions
- filled_examples: array of up to 5 filled prompt examples with placeholder_values and final prompts
- preview_image: path or URL
- featured: boolean
- prompt_of_week: boolean
@@ -698,10 +900,18 @@ prompt_variants object fields:
- risk_notes
- active boolean
filled_examples object fields:
- title
- description
- placeholder_values: object keyed by placeholder name
- prompt
- negative_prompt
Rules:
- Return one JSON object only.
- Keep excerpt concise and readable in cards.
- Keep tags relevant and production-usable.
- Include exactly 5 filled_examples whenever the prompt uses placeholders or has clear user-editable parameters.
- If you include tool_notes, keep them normalized and consistent.`
const aiPromptExamples = [
@@ -714,6 +924,7 @@ Create a Skinbase Academy prompt template JSON object from the following creativ
- Write a prompt that is immediately usable.
- Write an excerpt that works in cards and search results.
- Add 5 to 12 focused tags.
- Include 5 filled_examples with realistic placeholder_values and ready-to-copy final prompts.
- Include 2 to 4 tool_notes comparisons when the brief mentions multiple AI providers.
Creative brief:
@@ -727,6 +938,7 @@ Generate a prompt template JSON object for Skinbase Academy.
- Focus on the same core prompt being tested across multiple AI image providers.
- Include tool_notes entries for each provider.
- Each tool_notes item should explain settings, strengths, weaknesses, and best_for in plain production language.
- Include 5 filled_examples that show how users would swap placeholder values in real projects.
- Return JSON only.
Source notes:
@@ -740,6 +952,7 @@ Convert the following source prompt page into structured Skinbase Academy prompt
- Preserve the core instruction intent.
- Normalize tags and metadata.
- Convert provider reviews into tool_notes.
- Generate 5 filled_examples that demonstrate realistic filled-in prompt runs for end users.
- Use category/category_slug when category_id is unknown.
- Return JSON only.
@@ -832,6 +1045,7 @@ Source content:
<p>usage_notes, workflow_notes</p>
<p>documentation, placeholders</p>
<p>helper_prompts, prompt_variants</p>
<p>filled_examples</p>
<p>preview_image, preview_image_url</p>
<p>published_at, seo_title, seo_description</p>
<p>featured, prompt_of_week, active</p>
@@ -855,7 +1069,7 @@ Source content:
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Notes</div>
<div className="mt-3 space-y-3 leading-6 text-slate-400">
<p>`tool_notes` can be an array of comparison objects or a simpler array under `comparisons`.</p>
<p>`documentation`, `placeholders`, `helper_prompts`, and `prompt_variants` can be nested JSON and are preserved during import.</p>
<p>`documentation`, `placeholders`, `helper_prompts`, `prompt_variants`, and `filled_examples` can be nested JSON and are preserved during import.</p>
<p>`tags` can be strings or objects with `name`, `label`, `title`, or `slug`.</p>
<p>`preview_image` accepts either a stored path or an external URL.</p>
</div>
@@ -876,6 +1090,7 @@ Source content:
<p><strong className="text-slate-200">placeholders</strong> - prompt variables such as `CITY_NAME` or `MONTHLY_WEATHER_DATA`.</p>
<p><strong className="text-slate-200">helper_prompts</strong> - supporting prompts for data collection, validation, or refinement.</p>
<p><strong className="text-slate-200">prompt_variants</strong> - alternative versions of the same prompt for safer or model-specific output.</p>
<p><strong className="text-slate-200">filled_examples</strong> - up to 5 ready-to-copy filled prompt runs that show real placeholder substitutions.</p>
<p><strong className="text-slate-200">tool_notes</strong> - structured comparison notes for provider/model variants.</p>
<p><strong className="text-slate-200">preview_image</strong> - existing asset URL or stored path. File upload still happens separately.</p>
<p><strong className="text-slate-200">category_id</strong> is preferred when known. `category` or `category_slug` are used for best-effort matching.</p>
@@ -889,7 +1104,7 @@ Source content:
<p>Use JSON booleans for featured, prompt_of_week, and active.</p>
<p>Use `YYYY-MM-DD HH:MM:SS` for `published_at` when scheduling is needed.</p>
<p>Use `documentation` for longer public guidance, and keep `usage_notes` short and practical.</p>
<p>Use `helper_prompts` for data collection or validation prompts, and `prompt_variants` for safer or model-specific alternatives.</p>
<p>Use `helper_prompts` for data collection or validation prompts, `prompt_variants` for safer or model-specific alternatives, and `filled_examples` for ready-made filled prompt runs.</p>
<p>Keep comparison rows normalized so provider/model names remain consistent in the frontend.</p>
</div>
</div>
@@ -915,7 +1130,7 @@ Source content:
<div className="mt-3 space-y-2 leading-6 text-slate-400">
<p>Tell the model to return JSON only, with no explanation text.</p>
<p>Ask for `tool_notes` when you want provider-by-provider comparison output.</p>
<p>Ask for `documentation`, `placeholders`, `helper_prompts`, and `prompt_variants` only when the prompt needs advanced structure.</p>
<p>Ask for `documentation`, `placeholders`, `helper_prompts`, `prompt_variants`, and `filled_examples` when the prompt needs advanced structure and user-ready examples.</p>
<p>Tell the model to keep titles and tags production-ready, not overly verbose.</p>
</div>
</div>
@@ -1799,6 +2014,7 @@ function PromptEditor({ title, subtitle, fields, record, submitUrl, indexUrl, de
const placeholdersField = useMemo(() => getField(fields, 'placeholders'), [fields])
const helperPromptsField = useMemo(() => getField(fields, 'helper_prompts'), [fields])
const promptVariantsField = useMemo(() => getField(fields, 'prompt_variants'), [fields])
const filledExamplesField = useMemo(() => getField(fields, 'filled_examples'), [fields])
const slugTouchedRef = useRef(Boolean(String(record.slug || '').trim()))
const [activeTab, setActiveTab] = useState('overview')
const [jsonImportOpen, setJsonImportOpen] = useState(false)
@@ -1876,6 +2092,60 @@ function PromptEditor({ title, subtitle, fields, record, submitUrl, indexUrl, de
}
}
const generateStarterFilledExamples = () => {
let parsedPlaceholders
try {
parsedPlaceholders = parseStructuredJson(form.data.placeholders)
} catch {
const message = `${placeholdersField?.label || 'Placeholders JSON'} must be valid JSON before generating filled examples.`
form.setError('placeholders', message)
setActiveTab('advanced')
showToast(message, 'error')
return
}
const normalizedPlaceholders = normalizePromptPlaceholders(parsedPlaceholders)
if (normalizedPlaceholders.length === 0) {
const message = 'Add at least one placeholder before generating starter filled examples.'
form.setError('placeholders', message)
setActiveTab('advanced')
showToast(message, 'error')
return
}
const promptText = String(form.data.prompt || '').trim()
if (!promptText) {
const message = 'Write the main prompt before generating starter filled examples.'
form.setError('prompt', message)
setActiveTab('prompt')
showToast(message, 'error')
return
}
const existingExamples = String(form.data.filled_examples || '').trim()
if (existingExamples && typeof window !== 'undefined' && !window.confirm('Replace the current filled examples with a new 5-example starter set?')) {
return
}
const generatedExamples = buildStarterFilledExamples({
title: form.data.title,
excerpt: form.data.excerpt,
prompt: promptText,
negativePrompt: form.data.negative_prompt,
placeholders: normalizedPlaceholders,
})
form.clearErrors('placeholders')
form.clearErrors('filled_examples')
form.setData('filled_examples', serializeStructuredJson(generatedExamples))
setActiveTab('advanced')
showToast('Generated 5 starter filled examples. Review them before saving.', 'success')
}
const submit = (event) => {
event.preventDefault()
@@ -1884,6 +2154,7 @@ function PromptEditor({ title, subtitle, fields, record, submitUrl, indexUrl, de
{ name: 'placeholders', label: placeholdersField?.label || 'Placeholders JSON' },
{ name: 'helper_prompts', label: helperPromptsField?.label || 'Helper Prompts JSON' },
{ name: 'prompt_variants', label: promptVariantsField?.label || 'Prompt Variants JSON' },
{ name: 'filled_examples', label: filledExamplesField?.label || 'Filled Examples JSON' },
]
const parsedJsonFields = {}
@@ -1943,6 +2214,12 @@ function PromptEditor({ title, subtitle, fields, record, submitUrl, indexUrl, de
form.post(submitUrl, submitOptions)
}
const hasRequiredCategory = useMemo(() => {
const existing = String(form.data.category_id || '').trim()
const named = String(form.data.new_category_name || '').trim()
return Boolean(existing || named)
}, [form.data.category_id, form.data.new_category_name])
return (
<AdminLayout title={title} subtitle={subtitle}>
<Head title={`Admin · ${title}`} />
@@ -1996,7 +2273,7 @@ function PromptEditor({ title, subtitle, fields, record, submitUrl, indexUrl, de
<i className="fa-solid fa-file-import text-xs" />
<span>Import JSON</span>
</button>
<button type="submit" disabled={form.processing} className="inline-flex items-center gap-2 whitespace-nowrap rounded-2xl border border-sky-300/25 bg-sky-300/18 px-4 py-2 text-sm font-semibold text-sky-100 shadow-[inset_0_1px_0_rgba(255,255,255,0.05)] transition hover:bg-sky-300/24">
<button type="submit" disabled={form.processing || !hasRequiredCategory} className="inline-flex items-center gap-2 whitespace-nowrap rounded-2xl border border-sky-300/25 bg-sky-300/18 px-4 py-2 text-sm font-semibold text-sky-100 shadow-[inset_0_1px_0_rgba(255,255,255,0.05)] transition hover:bg-sky-300/24">
<i className="fa-solid fa-floppy-disk text-xs" />
<span>{form.processing ? 'Saving...' : 'Save prompt'}</span>
</button>
@@ -2034,6 +2311,9 @@ function PromptEditor({ title, subtitle, fields, record, submitUrl, indexUrl, de
}
}} options={categoryOptions} searchable searchPlaceholder="Filter categories..." className="rounded-2xl bg-black/20" error={form.errors.category_id} /> : null}
<TextField label="Or enter new category" value={form.data.new_category_name || ''} onChange={(event) => form.setData('new_category_name', event.target.value)} error={form.errors.new_category_name} placeholder="New prompt category name" />
{!hasRequiredCategory ? (
<div className="mt-2 text-xs text-rose-300">Choose an existing category or enter a new category name before saving.</div>
) : null}
{difficultyField ? <NovaSelect label={difficultyField.label} value={form.data.difficulty ?? ''} onChange={(nextValue) => form.setData('difficulty', nextValue ?? '')} options={difficultyField.options || []} searchable={false} className="rounded-2xl bg-black/20" error={form.errors.difficulty} /> : null}
</div>
@@ -2085,6 +2365,16 @@ function PromptEditor({ title, subtitle, fields, record, submitUrl, indexUrl, de
<TextAreaField label={helperPromptsField?.label || 'Helper Prompts JSON'} value={form.data.helper_prompts || ''} onChange={(event) => form.setData('helper_prompts', event.target.value)} error={form.errors.helper_prompts} rows={12} hint="Array of supporting prompts used for data collection, preparation, validation, or refinement." />
</div>
<TextAreaField label={promptVariantsField?.label || 'Prompt Variants JSON'} value={form.data.prompt_variants || ''} onChange={(event) => form.setData('prompt_variants', event.target.value)} error={form.errors.prompt_variants} rows={12} hint="Array of alternative prompt versions with prompt, negative_prompt, recommended flags, and risk notes." />
<div className="flex flex-wrap items-center justify-between gap-3 rounded-[24px] border border-white/10 bg-black/20 px-4 py-3">
<div>
<p className="text-sm font-semibold text-white">Starter filled examples</p>
<p className="mt-1 text-xs leading-5 text-slate-400">Generate 5 editable examples from the current placeholders, prompt text, and negative prompt.</p>
</div>
<button type="button" onClick={generateStarterFilledExamples} className="rounded-full border border-amber-300/25 bg-amber-300/12 px-4 py-2.5 text-sm font-semibold text-amber-100 transition hover:bg-amber-300/18">
Generate 5 starter examples
</button>
</div>
<TextAreaField label={filledExamplesField?.label || 'Filled Examples JSON'} value={form.data.filled_examples || ''} onChange={(event) => form.setData('filled_examples', event.target.value)} error={form.errors.filled_examples} rows={12} hint="Array of up to 5 filled prompt examples with title, description, placeholder_values, prompt, and optional negative_prompt." />
</SectionCard>
<SectionCard eyebrow="Structured blocks" title="AI model comparisons" description="Add reusable same-prompt comparison notes without burying provider-specific behavior inside the main prompt body." className={sectionClassName('prompt-comparisons')}>

View File

@@ -1,6 +1,7 @@
import React, { useEffect, useMemo, useState } from 'react'
import { Head, Link, router, usePage } from '@inertiajs/react'
import AdminLayout from '../../../Layouts/AdminLayout'
import AccessBadge from '../../../components/academy/billing/AccessBadge'
const PROMPT_VIEW_STORAGE_KEY = 'skinbase.admin.academy.prompts.view'
const COURSE_VIEW_STORAGE_KEY = 'skinbase.admin.academy.courses.view'
@@ -84,14 +85,34 @@ function courseSummary(items = [], summary = null) {
}), { total: 0, published: 0, featured: 0, drafts: 0, visibleOnPage: 0 })
}
function promptSummary(items = []) {
function promptSummary(items = [], summary = null) {
if (summary && typeof summary === 'object') {
return {
total: Number(summary.total || 0),
active: Number(summary.active || 0),
featured: Number(summary.featured || 0),
promptOfWeek: Number(summary.promptOfWeek || 0),
comparisons: Array.isArray(items) ? items.reduce((count, item) => count + Number(item.comparisons_count || 0), 0) : 0,
access: {
free: Number(summary.access?.free || 0),
creator: Number(summary.access?.creator || 0),
pro: Number(summary.access?.pro || 0),
},
}
}
return items.reduce((summary, item) => ({
total: summary.total + 1,
active: summary.active + (item.active ? 1 : 0),
featured: summary.featured + (item.featured ? 1 : 0),
promptOfWeek: summary.promptOfWeek + (item.prompt_of_week ? 1 : 0),
comparisons: summary.comparisons + Number(item.comparisons_count || 0),
}), { total: 0, active: 0, featured: 0, promptOfWeek: 0, comparisons: 0 })
access: {
free: summary.access.free + (item.access_level === 'free' ? 1 : 0),
creator: summary.access.creator + (item.access_level === 'creator' ? 1 : 0),
pro: summary.access.pro + (item.access_level === 'pro' ? 1 : 0),
},
}), { total: 0, active: 0, featured: 0, promptOfWeek: 0, comparisons: 0, access: { free: 0, creator: 0, pro: 0 } })
}
function PromptFlag({ children, tone = 'default' }) {
@@ -371,9 +392,9 @@ function PromptPreview({ item, compact = false }) {
function PromptMeta({ item }) {
return (
<div className="flex flex-wrap gap-2">
{item.access_level ? <AccessBadge tier={item.access_level} className="px-3 py-1" /> : null}
{item.category_name ? <PromptFlag tone="warm">{item.category_name}</PromptFlag> : null}
{item.difficulty ? <PromptFlag>{item.difficulty}</PromptFlag> : null}
{item.access_level ? <PromptFlag>{item.access_level}</PromptFlag> : null}
{item.aspect_ratio ? <PromptFlag>{item.aspect_ratio}</PromptFlag> : null}
{item.featured ? <PromptFlag tone="sky">Featured</PromptFlag> : null}
{item.prompt_of_week ? <PromptFlag tone="emerald">Prompt of week</PromptFlag> : null}
@@ -390,6 +411,8 @@ function PromptGalleryCard({ item }) {
<PromptPreview item={item} />
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(2,6,23,0.04),rgba(2,6,23,0.32))]" />
<div className="absolute left-4 top-4 flex flex-wrap gap-2">
<AccessBadge tier={item.access_level || 'free'} className="px-3 py-1.5 text-[12px]" />
<PromptFlag>{Number(item.views_count || 0).toLocaleString()} views</PromptFlag>
<PromptFlag tone="warm">{item.comparisons_count || 0} comparisons</PromptFlag>
{item.slug ? <PromptFlag>{item.slug}</PromptFlag> : null}
</div>
@@ -418,7 +441,7 @@ function PromptGalleryCard({ item }) {
</div>
<div>
<p className="text-[10px] font-semibold uppercase tracking-[0.2em] text-slate-500">Access</p>
<p className="mt-1 text-sm font-semibold text-white">{item.access_level || 'free'}</p>
<div className="mt-2"><AccessBadge tier={item.access_level || 'free'} /></div>
</div>
<div>
<p className="text-[10px] font-semibold uppercase tracking-[0.2em] text-slate-500">Status</p>
@@ -440,6 +463,9 @@ function PromptGridCard({ item }) {
<div className="relative h-52 overflow-hidden border-b border-white/10">
<PromptPreview item={item} compact />
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(2,6,23,0.02),rgba(2,6,23,0.34))]" />
<div className="absolute left-4 top-4">
<AccessBadge tier={item.access_level || 'free'} className="px-3 py-1.5 text-[12px]" />
</div>
</div>
<div className="p-5">
<PromptMeta item={item} />
@@ -447,7 +473,7 @@ function PromptGridCard({ item }) {
<p className="mt-3 text-sm leading-7 text-slate-300">{item.excerpt || 'No excerpt added yet.'}</p>
<div className="mt-5 flex items-center justify-between gap-3 text-sm text-slate-400">
<span>{formatDateLabel(item.updated_at)}</span>
<span>{item.comparisons_count || 0} comparisons</span>
<span>{Number(item.views_count || 0).toLocaleString()} views</span>
</div>
<div className="mt-5">
<PromptActions item={item} />
@@ -468,6 +494,7 @@ function PromptTable({ items }) {
<th className="px-5 py-4">Category</th>
<th className="px-5 py-4">Access</th>
<th className="px-5 py-4">Signals</th>
<th className="px-5 py-4">Views</th>
<th className="px-5 py-4">Updated</th>
<th className="px-5 py-4 text-right">Actions</th>
</tr>
@@ -487,7 +514,7 @@ function PromptTable({ items }) {
</div>
</td>
<td className="px-5 py-4">{item.category_name || 'Uncategorized'}</td>
<td className="px-5 py-4">{item.access_level || 'free'}</td>
<td className="px-5 py-4"><AccessBadge tier={item.access_level || 'free'} className="px-3 py-1.5 text-[12px]" /></td>
<td className="px-5 py-4">
<div className="space-y-1">
<p>{item.comparisons_count || 0} comparisons</p>
@@ -495,6 +522,7 @@ function PromptTable({ items }) {
<p>{item.active ? 'Active' : 'Draft'}</p>
</div>
</td>
<td className="px-5 py-4">{Number(item.views_count || 0).toLocaleString()}</td>
<td className="px-5 py-4">{formatDateLabel(item.updated_at)}</td>
<td className="px-5 py-4">
<div className="flex justify-end gap-2">
@@ -511,6 +539,99 @@ function PromptTable({ items }) {
)
}
function PromptStatCard({ label, value, tone = 'default' }) {
const toneClass = tone === 'sky'
? 'border-sky-300/20 bg-sky-300/10 text-sky-100'
: tone === 'emerald'
? 'border-emerald-300/20 bg-emerald-300/10 text-emerald-100'
: tone === 'warm'
? 'border-amber-300/20 bg-amber-300/10 text-amber-100'
: 'border-white/10 bg-black/20 text-slate-300'
return (
<div className={`rounded-[24px] border px-5 py-4 ${toneClass}`}>
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] opacity-70">{label}</p>
<p className="mt-2 text-2xl font-semibold tracking-[-0.04em] text-white">{value}</p>
</div>
)
}
function PromptSelect({ value, options = [], onChange }) {
return (
<select
value={value}
onChange={(event) => onChange(event.target.value)}
className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white focus:border-white/20 focus:outline-none focus:ring-1 focus:ring-white/10"
>
{options.map((option) => (
<option key={`${option.value}-${option.label}`} value={option.value} className="bg-slate-950 text-white">
{option.label}
</option>
))}
</select>
)
}
function PromptSearchBar({ filters, onChange, onSubmit, onReset, viewMode, onViewModeChange, filterOptions = {} }) {
return (
<div className="rounded-[28px] border border-white/10 bg-white/[0.03] p-4 shadow-[0_18px_50px_rgba(2,6,23,0.14)]">
<div className="flex flex-col gap-4 2xl:flex-row 2xl:items-start 2xl:justify-between">
<form onSubmit={onSubmit} className="flex-1 space-y-4">
<div className="grid gap-4 xl:grid-cols-[minmax(0,1.2fr)_repeat(3,minmax(0,0.8fr))]">
<div className="relative">
<i className="fa-solid fa-magnifying-glass absolute left-3.5 top-1/2 -translate-y-1/2 text-xs text-slate-500" />
<input
name="search"
value={filters.search}
onChange={(event) => onChange('search', event.target.value)}
placeholder="Search title, slug, excerpt, prompt text, or category…"
className="w-full rounded-2xl border border-white/10 bg-black/20 py-3 pl-9 pr-4 text-sm text-white placeholder:text-slate-600 focus:border-white/20 focus:outline-none focus:ring-1 focus:ring-white/10"
/>
</div>
<PromptSelect value={filters.category} onChange={(value) => onChange('category', value)} options={filterOptions.categories} />
<PromptSelect value={filters.access_level} onChange={(value) => onChange('access_level', value)} options={filterOptions.access} />
<PromptSelect value={filters.order} onChange={(value) => onChange('order', value)} options={filterOptions.order} />
</div>
<div className="grid gap-4 xl:grid-cols-4">
<PromptSelect value={filters.difficulty} onChange={(value) => onChange('difficulty', value)} options={filterOptions.difficulty} />
<PromptSelect value={filters.featured} onChange={(value) => onChange('featured', value)} options={filterOptions.featured} />
<PromptSelect value={filters.prompt_of_week} onChange={(value) => onChange('prompt_of_week', value)} options={filterOptions.promptOfWeek} />
<PromptSelect value={filters.active} onChange={(value) => onChange('active', value)} options={filterOptions.active} />
</div>
<div className="flex flex-wrap gap-3">
<button type="submit" className="rounded-2xl bg-sky-300/12 px-4 py-3 text-sm font-semibold text-sky-100 transition hover:bg-sky-300/16">
Apply filters
</button>
<button type="button" onClick={onReset} className="rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm font-semibold text-white/80 transition hover:bg-white/[0.08]">
Reset
</button>
</div>
</form>
<div className="flex flex-wrap items-center gap-2">
{PROMPT_VIEW_OPTIONS.map((option) => {
const active = option.value === viewMode
return (
<button
key={option.value}
type="button"
onClick={() => onViewModeChange(option.value)}
className={`inline-flex items-center gap-2 rounded-full border px-4 py-2.5 text-sm font-semibold transition ${active ? 'border-sky-300/25 bg-sky-300/12 text-sky-100' : 'border-white/10 bg-white/[0.04] text-slate-200 hover:border-white/20 hover:bg-white/[0.07]'}`}
>
<i className={`fa-solid ${option.icon} text-xs`} />
<span>{option.label}</span>
</button>
)
})}
</div>
</div>
</div>
)
}
function PromptHeroCollage({ items = [] }) {
const images = items
.map((item) => item?.preview_image_url)
@@ -734,10 +855,21 @@ function renderCrudCell(column, item) {
return <p className="mt-1 text-sm text-white">{String(item[column] ?? '')}</p>
}
function PromptIndexContent({ title, subtitle, items, createUrl }) {
function PromptIndexContent({ title, subtitle, items, createUrl, filters = {}, summary = {}, filterOptions = {} }) {
const { url } = usePage()
const promptItems = items?.data || []
const summary = promptSummary(promptItems)
const stats = useMemo(() => promptSummary(promptItems, summary), [promptItems, summary])
const [viewMode, setViewMode] = useState('gallery')
const [query, setQuery] = useState({
search: filters.search || '',
category: filters.category || 'all',
featured: filters.featured || 'all',
prompt_of_week: filters.prompt_of_week || 'all',
active: filters.active || 'all',
access_level: filters.access_level || 'all',
difficulty: filters.difficulty || 'all',
order: filters.order || 'updated_desc',
})
useEffect(() => {
if (typeof window === 'undefined') return
@@ -753,6 +885,72 @@ function PromptIndexContent({ title, subtitle, items, createUrl }) {
window.localStorage.setItem(PROMPT_VIEW_STORAGE_KEY, viewMode)
}, [viewMode])
useEffect(() => {
setQuery({
search: filters.search || '',
category: filters.category || 'all',
featured: filters.featured || 'all',
prompt_of_week: filters.prompt_of_week || 'all',
active: filters.active || 'all',
access_level: filters.access_level || 'all',
difficulty: filters.difficulty || 'all',
order: filters.order || 'updated_desc',
})
}, [filters])
const currentPath = url.split('?')[0]
const meta = items?.meta || {}
const hasFilters = Boolean(
(query.search || '').trim()
|| query.category !== 'all'
|| query.featured !== 'all'
|| query.prompt_of_week !== 'all'
|| query.active !== 'all'
|| query.access_level !== 'all'
|| query.difficulty !== 'all'
|| query.order !== 'updated_desc'
)
const applyQuery = (nextQuery) => {
const payload = {}
if ((nextQuery.search || '').trim()) payload.search = nextQuery.search.trim()
if (nextQuery.category && nextQuery.category !== 'all') payload.category = nextQuery.category
if (nextQuery.featured && nextQuery.featured !== 'all') payload.featured = nextQuery.featured
if (nextQuery.prompt_of_week && nextQuery.prompt_of_week !== 'all') payload.prompt_of_week = nextQuery.prompt_of_week
if (nextQuery.active && nextQuery.active !== 'all') payload.active = nextQuery.active
if (nextQuery.access_level && nextQuery.access_level !== 'all') payload.access_level = nextQuery.access_level
if (nextQuery.difficulty && nextQuery.difficulty !== 'all') payload.difficulty = nextQuery.difficulty
if (nextQuery.order && nextQuery.order !== 'updated_desc') payload.order = nextQuery.order
router.get(currentPath, payload, { preserveScroll: true, preserveState: true, replace: true })
}
const handleFilterChange = (key, value) => {
setQuery((current) => ({ ...current, [key]: value }))
}
const handleSubmit = (event) => {
event.preventDefault()
applyQuery(query)
}
const handleReset = () => {
const nextQuery = {
search: '',
category: 'all',
featured: 'all',
prompt_of_week: 'all',
active: 'all',
access_level: 'all',
difficulty: 'all',
order: 'updated_desc',
}
setQuery(nextQuery)
router.get(currentPath, {}, { preserveScroll: true, preserveState: true, replace: true })
}
return (
<div className="space-y-6">
<section className="overflow-hidden rounded-[38px] border border-white/[0.08] bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.16),transparent_24%),radial-gradient(circle_at_bottom_right,rgba(255,207,191,0.16),transparent_24%),linear-gradient(135deg,rgba(4,9,18,0.98),rgba(15,23,42,0.92))] shadow-[0_28px_90px_rgba(2,6,23,0.28)]">
@@ -781,46 +979,40 @@ function PromptIndexContent({ title, subtitle, items, createUrl }) {
</div>
</div>
<div className="mt-6 flex flex-wrap gap-3">
{PROMPT_VIEW_OPTIONS.map((option) => {
const active = option.value === viewMode
return (
<button
key={option.value}
type="button"
onClick={() => setViewMode(option.value)}
className={`inline-flex items-center gap-2 rounded-full border px-4 py-2 text-sm font-semibold transition ${active ? 'border-sky-300/25 bg-sky-300/12 text-sky-100' : 'border-white/10 bg-white/[0.04] text-slate-200 hover:border-white/20 hover:bg-white/[0.07]'}`}
>
<i className={`fa-solid ${option.icon}`} />
<span>{option.label} view</span>
</button>
)
})}
</div>
<div className="mt-7 flex flex-nowrap gap-3 overflow-x-auto pb-1">
<Link href={createUrl} className="inline-flex items-center gap-2 whitespace-nowrap rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100"><i className="fa-solid fa-plus text-xs" />Create prompt</Link>
<Link href="/academy/prompts" className="inline-flex items-center gap-2 whitespace-nowrap rounded-full border border-white/10 bg-white/[0.04] px-5 py-3 text-sm font-semibold text-white/85"><i className="fa-solid fa-book-open text-xs" />Open public library</Link>
<span className="inline-flex items-center gap-2 whitespace-nowrap rounded-full border border-white/10 bg-white/[0.04] px-5 py-3 text-sm font-semibold text-white/85"><i className="fa-solid fa-layer-group text-xs" />{summary.total} prompts in view</span>
<span className="inline-flex items-center gap-2 whitespace-nowrap rounded-full border border-white/10 bg-white/[0.04] px-5 py-3 text-sm font-semibold text-white/85"><i className="fa-solid fa-layer-group text-xs" />{stats.total} prompts in view</span>
</div>
<div className="mt-7 grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<PromptStatCard label="Active" value={stats.active} tone="emerald" />
<PromptStatCard label="Featured" value={stats.featured} tone="sky" />
<PromptStatCard label="Prompt of week" value={stats.promptOfWeek} tone="warm" />
<PromptStatCard label="Views on page" value={promptItems.reduce((count, item) => count + Number(item.views_count || 0), 0).toLocaleString()} />
</div>
<div className="mt-3 grid gap-3 sm:grid-cols-3">
<div className="rounded-[24px] border border-white/10 bg-black/20 px-5 py-4">
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">Active</p>
<p className="mt-2 text-2xl font-semibold tracking-[-0.04em] text-white">{summary.active}</p>
<div className="flex items-center justify-between gap-3">
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">Free access</p>
<AccessBadge tier="free" />
</div>
<p className="mt-2 text-2xl font-semibold tracking-[-0.04em] text-white">{stats.access.free}</p>
</div>
<div className="rounded-[24px] border border-white/10 bg-black/20 px-5 py-4">
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">Featured</p>
<p className="mt-2 text-2xl font-semibold tracking-[-0.04em] text-white">{summary.featured}</p>
<div className="flex items-center justify-between gap-3">
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">Creator access</p>
<AccessBadge tier="creator" />
</div>
<p className="mt-2 text-2xl font-semibold tracking-[-0.04em] text-white">{stats.access.creator}</p>
</div>
<div className="rounded-[24px] border border-white/10 bg-black/20 px-5 py-4">
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">Prompt of week</p>
<p className="mt-2 text-2xl font-semibold tracking-[-0.04em] text-white">{summary.promptOfWeek}</p>
<div className="flex items-center justify-between gap-3">
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">Pro access</p>
<AccessBadge tier="pro" />
</div>
<div className="rounded-[24px] border border-white/10 bg-black/20 px-5 py-4">
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">Comparisons</p>
<p className="mt-2 text-2xl font-semibold tracking-[-0.04em] text-white">{summary.comparisons}</p>
<p className="mt-2 text-2xl font-semibold tracking-[-0.04em] text-white">{stats.access.pro}</p>
</div>
</div>
</div>
@@ -831,8 +1023,27 @@ function PromptIndexContent({ title, subtitle, items, createUrl }) {
</div>
</section>
<PromptSearchBar
filters={query}
onChange={handleFilterChange}
onSubmit={handleSubmit}
onReset={handleReset}
viewMode={viewMode}
onViewModeChange={setViewMode}
filterOptions={filterOptions}
/>
<div className="flex flex-wrap items-center justify-between gap-4">
<p className="text-sm text-slate-400">Manage Academy content below. Changes clear Academy cache automatically.</p>
<p className="text-sm text-slate-400">
{meta.total ? (
<>
Showing {meta.from || 0}-{meta.to || 0} of {meta.total} prompts
{hasFilters ? <span className="ml-2 text-sky-200">with active search or filters</span> : null}
</>
) : (
'Manage Academy content below. Changes clear Academy cache automatically.'
)}
</p>
<div className="flex flex-wrap gap-3">
<Link href="/academy/prompts" className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-5 py-3 text-sm font-semibold text-white/85"><i className="fa-solid fa-book-open text-xs" />View public library</Link>
<Link href={createUrl} className="inline-flex items-center gap-2 rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100"><i className="fa-solid fa-plus text-xs" />Create prompt</Link>
@@ -840,7 +1051,16 @@ function PromptIndexContent({ title, subtitle, items, createUrl }) {
</div>
{promptItems.length === 0 ? (
<div className="rounded-[28px] border border-white/[0.08] bg-white/[0.03] px-6 py-12 text-center text-slate-400">No prompt templates exist yet.</div>
<div className="rounded-[28px] border border-white/[0.08] bg-white/[0.03] px-6 py-12 text-center text-slate-400">
{hasFilters ? (
<div className="space-y-3">
<p className="text-lg font-semibold text-white">No prompt templates matched these filters.</p>
<button type="button" onClick={handleReset} className="rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-white">Reset filters</button>
</div>
) : (
'No prompt templates exist yet.'
)}
</div>
) : viewMode === 'table' ? (
<PromptTable items={promptItems} />
) : viewMode === 'grid' ? (
@@ -863,6 +1083,7 @@ export default function AcademyCrudIndex({ title, subtitle, items, columns, crea
const resource = usePage().props.resource
const filters = usePage().props.filters || {}
const summary = usePage().props.summary || {}
const filterOptions = usePage().props.filterOptions || {}
return (
<AdminLayout title={title} subtitle={subtitle}>
@@ -873,7 +1094,7 @@ export default function AcademyCrudIndex({ title, subtitle, items, columns, crea
{resource === 'courses' ? (
<CourseIndexContent title={title} subtitle={subtitle} items={items} createUrl={createUrl} filters={filters} summary={summary} />
) : resource === 'prompts' ? (
<PromptIndexContent title={title} subtitle={subtitle} items={items} createUrl={createUrl} />
<PromptIndexContent title={title} subtitle={subtitle} items={items} createUrl={createUrl} filters={filters} summary={summary} filterOptions={filterOptions} />
) : (
<>
<div className="mb-6 flex items-center justify-between gap-4">

View File

@@ -52,7 +52,6 @@ export default function Dashboard({ stats }) {
{ label: 'Artworks', href: '/moderation/artworks', icon: 'fa-solid fa-images', desc: 'Browse all uploaded artworks' },
{ label: 'Enhance Jobs', href: '/moderation/enhance', icon: 'fa-solid fa-up-right-and-down-left-from-center', desc: 'Inspect queued, failed, and completed image enhance jobs' },
{ label: 'Featured Artworks', href: '/moderation/artworks/featured', icon: 'fa-solid fa-star', desc: 'Curate the homepage featured artwork lineup' },
{ label: 'AI Biography', href: '/moderation/ai-biography', icon: 'fa-solid fa-wand-magic-sparkles', desc: 'Review generated creator biographies and moderation flags' },
].map((item) => (
<a
key={item.href}

View File

@@ -316,7 +316,7 @@ function CategoriesPage({ apiUrl = '/api/categories', pageTitle = 'Categories',
const hasMorePages = meta.current_page < meta.last_page
return (
<div className="pb-24 text-white">
<div className="categories-page pb-24 text-white">
<section className="relative overflow-hidden">
<div className="absolute inset-x-0 top-0 h-[28rem] bg-[radial-gradient(circle_at_top_left,rgba(34,211,238,0.12),transparent_38%),radial-gradient(circle_at_top_right,rgba(249,115,22,0.14),transparent_34%)]" />
<div className="relative w-full px-6 pb-8 pt-14 sm:px-8 sm:pt-20 xl:px-10 2xl:px-14 lg:pt-24">

View File

@@ -16,10 +16,10 @@ export default function GroupIndex() {
const leaderboardItems = Array.isArray(props.leaderboard?.items) ? props.leaderboard.items : []
return (
<main className="min-h-screen bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.16),_transparent_28%),linear-gradient(180deg,_#020617_0%,_#02040a_100%)] px-4 py-10 sm:px-6 lg:px-8">
<main className="groups-directory-page min-h-screen bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.16),_transparent_28%),linear-gradient(180deg,_#020617_0%,_#02040a_100%)] px-4 py-10 sm:px-6 lg:px-8">
<SeoHead title="Groups - Skinbase" description={props.description} />
<div className="mx-auto max-w-6xl">
<section className="rounded-[32px] border border-white/10 bg-white/[0.03] p-6">
<section className="groups-directory-page__hero rounded-[32px] border border-white/10 bg-white/[0.03] p-6">
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">Groups</p>
<h1 className="mt-2 text-4xl font-semibold text-white">Collective publishing identities</h1>
<p className="mt-4 max-w-3xl text-sm leading-6 text-slate-300">Discover collaborative studios, follow shared creative brands, and browse the artworks, releases, and collections published under each group identity.</p>

View File

@@ -77,9 +77,9 @@ export default function LeaderboardPage() {
<>
<SeoHead seo={seo} title={seo?.title || 'Leaderboard — Skinbase'} description={seo?.description || 'Top creators, groups, artworks, stories, and Worlds on Skinbase.'} />
<div className="min-h-screen bg-[radial-gradient(circle_at_top,rgba(14,165,233,0.14),transparent_34%),linear-gradient(180deg,#020617_0%,#0f172a_48%,#020617_100%)] pb-16 text-slate-100">
<div className="leaderboard-page min-h-screen bg-[radial-gradient(circle_at_top,rgba(14,165,233,0.14),transparent_34%),linear-gradient(180deg,#020617_0%,#0f172a_48%,#020617_100%)] pb-16 text-slate-100">
<div className="mx-auto w-full max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
<header className="rounded-[2rem] border border-white/10 bg-slate-950/70 px-6 py-8 shadow-[0_35px_120px_rgba(2,6,23,0.75)] backdrop-blur">
<header className="leaderboard-page__hero rounded-[2rem] border border-white/10 bg-slate-950/70 px-6 py-8 shadow-[0_35px_120px_rgba(2,6,23,0.75)] backdrop-blur">
<p className="text-xs font-semibold uppercase tracking-[0.28em] text-sky-300">Skinbase Competition Board</p>
<h1 className="mt-4 max-w-3xl text-4xl font-black tracking-tight text-white sm:text-5xl">
Top creators, groups, standout artworks, stories, and Worlds with momentum.

View File

@@ -0,0 +1,11 @@
import React from 'react'
import AdminLayout from '../../Layouts/AdminLayout'
import FeaturedArtworksAdmin from '../Collection/FeaturedArtworksAdmin'
export default function ModerationFeaturedArtworks() {
return (
<AdminLayout>
<FeaturedArtworksAdmin />
</AdminLayout>
)
}

View File

@@ -0,0 +1,148 @@
import React from 'react'
import { Head, router, Link } from '@inertiajs/react'
import AdminLayout from '../../../Layouts/AdminLayout'
function formatDateTime(value) {
if (!value) return '—'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return '—'
return new Intl.DateTimeFormat('en', { dateStyle: 'medium', timeStyle: 'short' }).format(date)
}
function StatCard({ label, value }) {
return (
<div className="rounded-[24px] border border-white/10 bg-white/[0.04] p-5">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">{label}</div>
<div className="mt-4 text-3xl font-semibold tracking-[-0.04em] text-white">{Number(value || 0).toLocaleString()}</div>
</div>
)
}
export default function StaffApplicationsIndex({ title, items, stats, filters, topics, endpoints }) {
const [state, setState] = React.useState(filters || { q: '', topic: 'all' })
React.useEffect(() => {
setState(filters || { q: '', topic: 'all' })
}, [filters])
function update(key, value) {
setState((current) => ({ ...current, [key]: value }))
}
function applyFilters(event) {
event.preventDefault()
router.get(endpoints.index, state, { preserveState: true, replace: true, preserveScroll: true })
}
const rows = items?.data || []
return (
<AdminLayout title={title || 'Staff Applications'} subtitle="Review staff and contact submissions without leaving moderation.">
<Head title="Moderation · Staff Applications" />
<section className="rounded-[32px] border border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.12),transparent_34%),linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,0.92))] p-6 shadow-[0_24px_70px_rgba(2,6,23,0.32)]">
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.28em] text-sky-200/80">Moderation surface</p>
<h1 className="mt-3 text-3xl font-semibold tracking-[-0.04em] text-white">Staff Applications</h1>
<p className="mt-2 max-w-3xl text-sm leading-relaxed text-slate-300">Review staff and contact submissions in the same moderation workspace as the rest of Skinbase.</p>
</div>
<div className="flex flex-wrap gap-3 text-xs uppercase tracking-[0.16em] text-slate-300">
<span className="rounded-full border border-white/10 bg-white/[0.05] px-4 py-2">Page {items?.current_page || 1} / {items?.last_page || 1}</span>
<span className="rounded-full border border-white/10 bg-white/[0.05] px-4 py-2">{Number(items?.total || 0).toLocaleString()} submissions</span>
</div>
</div>
<div className="mt-6 grid gap-4 md:grid-cols-2 xl:grid-cols-5">
<StatCard label="Total" value={stats?.total} />
<StatCard label="Applications" value={stats?.applications} />
<StatCard label="Bug reports" value={stats?.bug} />
<StatCard label="Contact" value={stats?.contact} />
<StatCard label="Other" value={stats?.other} />
</div>
<form onSubmit={applyFilters} className="mt-6 grid gap-3 lg:grid-cols-[2fr_1fr_auto]">
<input
value={state.q || ''}
onChange={(event) => update('q', event.target.value)}
placeholder="Search name, email, role, or message"
className="rounded-2xl border border-white/10 bg-slate-950/70 px-4 py-3 text-sm text-white outline-none"
/>
<select
value={state.topic || 'all'}
onChange={(event) => update('topic', event.target.value)}
className="rounded-2xl border border-white/10 bg-slate-950/70 px-4 py-3 text-sm text-white outline-none"
>
<option value="all">All topics</option>
{(topics || []).map((topic) => (
<option key={topic} value={topic}>{String(topic).replaceAll('_', ' ')}</option>
))}
</select>
<button type="submit" className="rounded-2xl border border-white/10 bg-white/[0.06] px-5 py-3 text-xs font-semibold uppercase tracking-[0.14em] text-white transition hover:bg-white/[0.1]">Apply</button>
</form>
</section>
<div className="mt-8 overflow-hidden rounded-[28px] border border-white/10 bg-[#08111d] shadow-[0_18px_48px_rgba(2,6,23,0.2)]">
<div className="overflow-x-auto">
<table className="min-w-full text-sm">
<thead className="bg-white/[0.03] text-left text-xs uppercase tracking-[0.18em] text-slate-400">
<tr>
<th className="px-5 py-4 font-medium">Received</th>
<th className="px-5 py-4 font-medium">Topic</th>
<th className="px-5 py-4 font-medium">Name</th>
<th className="px-5 py-4 font-medium">Email</th>
<th className="px-5 py-4 font-medium">Role</th>
<th className="px-5 py-4 font-medium">Action</th>
</tr>
</thead>
<tbody className="divide-y divide-white/5 text-slate-200">
{rows.length === 0 ? (
<tr>
<td colSpan={6} className="px-5 py-14 text-center text-slate-400">No staff applications matched the current filters.</td>
</tr>
) : rows.map((item) => (
<tr key={item.id} className="hover:bg-white/[0.02]">
<td className="px-5 py-4 text-slate-400">{formatDateTime(item.created_at)}</td>
<td className="px-5 py-4">
<span className="inline-flex rounded-full border border-white/10 bg-white/[0.06] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-200">
{String(item.topic || 'contact').replaceAll('_', ' ')}
</span>
</td>
<td className="px-5 py-4 font-medium text-white">{item.name}</td>
<td className="px-5 py-4 text-slate-300">{item.email}</td>
<td className="px-5 py-4 text-slate-300">{item.role || '—'}</td>
<td className="px-5 py-4">
<Link href={item.show_url} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-white transition hover:bg-white/[0.09]">
View
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{(items?.prev_page_url || items?.next_page_url) ? (
<div className="mt-8 flex items-center justify-between gap-3 rounded-[24px] border border-white/10 bg-black/20 px-5 py-4">
<div className="text-xs uppercase tracking-[0.16em] text-slate-400">
Showing page {items?.current_page || 1} of {items?.last_page || 1}
</div>
<div className="flex gap-2">
{items?.prev_page_url ? (
<button type="button" onClick={() => router.get(items.prev_page_url, {}, { preserveScroll: true })} className="rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-white transition hover:bg-white/[0.09]">
Previous
</button>
) : null}
{items?.next_page_url ? (
<button type="button" onClick={() => router.get(items.next_page_url, {}, { preserveScroll: true })} className="rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-white transition hover:bg-white/[0.09]">
Next
</button>
) : null}
</div>
</div>
) : null}
</AdminLayout>
)
}

View File

@@ -0,0 +1,87 @@
import React from 'react'
import { Head, Link } from '@inertiajs/react'
import AdminLayout from '../../../Layouts/AdminLayout'
function formatDateTime(value) {
if (!value) return '—'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return '—'
return new Intl.DateTimeFormat('en', { dateStyle: 'medium', timeStyle: 'short' }).format(date)
}
function Field({ label, children }) {
return (
<div className="rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">{label}</div>
<div className="mt-2 text-sm leading-7 text-slate-100">{children}</div>
</div>
)
}
export default function StaffApplicationShow({ title, item, backUrl }) {
const payload = item?.payload || {}
return (
<AdminLayout title={title || 'Staff Application'} subtitle="Read the full submission in a moderation-friendly layout.">
<Head title="Moderation · Staff Application" />
<section className="rounded-[32px] border border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(34,211,238,0.12),transparent_34%),linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,0.92))] p-6 shadow-[0_24px_70px_rgba(2,6,23,0.32)]">
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.28em] text-sky-200/80">Moderation surface</p>
<h1 className="mt-3 text-3xl font-semibold tracking-[-0.04em] text-white">{item?.name || 'Staff application'}</h1>
<p className="mt-2 max-w-3xl text-sm leading-relaxed text-slate-300">Topic: {String(item?.topic || 'contact').replaceAll('_', ' ')} Received {formatDateTime(item?.created_at)}</p>
</div>
<div className="flex flex-wrap gap-3">
<Link href={backUrl} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.05] px-5 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-white transition hover:bg-white/[0.09]">
Back
</Link>
{item?.email ? (
<a href={`mailto:${item.email}`} className="inline-flex items-center gap-2 rounded-full border border-sky-300/20 bg-sky-400/12 px-5 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-sky-50 transition hover:bg-sky-400/18">
Email
</a>
) : null}
</div>
</div>
</section>
<div className="mt-8 grid gap-6 xl:grid-cols-[1.35fr_0.85fr]">
<div className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<Field label="Name">{item?.name || '—'}</Field>
<Field label="Email">{item?.email || '—'}</Field>
<Field label="Role">{item?.role || '—'}</Field>
<Field label="Portfolio">{item?.portfolio ? <a href={item.portfolio} className="text-sky-300 hover:text-sky-200" target="_blank" rel="noreferrer">{item.portfolio}</a> : '—'}</Field>
</div>
<div className="rounded-[28px] border border-white/10 bg-[#08111d] p-5 shadow-[0_18px_48px_rgba(2,6,23,0.2)]">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">Message</div>
<div className="mt-3 whitespace-pre-wrap rounded-2xl border border-white/10 bg-slate-950/60 px-4 py-4 text-sm leading-7 text-slate-100">
{item?.message || 'No message included.'}
</div>
</div>
</div>
<aside className="space-y-4">
<div className="rounded-[28px] border border-white/10 bg-[#08111d] p-5 shadow-[0_18px_48px_rgba(2,6,23,0.2)]">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">Metadata</div>
<div className="mt-4 space-y-3 text-sm text-slate-200">
<div><span className="font-semibold text-white">Received:</span> {formatDateTime(item?.created_at)}</div>
<div><span className="font-semibold text-white">IP:</span> {item?.ip || '—'}</div>
<div><span className="font-semibold text-white">User agent:</span> {item?.user_agent || '—'}</div>
<div><span className="font-semibold text-white">ID:</span> {item?.id || '—'}</div>
</div>
</div>
<div className="rounded-[28px] border border-white/10 bg-[#08111d] p-5 shadow-[0_18px_48px_rgba(2,6,23,0.2)]">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">Payload</div>
<pre className="mt-3 max-h-[420px] overflow-auto rounded-2xl border border-white/10 bg-slate-950/70 px-4 py-4 text-xs leading-6 text-slate-200">
{JSON.stringify(payload, null, 2)}
</pre>
</div>
</aside>
</div>
</AdminLayout>
)
}

View File

@@ -0,0 +1,168 @@
import React from 'react'
import { Head, router } from '@inertiajs/react'
import AdminLayout from '../../Layouts/AdminLayout'
function badgeTone(status) {
if (status === 'published') return 'border-emerald-300/20 bg-emerald-400/12 text-emerald-100'
if (status === 'scheduled') return 'border-sky-300/20 bg-sky-400/12 text-sky-100'
if (status === 'pending_review') return 'border-amber-300/20 bg-amber-400/12 text-amber-100'
if (status === 'archived' || status === 'rejected') return 'border-rose-300/20 bg-rose-400/12 text-rose-100'
return 'border-white/10 bg-white/[0.06] text-slate-200'
}
function StatCard({ label, value }) {
return (
<div className="rounded-[24px] border border-white/10 bg-white/[0.04] p-5">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">{label}</div>
<div className="mt-4 text-3xl font-semibold tracking-[-0.04em] text-white">{Number(value || 0).toLocaleString()}</div>
</div>
)
}
export default function Stories({ title, stories, filters, stats, endpoints }) {
const [state, setState] = React.useState(filters || { q: '', status: 'all' })
React.useEffect(() => {
setState(filters || { q: '', status: 'all' })
}, [filters])
function update(key, value) {
setState((current) => ({ ...current, [key]: value }))
}
function applyFilters(event) {
event.preventDefault()
router.get(endpoints.index, state, { preserveState: true, replace: true, preserveScroll: true })
}
const items = stories?.data || []
return (
<AdminLayout title={title || 'Stories'} subtitle="Review creator stories from the moderation surface, without jumping back to the old CP layout.">
<Head title="Moderation · Stories" />
<section className="rounded-[32px] border border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.14),transparent_34%),linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,0.92))] p-6 shadow-[0_24px_70px_rgba(2,6,23,0.32)]">
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.28em] text-sky-200/80">Moderation surface</p>
<h1 className="mt-3 text-3xl font-semibold tracking-[-0.04em] text-white">Stories</h1>
<p className="mt-2 max-w-3xl text-sm leading-relaxed text-slate-300">Browse creator stories, filter by status, and jump straight to the public view when it exists.</p>
</div>
<div className="flex flex-wrap gap-3 text-xs uppercase tracking-[0.16em] text-slate-300">
<span className="rounded-full border border-white/10 bg-white/[0.05] px-4 py-2">Page {stories?.current_page || 1} / {stories?.last_page || 1}</span>
<span className="rounded-full border border-white/10 bg-white/[0.05] px-4 py-2">{Number(stories?.total || 0).toLocaleString()} stories</span>
</div>
</div>
<div className="mt-6 grid gap-4 md:grid-cols-2 xl:grid-cols-6">
<StatCard label="Total" value={stats?.total} />
<StatCard label="Published" value={stats?.published} />
<StatCard label="Draft" value={stats?.draft} />
<StatCard label="Scheduled" value={stats?.scheduled} />
<StatCard label="Pending review" value={stats?.pending_review} />
<StatCard label="Archived" value={stats?.archived} />
</div>
<form onSubmit={applyFilters} className="mt-6 grid gap-3 lg:grid-cols-[2fr_1fr_auto]">
<input
value={state.q || ''}
onChange={(event) => update('q', event.target.value)}
placeholder="Search title, slug, or creator"
className="rounded-2xl border border-white/10 bg-slate-950/70 px-4 py-3 text-sm text-white outline-none"
/>
<select
value={state.status || 'all'}
onChange={(event) => update('status', event.target.value)}
className="rounded-2xl border border-white/10 bg-slate-950/70 px-4 py-3 text-sm text-white outline-none"
>
<option value="all">All statuses</option>
<option value="draft">Draft</option>
<option value="pending_review">Pending review</option>
<option value="scheduled">Scheduled</option>
<option value="published">Published</option>
<option value="archived">Archived</option>
<option value="rejected">Rejected</option>
</select>
<button type="submit" className="rounded-2xl border border-white/10 bg-white/[0.06] px-5 py-3 text-xs font-semibold uppercase tracking-[0.14em] text-white transition hover:bg-white/[0.1]">Apply</button>
</form>
</section>
<div className="mt-8 grid gap-4 xl:grid-cols-2">
{items.length === 0 ? (
<div className="rounded-[28px] border border-white/10 bg-white/[0.04] px-6 py-12 text-center text-slate-300 xl:col-span-2">
No stories matched the current filters.
</div>
) : items.map((story) => (
<article key={story.id} className="overflow-hidden rounded-[28px] border border-white/10 bg-[#08111d] shadow-[0_18px_48px_rgba(2,6,23,0.2)]">
<div className="grid gap-4 md:grid-cols-[180px_1fr]">
<div className="aspect-[3/4] bg-black/30">
{story.cover_url ? (
<img src={story.cover_url} alt={story.title} className="h-full w-full object-cover" />
) : (
<div className="flex h-full items-center justify-center text-white/20">
<i className="fa-solid fa-feather-pointed text-4xl" />
</div>
)}
</div>
<div className="p-5">
<div className="flex flex-wrap items-center gap-2">
<span className={`inline-flex rounded-full border px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] ${badgeTone(story.status)}`}>
{String(story.status || 'draft').replaceAll('_', ' ')}
</span>
{story.creator ? (
<span className="inline-flex rounded-full border border-white/10 bg-white/[0.06] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-200">
@{story.creator.username}
</span>
) : null}
</div>
<h2 className="mt-3 text-2xl font-semibold tracking-[-0.03em] text-white">{story.title}</h2>
<p className="mt-2 text-sm text-slate-300">/{story.slug}{story.creator ? `${story.creator.name}` : ''}</p>
{story.excerpt ? <p className="mt-3 text-sm leading-6 text-slate-300">{story.excerpt}</p> : null}
<div className="mt-4 flex flex-wrap gap-2 text-xs uppercase tracking-[0.16em] text-slate-400">
<span>{story.published_at ? new Date(story.published_at).toLocaleDateString() : 'Unpublished'}</span>
<span>{story.created_at ? new Date(story.created_at).toLocaleDateString() : '—'}</span>
</div>
<div className="mt-5 flex flex-wrap gap-2">
{story.open_url ? (
<a href={story.open_url} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-white transition hover:bg-white/[0.09]">
Open
</a>
) : (
<span className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.03] px-4 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-slate-400">
No public view
</span>
)}
</div>
</div>
</div>
</article>
))}
</div>
{stories?.prev_page_url || stories?.next_page_url ? (
<div className="mt-8 flex items-center justify-between gap-3 rounded-[24px] border border-white/10 bg-black/20 px-5 py-4">
<div className="text-xs uppercase tracking-[0.16em] text-slate-400">
Showing page {stories?.current_page || 1} of {stories?.last_page || 1}
</div>
<div className="flex gap-2">
{stories?.prev_page_url ? (
<button type="button" onClick={() => router.get(stories.prev_page_url, {}, { preserveScroll: true })} className="rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-white transition hover:bg-white/[0.09]">
Previous
</button>
) : null}
{stories?.next_page_url ? (
<button type="button" onClick={() => router.get(stories.next_page_url, {}, { preserveScroll: true })} className="rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-white transition hover:bg-white/[0.09]">
Next
</button>
) : null}
</div>
</div>
) : null}
</AdminLayout>
)
}

View File

@@ -0,0 +1,233 @@
import React from 'react'
import { Head, router } from '@inertiajs/react'
import AdminLayout from '../../Layouts/AdminLayout'
function formatDateTime(value) {
if (!value) return '—'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return '—'
return new Intl.DateTimeFormat('en', { dateStyle: 'medium', timeStyle: 'short' }).format(date)
}
function StatCard({ label, value, tone = 'sky' }) {
const tones = {
sky: 'border-sky-300/15 bg-sky-400/10 text-sky-100',
amber: 'border-amber-300/15 bg-amber-400/10 text-amber-100',
emerald: 'border-emerald-300/15 bg-emerald-400/10 text-emerald-100',
rose: 'border-rose-300/15 bg-rose-400/10 text-rose-100',
}
return (
<div className="rounded-[24px] border border-white/10 bg-white/[0.04] p-5 backdrop-blur-sm">
<div className={`inline-flex rounded-full border px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] ${tones[tone] || tones.sky}`}>{label}</div>
<div className="mt-4 text-3xl font-semibold tracking-[-0.04em] text-white">{Number(value || 0).toLocaleString()}</div>
</div>
)
}
function badgeTone(status) {
if (status === 'approved') return 'border-emerald-300/20 bg-emerald-400/12 text-emerald-100'
if (status === 'rejected') return 'border-rose-300/20 bg-rose-400/12 text-rose-100'
return 'border-amber-300/20 bg-amber-400/12 text-amber-100'
}
async function requestJson(url, body) {
const response = await fetch(url, {
method: 'POST',
credentials: 'same-origin',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '',
'X-Requested-With': 'XMLHttpRequest',
},
body: JSON.stringify(body || {}),
})
const payload = await response.json().catch(() => ({}))
if (!response.ok) {
throw new Error(payload?.message || 'Request failed.')
}
return payload
}
export default function UsernameQueue({ title, requests, stats, filters, options, endpoints }) {
const [state, setState] = React.useState(filters || { q: '', status: 'pending' })
const [notes, setNotes] = React.useState({})
const [busy, setBusy] = React.useState('')
const [notice, setNotice] = React.useState('')
const [error, setError] = React.useState('')
React.useEffect(() => {
setState(filters || { q: '', status: 'pending' })
}, [filters])
function update(key, value) {
setState((current) => ({ ...current, [key]: value }))
}
function applyFilters(event) {
event.preventDefault()
router.get(endpoints.index, state, { preserveState: true, replace: true, preserveScroll: true })
}
async function moderate(item, action) {
const actionKey = `${action}-${item.id}`
setBusy(actionKey)
setError('')
setNotice('')
try {
const payload = await requestJson(action === 'approve' ? item.approve_url : item.reject_url, {
note: String(notes[item.id] || ''),
})
setNotice(payload.message || `Request ${action}d.`)
router.reload({ only: ['requests', 'stats'], preserveScroll: true })
} catch (requestError) {
setError(requestError.message || 'Request failed.')
} finally {
setBusy('')
}
}
const items = requests?.data || []
return (
<AdminLayout title={title || 'Username Queue'} subtitle="Review username changes in the same moderation surface as the rest of Skinbase.">
<Head title="Moderation · Username Queue" />
<section className="rounded-[32px] border border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(244,114,182,0.12),transparent_34%),linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,0.92))] p-6 shadow-[0_24px_70px_rgba(2,6,23,0.32)]">
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.28em] text-rose-200/80">Moderation surface</p>
<h1 className="mt-3 text-3xl font-semibold tracking-[-0.04em] text-white">Username Queue</h1>
<p className="mt-2 max-w-3xl text-sm leading-relaxed text-slate-300">Review pending username requests before they are applied to the account or history trail.</p>
</div>
<div className="flex flex-wrap gap-3 text-xs uppercase tracking-[0.16em] text-slate-300">
<span className="rounded-full border border-white/10 bg-white/[0.05] px-4 py-2">Page {requests?.current_page || 1} / {requests?.last_page || 1}</span>
<span className="rounded-full border border-white/10 bg-white/[0.05] px-4 py-2">{Number(requests?.total || 0).toLocaleString()} requests</span>
</div>
</div>
<div className="mt-6 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<StatCard label="Total" value={stats?.total} />
<StatCard label="Pending" value={stats?.pending} tone="amber" />
<StatCard label="Approved" value={stats?.approved} tone="emerald" />
<StatCard label="Rejected" value={stats?.rejected} tone="rose" />
</div>
<form onSubmit={applyFilters} className="mt-6 grid gap-3 lg:grid-cols-[2fr_1fr_auto]">
<input
value={state.q || ''}
onChange={(event) => update('q', event.target.value)}
placeholder="Search requested or current username"
className="rounded-2xl border border-white/10 bg-slate-950/70 px-4 py-3 text-sm text-white outline-none"
/>
<select
value={state.status || 'pending'}
onChange={(event) => update('status', event.target.value)}
className="rounded-2xl border border-white/10 bg-slate-950/70 px-4 py-3 text-sm text-white outline-none"
>
{(options?.statuses || []).map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
<button type="submit" className="rounded-2xl border border-white/10 bg-white/[0.06] px-5 py-3 text-xs font-semibold uppercase tracking-[0.14em] text-white transition hover:bg-white/[0.1]">Apply</button>
</form>
</section>
{notice ? <div className="mt-6 rounded-2xl border border-emerald-300/20 bg-emerald-400/10 px-4 py-3 text-sm text-emerald-50">{notice}</div> : null}
{error ? <div className="mt-6 rounded-2xl border border-rose-300/20 bg-rose-400/10 px-4 py-3 text-sm text-rose-100">{error}</div> : null}
<div className="mt-8 space-y-4">
{items.length === 0 ? (
<div className="rounded-[28px] border border-white/10 bg-white/[0.04] px-6 py-12 text-center text-slate-300">No username requests matched the current filters.</div>
) : items.map((item) => (
<article key={item.id} className="rounded-[28px] border border-white/10 bg-[#08111d] p-5 shadow-[0_18px_48px_rgba(2,6,23,0.2)]">
<div className="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
<div>
<div className="flex flex-wrap gap-2">
<span className={`inline-flex rounded-full border px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] ${badgeTone(item.status)}`}>
{String(item.status || 'pending').replaceAll('_', ' ')}
</span>
{item.context ? (
<span className="inline-flex rounded-full border border-white/10 bg-white/[0.06] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-200">
{item.context.replaceAll('_', ' ')}
</span>
) : null}
{item.similar_to ? (
<span className="inline-flex rounded-full border border-amber-300/20 bg-amber-400/12 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-amber-100">
Similar to {item.similar_to}
</span>
) : null}
</div>
<h2 className="mt-3 text-2xl font-semibold tracking-[-0.03em] text-white">{item.requested_username}</h2>
<p className="mt-2 text-sm text-slate-300">
{item.current_username ? `Current: @${item.current_username}` : 'No current username'}
{item.current_name ? `${item.current_name}` : ''}
</p>
<p className="mt-2 text-xs uppercase tracking-[0.16em] text-slate-400">
Requested {formatDateTime(item.created_at)}
{item.reviewed_at ? ` • reviewed ${formatDateTime(item.reviewed_at)}` : ''}
</p>
</div>
<div className="w-full max-w-xl space-y-3">
<textarea
value={notes[item.id] || ''}
onChange={(event) => setNotes((current) => ({ ...current, [item.id]: event.target.value }))}
placeholder="Optional moderation note"
rows={3}
className="w-full rounded-2xl border border-white/10 bg-slate-950/70 px-4 py-3 text-sm text-white outline-none"
/>
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => moderate(item, 'approve')}
disabled={busy === `approve-${item.id}`}
className="inline-flex items-center gap-2 rounded-full border border-emerald-300/20 bg-emerald-400/12 px-4 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-emerald-100 transition hover:bg-emerald-400/18 disabled:opacity-60"
>
{busy === `approve-${item.id}` ? 'Saving…' : 'Approve'}
</button>
<button
type="button"
onClick={() => moderate(item, 'reject')}
disabled={busy === `reject-${item.id}`}
className="inline-flex items-center gap-2 rounded-full border border-rose-300/20 bg-rose-400/12 px-4 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-rose-100 transition hover:bg-rose-400/18 disabled:opacity-60"
>
{busy === `reject-${item.id}` ? 'Saving…' : 'Reject'}
</button>
</div>
</div>
</div>
</article>
))}
</div>
{requests?.prev_page_url || requests?.next_page_url ? (
<div className="mt-8 flex items-center justify-between gap-3 rounded-[24px] border border-white/10 bg-black/20 px-5 py-4">
<div className="text-xs uppercase tracking-[0.16em] text-slate-400">
Showing page {requests?.current_page || 1} of {requests?.last_page || 1}
</div>
<div className="flex gap-2">
{requests?.prev_page_url ? (
<button type="button" onClick={() => router.get(requests.prev_page_url, {}, { preserveScroll: true })} className="rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-white transition hover:bg-white/[0.09]">
Previous
</button>
) : null}
{requests?.next_page_url ? (
<button type="button" onClick={() => router.get(requests.next_page_url, {}, { preserveScroll: true })} className="rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-white transition hover:bg-white/[0.09]">
Next
</button>
) : null}
</div>
</div>
) : null}
</AdminLayout>
)
}

View File

@@ -135,7 +135,7 @@ export default function ProfileShow() {
return (
<>
<SeoHead seo={seo} />
<div className="relative min-h-screen overflow-hidden pb-16">
<div className="profile-page relative min-h-screen overflow-hidden pb-16">
<div
aria-hidden="true"
className="pointer-events-none absolute inset-x-0 top-0 -z-10 h-[34rem] opacity-90"

View File

@@ -32,7 +32,6 @@ const TABS = [
{ id: 'worlds', label: 'Worlds', icon: 'fa-solid fa-globe' },
{ id: 'taxonomy', label: 'Category', icon: 'fa-solid fa-palette' },
{ id: 'visibility', label: 'Visibility', icon: 'fa-solid fa-eye' },
{ id: 'ai', label: 'AI Assist', icon: 'fa-solid fa-wand-magic-sparkles' },
]
// ─── Helpers ─────────────────────────────────────────────────────────────────
@@ -1233,9 +1232,6 @@ export default function StudioArtworkEdit() {
{tab.id === 'evolution' && evolutionTarget && (
<span className="h-1.5 w-1.5 rounded-full bg-sky-400" />
)}
{tab.id === 'ai' && aiStatus !== 'not_analyzed' && (
<span className={`h-1.5 w-1.5 rounded-full ${aiStatus === 'ready' ? 'bg-emerald-400' : aiStatus === 'failed' ? 'bg-red-400' : 'bg-sky-400'}`} />
)}
</button>
))}
</div>

27
resources/js/academy.jsx Normal file
View File

@@ -0,0 +1,27 @@
import { mountInertiaRoot } from './bootstrap-lite'
import React from 'react'
import { createInertiaApp } from '@inertiajs/react'
const pages = import.meta.glob([
'./Pages/Academy/**/*.jsx',
'!./Pages/Academy/**/__tests__/**',
'!./Pages/Academy/**/*.test.jsx',
])
function resolvePage(name) {
const path = `./Pages/${name}.jsx`
const page = pages[path]
if (!page) {
throw new Error(`Unknown academy page: ${path}`)
}
return page().then((module) => module.default)
}
createInertiaApp({
resolve: resolvePage,
setup({ el, App, props }) {
mountInertiaRoot(el, App, props)
},
})

View File

@@ -4,8 +4,11 @@ import { createInertiaApp } from '@inertiajs/react'
const pages = import.meta.glob([
'./Pages/Admin/**/*.jsx',
'./Pages/Moderation/**/*.jsx',
'!./Pages/Admin/**/__tests__/**',
'!./Pages/Admin/**/*.test.jsx',
'!./Pages/Moderation/**/__tests__/**',
'!./Pages/Moderation/**/*.test.jsx',
])
function resolvePage(name) {

32
resources/js/bootstrap-lite.js vendored Normal file
View File

@@ -0,0 +1,32 @@
import axios from 'axios'
import React from 'react'
import { createRoot, hydrateRoot } from 'react-dom/client'
const csrfToken = typeof document !== 'undefined'
? document.querySelector('meta[name="csrf-token"]')?.getAttribute('content')
: null
axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest'
if (csrfToken) {
axios.defaults.headers.common['X-CSRF-TOKEN'] = csrfToken
}
if (typeof window !== 'undefined') {
window.axios = axios
}
export function mountInertiaRoot(el, App, props) {
if (!el) {
return null
}
const node = React.createElement(App, props)
const hasServerMarkup = el.childNodes.length > 0 && el.innerHTML.trim() !== ''
if (hasServerMarkup) {
return hydrateRoot(el, node)
}
return createRoot(el).render(node)
}

View File

@@ -1,4 +1,4 @@
import { mountInertiaRoot } from './bootstrap'
import { mountInertiaRoot } from './bootstrap-lite'
import React from 'react'
import { createInertiaApp } from '@inertiajs/react'
const pages = {

View File

@@ -25,7 +25,7 @@ export default function PlanCard({ product, selectedPlan, currentTier, isSubscri
const isActivePlan = selectedPlan?.key === activePlanKey
// Pro subscribers already have creator access — don't show a separate "switch" CTA for creator card
const isHigherTierCovered = activeTier === 'pro' && product.tier === 'creator'
const isPlanReady = Boolean(selectedPlan?.configured && selectedPlan?.price_id_valid)
const isPlanReady = Boolean(selectedPlan?.configured && selectedPlan?.price_id_valid && selectedPlan?.remote_price_exists !== false)
// User has a different active subscription (not this plan)
const isSubscribedElsewhere = isSubscribed && !isActivePlan

View File

@@ -386,16 +386,6 @@ export default function ArtworkActionBar({ artwork, stats, canonicalUrl, onStats
</a>
) : null}
{enhanceUrl ? (
<a
href={enhanceUrl}
className="inline-flex items-center gap-2 rounded-full border border-violet-300/25 bg-violet-400/12 px-5 py-2.5 text-sm font-medium text-violet-50 transition-all duration-200 hover:border-violet-200/40 hover:bg-violet-400/18 hover:text-white"
>
<EnhanceIcon />
Enhance image
</a>
) : null}
{/* Report pill */}
<button
type="button"

View File

@@ -279,7 +279,7 @@ function ActionLink({ href, label, children, onClick }) {
href={href || '#'}
aria-label={label}
onClick={onClick}
className="inline-flex h-10 w-10 items-center justify-center rounded-lg border border-white/10 bg-white/10 text-white/90 shadow-[0_14px_36px_rgba(2,6,23,0.38)] backdrop-blur-md transition duration-200 hover:bg-white/20 hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/80"
className="artwork-card__action-btn inline-flex h-10 w-10 items-center justify-center rounded-lg border border-white/10 bg-white/10 text-white/90 shadow-[0_14px_36px_rgba(2,6,23,0.38)] backdrop-blur-md transition duration-200 hover:bg-white/20 hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/80"
>
{children}
</a>
@@ -292,7 +292,7 @@ function ActionButton({ label, children, onClick }) {
type="button"
aria-label={label}
onClick={onClick}
className="inline-flex h-10 w-10 items-center justify-center rounded-lg border border-white/10 bg-white/10 text-white/90 shadow-[0_14px_36px_rgba(2,6,23,0.38)] backdrop-blur-md transition duration-200 hover:bg-white/20 hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/80"
className="artwork-card__action-btn inline-flex h-10 w-10 items-center justify-center rounded-lg border border-white/10 bg-white/10 text-white/90 shadow-[0_14px_36px_rgba(2,6,23,0.38)] backdrop-blur-md transition duration-200 hover:bg-white/20 hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/80"
>
{children}
</button>
@@ -832,7 +832,7 @@ export default function ArtworkCard({
style={articleStyle}
{...articleData}
>
<div className={cx('relative overflow-hidden rounded-[1.6rem] border border-white/8 bg-slate-950/80 shadow-[0_18px_60px_rgba(2,6,23,0.45)] transition duration-300 ease-out group-hover:-translate-y-1 group-hover:scale-[1.02] group-hover:border-white/14 group-hover:shadow-[0_24px_80px_rgba(8,47,73,0.5)] group-focus-within:-translate-y-1 group-focus-within:scale-[1.02] group-focus-within:border-sky-200/40', frameClassName)}>
<div className={cx('artwork-card__frame relative overflow-hidden rounded-[1.6rem] border border-white/8 bg-slate-950/80 shadow-[0_18px_60px_rgba(2,6,23,0.45)] transition duration-300 ease-out group-hover:-translate-y-1 group-hover:scale-[1.02] group-hover:border-white/14 group-hover:shadow-[0_24px_80px_rgba(8,47,73,0.5)] group-focus-within:-translate-y-1 group-focus-within:scale-[1.02] group-focus-within:border-sky-200/40', frameClassName)}>
<a
href={href}
aria-label={`Open artwork: ${cardLabel}`}
@@ -842,8 +842,8 @@ export default function ArtworkCard({
<span className="sr-only">{cardLabel}</span>
</a>
<div className={cx('relative overflow-hidden bg-slate-900', aspectClass, mediaClassName)} style={mediaStyle}>
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top,_rgba(148,163,184,0.28),_transparent_52%),linear-gradient(180deg,rgba(15,23,42,0.08),rgba(15,23,42,0.42))]" />
<div className={cx('artwork-card__media relative overflow-hidden bg-slate-900', aspectClass, mediaClassName)} style={mediaStyle}>
<div className="artwork-card__media-glow absolute inset-0 bg-[radial-gradient(circle_at_top,_rgba(148,163,184,0.28),_transparent_52%),linear-gradient(180deg,rgba(15,23,42,0.08),rgba(15,23,42,0.42))]" />
<img
src={image}
@@ -861,7 +861,7 @@ export default function ArtworkCard({
}}
/>
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/85 via-black/38 to-transparent opacity-90 transition duration-300 md:opacity-45 md:group-hover:opacity-100 md:group-focus-within:opacity-100" />
<div className="artwork-card__media-shade pointer-events-none absolute inset-0 bg-gradient-to-t from-black/85 via-black/38 to-transparent opacity-90 transition duration-300 md:opacity-45 md:group-hover:opacity-100 md:group-focus-within:opacity-100" />
{(resolvedMetricBadge?.label || relativePublishedAt) ? (
<div className="pointer-events-none absolute inset-x-0 top-0 z-20 flex items-start justify-between gap-3 p-3">
@@ -888,7 +888,7 @@ export default function ArtworkCard({
{showActions && (
<div className={cx(
'absolute right-3 z-20 flex max-w-[14rem] flex-wrap justify-end translate-y-2 gap-2 opacity-100 transition duration-200 md:opacity-0 md:group-hover:translate-y-0 md:group-hover:opacity-100 md:group-focus-within:translate-y-0 md:group-focus-within:opacity-100',
'artwork-card__actions absolute right-3 z-20 flex max-w-[14rem] flex-wrap justify-end translate-y-2 gap-2 opacity-100 transition duration-200 md:opacity-0 md:group-hover:translate-y-0 md:group-hover:opacity-100 md:group-focus-within:translate-y-0 md:group-focus-within:opacity-100',
relativePublishedAt ? 'top-12' : 'top-3'
)}>
<ActionButton label={liked ? 'Unlike artwork' : 'Like artwork'} onClick={handleLike}>
@@ -917,7 +917,7 @@ export default function ArtworkCard({
</div>
)}
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-20 bg-gradient-to-t from-black/80 via-black/40 to-transparent p-3 backdrop-blur-[2px] opacity-100 transition-opacity duration-200 md:opacity-0 md:group-hover:opacity-100 md:group-focus-within:opacity-100">
<div className="artwork-card__meta pointer-events-none absolute inset-x-0 bottom-0 z-20 bg-gradient-to-t from-black/80 via-black/40 to-transparent p-3 backdrop-blur-[2px] opacity-100 transition-opacity duration-200 md:opacity-0 md:group-hover:opacity-100 md:group-focus-within:opacity-100">
{shouldBlurMature ? <div className="mb-2 inline-flex rounded-full border border-amber-300/20 bg-black/55 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-amber-100">Blurred by your content settings</div> : null}
<h3 className={cx('truncate font-semibold text-white', titleClass)}>
{title}

View File

@@ -162,7 +162,7 @@ export default function ArtworkHero({ artwork, presentMd, presentLg, presentXl,
event.currentTarget.onerror = null
if (mainImageMode === 'primary') {
setMainImageMode('fallback')
setMainImageMode(hasRealArtworkImage ? 'hidden' : 'fallback')
setIsLoaded(false)
return
}

View File

@@ -11,7 +11,7 @@ export default function GroupDiscoveryCard({ group, className = '', compact = fa
<a
href={group.urls?.public || '/groups'}
className={cx(
'group block overflow-hidden rounded-[30px] border border-white/10 bg-[linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,0.92))] p-5 shadow-[0_24px_70px_rgba(2,6,23,0.34)] transition duration-200 hover:-translate-y-1 hover:border-white/20',
'group-discovery-card group block overflow-hidden rounded-[30px] border border-white/10 bg-[linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,0.92))] p-5 shadow-[0_24px_70px_rgba(2,6,23,0.34)] transition duration-200 hover:-translate-y-1 hover:border-white/20',
className,
)}
>

View File

@@ -7,7 +7,7 @@ export default function GroupLeaderboardCard({ item }) {
const entity = item.entity
return (
<article className="rounded-[26px] border border-white/10 bg-white/[0.03] p-4 shadow-[0_18px_50px_rgba(2,6,23,0.3)]">
<article className="group-leaderboard-card rounded-[26px] border border-white/10 bg-white/[0.03] p-4 shadow-[0_18px_50px_rgba(2,6,23,0.3)]">
<div className="flex items-start gap-4">
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-2xl border border-white/10 bg-slate-950/70 text-lg font-black text-white">
#{item.rank}

View File

@@ -5,7 +5,7 @@ export default function GroupPromoCard({ group, eyebrow = 'Groups spotlight', ti
if (!group) return null
return (
<section className="overflow-hidden rounded-[34px] border border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.18),transparent_28%),radial-gradient(circle_at_80%_20%,rgba(16,185,129,0.12),transparent_26%),linear-gradient(180deg,rgba(7,16,29,0.98),rgba(2,6,23,0.94))] shadow-[0_30px_90px_rgba(2,6,23,0.45)]">
<section className="group-promo-card overflow-hidden rounded-[34px] border border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.18),transparent_28%),radial-gradient(circle_at_80%_20%,rgba(16,185,129,0.12),transparent_26%),linear-gradient(180deg,rgba(7,16,29,0.98),rgba(2,6,23,0.94))] shadow-[0_30px_90px_rgba(2,6,23,0.45)]">
<div className="grid gap-6 p-6 lg:grid-cols-[minmax(0,1.3fr)_320px] lg:p-8">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.28em] text-sky-200/80">{eyebrow}</p>
@@ -30,7 +30,7 @@ export default function GroupPromoCard({ group, eyebrow = 'Groups spotlight', ti
</div>
</div>
<div className="rounded-[28px] border border-white/10 bg-black/25 p-5 backdrop-blur-sm">
<div className="group-promo-card__summary rounded-[28px] border border-white/10 bg-black/25 p-5 backdrop-blur-sm">
<div className="flex items-center gap-4">
<div className="flex h-16 w-16 shrink-0 items-center justify-center overflow-hidden rounded-2xl border border-white/10 bg-white/[0.04]">
{group.avatar_url ? <img src={group.avatar_url} alt="" className="h-full w-full object-cover" loading="lazy" /> : <i className="fa-solid fa-people-group text-slate-300" />}

View File

@@ -24,9 +24,9 @@ export default function LeaderboardItem({ item, type, highlight = false }) {
const groupSignals = Array.isArray(entity.trust_signals) ? entity.trust_signals.slice(0, 2) : []
return (
<article className={cx('rounded-3xl border p-4 shadow-lg transition', tone)}>
<article className={cx('leaderboard-item rounded-3xl border p-4 shadow-lg transition', tone)}>
<div className="flex items-start gap-4">
<div className={cx('flex shrink-0 items-center justify-center rounded-2xl border font-black', highlight ? 'h-14 w-14 text-xl' : 'h-11 w-11 text-base', 'border-white/10 bg-slate-950/70 text-white')}>
<div className={cx('leaderboard-item__rank flex shrink-0 items-center justify-center rounded-2xl border font-black', highlight ? 'h-14 w-14 text-xl' : 'h-11 w-11 text-base', 'border-white/10 bg-slate-950/70 text-white')}>
#{rank}
</div>

View File

@@ -6,7 +6,7 @@ function cx(...parts) {
export default function LeaderboardTabs({ items, active, onChange, sticky = false, label }) {
return (
<div className={cx(sticky ? 'sticky top-16 z-20' : '', 'rounded-2xl border border-white/10 bg-slate-950/85 p-2 backdrop-blur') }>
<div className={cx(sticky ? 'sticky top-16 z-20' : '', 'leaderboard-tabs rounded-2xl border border-white/10 bg-slate-950/85 p-2 backdrop-blur') }>
<div className="flex flex-wrap items-center gap-2" role="tablist" aria-label={label || 'Leaderboard tabs'}>
{items.map((item) => {
const isActive = item.value === active
@@ -19,7 +19,7 @@ export default function LeaderboardTabs({ items, active, onChange, sticky = fals
aria-selected={isActive}
onClick={() => onChange(item.value)}
className={cx(
'rounded-full px-4 py-2 text-sm font-semibold transition',
'leaderboard-tabs__tab rounded-full px-4 py-2 text-sm font-semibold transition',
isActive
? 'bg-sky-400 text-slate-950 shadow-[0_12px_30px_rgba(56,189,248,0.28)]'
: 'bg-white/5 text-slate-300 hover:bg-white/10 hover:text-white',

View File

@@ -35,7 +35,7 @@ export default function ProfileHero({ user, profile, isOwner, viewerIsFollowing,
return (
<>
<div className="relative mx-auto max-w-7xl px-4 pt-4 md:pt-6">
<div className="profile-hero relative mx-auto max-w-7xl px-4 pt-4 md:pt-6">
<div
aria-hidden="true"
className="pointer-events-none absolute inset-x-10 top-8 -z-10 h-44 rounded-full blur-3xl"

View File

@@ -31,7 +31,7 @@ export default function ProfileTabs({ activeTab, onTabChange }) {
}, [activeTab])
return (
<div className="sticky top-0 z-30 border-b border-white/10 bg-[#08111f]/80 backdrop-blur-2xl">
<div className="profile-tabs-shell sticky top-0 z-30 border-b border-white/10 bg-[#08111f]/80 backdrop-blur-2xl">
<nav
ref={navRef}
className="profile-tabs-sticky overflow-x-auto scrollbar-hide"

View File

@@ -1148,12 +1148,12 @@ export default function DashboardPage({ username, isCreator, level, rank, receiv
: null
return (
<div className="min-h-screen bg-[#050c14] text-slate-100">
<div className="dashboard-home-page min-h-screen bg-[#050c14] text-slate-100">
<ShortcutSaveToast notice={shortcutNotice} />
<div className="relative isolate overflow-hidden">
<div className="pointer-events-none absolute inset-x-0 top-0 -z-10 h-[520px] bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.24),_transparent_36%),radial-gradient(circle_at_top_right,_rgba(245,158,11,0.16),_transparent_30%),linear-gradient(180deg,_rgba(8,17,28,0.98),_rgba(5,12,20,1))]" />
<div className="relative z-10 mx-auto w-full max-w-7xl px-4 py-8 sm:px-6 lg:px-8 lg:py-10">
<header className="relative overflow-hidden rounded-[32px] border border-white/10 bg-[#08111c]/92 p-6 shadow-2xl shadow-black/30 sm:p-8">
<header className="dashboard-home-page__hero relative overflow-hidden rounded-[32px] border border-white/10 bg-[#08111c]/92 p-6 shadow-2xl shadow-black/30 sm:p-8">
<div className="absolute inset-0 bg-[linear-gradient(135deg,_rgba(56,189,248,0.12),_transparent_40%,_rgba(245,158,11,0.10)_100%)]" />
<div className="relative grid gap-8 xl:grid-cols-[1.35fr_0.95fr] xl:items-start">
<div>

View File

@@ -2,7 +2,14 @@ import { mountInertiaRoot } from './bootstrap'
import React from 'react'
import { createInertiaApp } from '@inertiajs/react'
const pages = import.meta.glob('./Pages/Moderation/**/*.jsx')
const pages = import.meta.glob([
'./Pages/Moderation/**/*.jsx',
'./Pages/Admin/**/*.jsx',
'!./Pages/Moderation/**/__tests__/**',
'!./Pages/Moderation/**/*.test.jsx',
'!./Pages/Admin/**/__tests__/**',
'!./Pages/Admin/**/*.test.jsx',
])
createInertiaApp({
resolve: (name) => {

View File

@@ -14,6 +14,69 @@ if (!window.Alpine) {
import './lib/nav-context.js';
import { sendTagInteractionEvent } from './lib/tagAnalytics';
function initSkinbaseThemeToggle() {
var config = window.SKINBASE_THEME || {};
var storageKey = config.storageKey || 'skinbase.theme';
var allowedThemes = Array.isArray(config.themes) ? config.themes : ['default', 'light'];
var root = document.documentElement;
var toggles = Array.prototype.slice.call(document.querySelectorAll('[data-theme-toggle]'));
function normalizeTheme(theme) {
return allowedThemes.indexOf(theme) >= 0 ? theme : 'default';
}
function readTheme() {
try {
return normalizeTheme(window.localStorage.getItem(storageKey));
} catch (_error) {
return normalizeTheme(root.dataset.skinbaseTheme);
}
}
function writeTheme(theme) {
try {
window.localStorage.setItem(storageKey, theme);
} catch (_error) {
// Keep the in-page theme even when storage is unavailable.
}
}
function applyTheme(theme) {
var normalized = normalizeTheme(theme);
var isLight = normalized === 'light';
root.dataset.skinbaseTheme = normalized;
toggles.forEach(function (toggle) {
var label = toggle.querySelector('[data-theme-toggle-label]');
toggle.setAttribute('aria-pressed', isLight ? 'true' : 'false');
toggle.setAttribute('aria-label', isLight ? 'Switch to default theme' : 'Switch to light theme');
toggle.setAttribute('title', isLight ? 'Switch to default theme' : 'Switch to light theme');
if (label) {
label.textContent = isLight ? 'Light' : 'Dark';
}
});
}
applyTheme(readTheme());
toggles.forEach(function (toggle) {
toggle.addEventListener('click', function () {
var nextTheme = root.dataset.skinbaseTheme === 'light' ? 'default' : 'light';
applyTheme(nextTheme);
writeTheme(nextTheme);
});
});
window.addEventListener('storage', function (event) {
if (event.key === storageKey) {
applyTheme(event.newValue);
}
});
}
initSkinbaseThemeToggle();
function safeParseJson(value, fallback) {
try {
return JSON.parse(value || 'null') ?? fallback;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,20 @@
@extends('layouts.nova')
@push('head')
@if(request()->hasSession() && ! request()->attributes->get('skinbase.session_skipped'))
<meta name="csrf-token" content="{{ csrf_token() }}" />
@endif
@vite(['resources/js/academy.jsx'])
<style>
body.page-academy main { padding-top: 4rem; }
</style>
<script>
document.addEventListener('DOMContentLoaded', function () {
document.body.classList.add('page-academy')
})
</script>
@endpush
@section('content')
@inertia
@endsection

View File

@@ -21,7 +21,7 @@
this.open = false
},
}"
class="relative"
class="dashboard-filter-select relative"
@click.outside="open = false"
@keydown.escape.window="open = false"
>

View File

@@ -1,7 +1,7 @@
@props(['story'])
<a href="{{ route('stories.show', $story->slug) }}"
class="group block overflow-hidden rounded-xl border border-gray-700 bg-gray-800/70 shadow-lg transition-transform duration-200 hover:scale-[1.02] hover:border-sky-500/40">
class="story-card group block overflow-hidden rounded-xl border border-gray-700 bg-gray-800/70 shadow-lg transition-transform duration-200 hover:scale-[1.02] hover:border-sky-500/40">
@if($story->cover_url)
<div class="aspect-video overflow-hidden bg-gray-900">
<img src="{{ $story->cover_url }}" alt="{{ $story->title }}" class="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105" loading="lazy" />

View File

@@ -33,7 +33,7 @@
</x-slot>
</x-nova-page-header>
<section class="px-6 pb-16 pt-8 md:px-10">
<section class="following-dashboard-page px-6 pb-16 pt-8 md:px-10">
@php
$firstFollow = $following->getCollection()->first();
$latestFollowedAt = $firstFollow && !empty($firstFollow->followed_at)
@@ -43,29 +43,29 @@
@endphp
<div class="mb-6 grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
<div class="rounded-2xl border border-white/[0.08] bg-white/[0.03] p-5 shadow-[0_16px_60px_rgba(0,0,0,0.16)]">
<div class="following-dashboard-card rounded-2xl border border-white/[0.08] bg-white/[0.03] p-5 shadow-[0_16px_60px_rgba(0,0,0,0.16)]">
<p class="text-xs uppercase tracking-widest text-white/35">Following</p>
<p class="mt-2 text-3xl font-semibold text-white">{{ number_format($summary['total_following']) }}</p>
<p class="mt-2 text-xs text-white/40">People you currently follow</p>
</div>
<div class="rounded-2xl border border-white/[0.08] bg-white/[0.03] p-5 shadow-[0_16px_60px_rgba(0,0,0,0.16)]">
<div class="following-dashboard-card rounded-2xl border border-white/[0.08] bg-white/[0.03] p-5 shadow-[0_16px_60px_rgba(0,0,0,0.16)]">
<p class="text-xs uppercase tracking-widest text-white/35">Mutual follows</p>
<p class="mt-2 text-3xl font-semibold text-white">{{ number_format($summary['mutual']) }}</p>
<p class="mt-2 text-xs text-white/40">People who follow you back</p>
</div>
<div class="rounded-2xl border border-white/[0.08] bg-white/[0.03] p-5 shadow-[0_16px_60px_rgba(0,0,0,0.16)]">
<div class="following-dashboard-card rounded-2xl border border-white/[0.08] bg-white/[0.03] p-5 shadow-[0_16px_60px_rgba(0,0,0,0.16)]">
<p class="text-xs uppercase tracking-widest text-white/35">One-way follows</p>
<p class="mt-2 text-3xl font-semibold text-white">{{ number_format($summary['one_way']) }}</p>
<p class="mt-2 text-xs text-white/40">People you follow who do not follow back</p>
</div>
<div class="rounded-2xl border border-sky-400/20 bg-[linear-gradient(135deg,rgba(56,189,248,0.12),rgba(255,255,255,0.03))] p-5 shadow-[0_16px_60px_rgba(14,165,233,0.08)]">
<div class="following-dashboard-card rounded-2xl border border-sky-400/20 bg-[linear-gradient(135deg,rgba(56,189,248,0.12),rgba(255,255,255,0.03))] p-5 shadow-[0_16px_60px_rgba(14,165,233,0.08)]">
<p class="text-xs uppercase tracking-widest text-sky-100/60">Latest followed</p>
<p class="mt-2 truncate text-xl font-semibold text-white">{{ $latestFollowedName ?? '—' }}</p>
<p class="mt-2 text-xs text-sky-50/60">{{ $latestFollowedAt ?? 'No recent follow activity' }}</p>
</div>
</div>
<div class="mb-6 rounded-2xl border border-white/[0.06] bg-white/[0.03] p-4 shadow-[0_16px_60px_rgba(0,0,0,0.12)]">
<div class="following-dashboard-panel mb-6 rounded-2xl border border-white/[0.06] bg-white/[0.03] p-4 shadow-[0_16px_60px_rgba(0,0,0,0.12)]">
@php
$sortOptions = [
['value' => 'recent', 'label' => 'Most recent'],
@@ -196,7 +196,7 @@
}
}"
:class="following ? 'opacity-100' : 'opacity-50'"
class="group overflow-hidden rounded-2xl border border-white/[0.06] bg-[linear-gradient(180deg,rgba(255,255,255,0.035),rgba(255,255,255,0.02))] shadow-[0_18px_70px_rgba(0,0,0,0.14)] transition-all hover:-translate-y-0.5 hover:border-white/[0.10] hover:shadow-[0_24px_90px_rgba(0,0,0,0.20)]">
class="following-dashboard-panel group overflow-hidden rounded-2xl border border-white/[0.06] bg-[linear-gradient(180deg,rgba(255,255,255,0.035),rgba(255,255,255,0.02))] shadow-[0_18px_70px_rgba(0,0,0,0.14)] transition-all hover:-translate-y-0.5 hover:border-white/[0.10] hover:shadow-[0_24px_90px_rgba(0,0,0,0.20)]">
<div class="flex items-start justify-between gap-4 border-b border-white/[0.05] px-5 py-5">
<a href="{{ $f->profile_url }}" class="min-w-0 flex-1">
<div class="flex items-center gap-4 min-w-0">

View File

@@ -0,0 +1,21 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>Academy access activation request</title>
</head>
<body>
<h2>Academy support request</h2>
<p><strong>User:</strong> {{ $user->id }} {{ $user->email }}</p>
<p><strong>Issue type:</strong> {{ $issueType ?? 'n/a' }}</p>
<p><strong>Reply-to email:</strong> {{ $contactEmail ?? $user->email }}</p>
<p><strong>Checkout session id:</strong> {{ $sessionId ?? 'n/a' }}</p>
<h3>Message</h3>
<p>{!! nl2br(e($message ?? 'No message provided.')) !!}</p>
<hr>
<p>Sent from Skinbase application.</p>
</body>
</html>

View File

@@ -41,6 +41,11 @@
<div><strong>Email:</strong> <a href="mailto:{{ $application->email }}">{{ $application->email }}</a></div>
@if($application->role)<div><strong>Role:</strong> {{ $application->role }}</div>@endif
@if($application->portfolio)<div><strong>Portfolio:</strong> <a href="{{ $application->portfolio }}">{{ $application->portfolio }}</a></div>@endif
@if($application->payload['data']['source'] ?? false)<div><strong>Source:</strong> {{ $application->payload['data']['source'] }}</div>@endif
@if($application->payload['data']['issue_type'] ?? false)<div><strong>Issue type:</strong> {{ $application->payload['data']['issue_type'] }}</div>@endif
@if($application->payload['data']['session_id'] ?? false)<div><strong>Session ID:</strong> {{ $application->payload['data']['session_id'] }}</div>@endif
@if($application->payload['data']['account_email'] ?? false)<div><strong>Account email:</strong> <a href="mailto:{{ $application->payload['data']['account_email'] }}">{{ $application->payload['data']['account_email'] }}</a></div>@endif
@if($application->payload['data']['user_id'] ?? false)<div><strong>User ID:</strong> {{ $application->payload['data']['user_id'] }}</div>@endif
</div>
</div>

View File

@@ -49,7 +49,7 @@
@endphp
@section('content')
<div class="container-fluid legacy-page">
<div class="gallery-page container-fluid legacy-page">
@php Banner::ShowResponsiveAd(); @endphp
@php

View File

@@ -2,9 +2,9 @@
$gridVersion = request()->query('grid') === 'v2' ? 'v2' : 'v1';
$skinbaseSessionSkipped = request()->attributes->get('skinbase.session_skipped') === true;
$skinbaseCanUseSession = request()->hasSession() && ! $skinbaseSessionSkipped;
$deferToolbarSearch = request()->routeIs('index');
$deferToolbarSearch = request()->routeIs('index', 'academy.*');
$deferFontAwesome = request()->routeIs('index');
$deferWebManifest = request()->routeIs('index');
$deferWebManifest = request()->routeIs('index', 'academy.*');
$isInertiaPage = isset($page) && is_array($page);
$isAuthSeoRoute = request()->routeIs([
'login',
@@ -35,13 +35,27 @@
}
@endphp
<!DOCTYPE html>
<html lang="{{ app()->getLocale() }}" data-grid-version="{{ $gridVersion }}">
<html lang="{{ app()->getLocale() }}" data-grid-version="{{ $gridVersion }}" data-skinbase-theme="default">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
@if($skinbaseCanUseSession)
<meta name="csrf-token" content="{{ csrf_token() }}">
@endif
<script>
(() => {
const storageKey = 'skinbase.theme';
const allowedThemes = new Set(['default', 'light']);
try {
const savedTheme = localStorage.getItem(storageKey);
const theme = allowedThemes.has(savedTheme) ? savedTheme : 'default';
document.documentElement.dataset.skinbaseTheme = theme;
} catch (_error) {
document.documentElement.dataset.skinbaseTheme = 'default';
}
})();
</script>
<meta name="msvalidate.01" content="E81C84AA9CE4A9CDF1B0039010228C41">
<meta name="verify-v1" content="HNZJnSy5ZbqcrmXUXUwUMtPZzXsKQ+esjxPgXIXDQdk=">
<meta name="google-site-verification" content="D5L-4F-ZP1HFLzLsau6ge7LNGEGb9Sfio4RINkleQto">
@@ -76,6 +90,11 @@
color-scheme: dark;
}
html[data-skinbase-theme="light"] {
background-color: rgb(244, 247, 251);
color-scheme: light;
}
body {
margin: 0;
min-height: 100vh;
@@ -84,6 +103,11 @@
background-color: rgb(14, 18, 27);
color: #fff;
}
html[data-skinbase-theme="light"] body {
background-color: rgb(244, 247, 251);
color: #172033;
}
</style>
@foreach($novaCssEntries as $novaCssEntry)
@php
@@ -100,6 +124,12 @@
}),
});
</script>
<script>
window.SKINBASE_THEME = {
storageKey: 'skinbase.theme',
themes: @json(array_values(array_filter(['default', config('theme.enabled') ? 'light' : null]))),
};
</script>
@stack('head')
@if($deferToolbarSearch)

View File

@@ -33,7 +33,7 @@
@endphp
@section('content')
<div class="container-fluid legacy-page">
<div class="explore-page container-fluid legacy-page">
@php Banner::ShowResponsiveAd(); @endphp
<div class="pt-0">
@@ -100,7 +100,7 @@
$activeTab = $current_sort ?? 'trending';
@endphp
<div class="sticky top-0 z-30 border-b border-white/10 bg-nova-900/90 backdrop-blur-md" id="gallery-ranking-tabs">
<div class="explore-page__tabs sticky top-0 z-30 border-b border-white/10 bg-nova-900/90 backdrop-blur-md" id="gallery-ranking-tabs">
<div class="px-6 md:px-10">
<div class="flex items-center justify-between gap-4">
<nav class="flex items-center gap-0 -mb-px nb-scrollbar-none overflow-x-auto" role="tablist">

View File

@@ -261,6 +261,25 @@
</div>
</div>
@if(config('theme.show_toolbar_switch'))
<button
type="button"
class="theme-toggle shrink-0"
data-theme-toggle
aria-label="Switch to default theme"
aria-pressed="true"
title="Switch to default theme"
>
<span class="theme-toggle__track" aria-hidden="true">
<span class="theme-toggle__thumb">
<i class="theme-toggle__icon theme-toggle__icon--moon fa-solid fa-moon"></i>
<i class="theme-toggle__icon theme-toggle__icon--sun fa-solid fa-sun"></i>
</span>
</span>
<span class="hidden xl:inline text-xs font-semibold uppercase tracking-[0.14em]" data-theme-toggle-label>Light</span>
</button>
@endif
@if($skinbaseToolbarCanAuth)
<!-- Notification icons -->
<div class="hidden md:flex items-center gap-0.5 lg:gap-1 text-soft shrink-0">

View File

@@ -1,4 +1,4 @@
<article class="group overflow-hidden rounded-[28px] border border-white/[0.06] bg-[linear-gradient(180deg,rgba(11,16,26,0.94),rgba(7,11,19,0.92))] shadow-[0_18px_45px_rgba(0,0,0,0.22)] transition hover:-translate-y-0.5 hover:border-white/[0.12]">
<article class="skinbase-dark-surface group overflow-hidden rounded-[28px] border border-white/[0.06] bg-[linear-gradient(180deg,rgba(11,16,26,0.94),rgba(7,11,19,0.92))] shadow-[0_18px_45px_rgba(0,0,0,0.22)] transition hover:-translate-y-0.5 hover:border-white/[0.12]">
<a href="{{ route('news.show', $article->slug) }}" class="block">
<div class="relative aspect-[16/9] overflow-hidden bg-black/20">
@if($article->cover_url)

View File

@@ -7,14 +7,14 @@
@if($isPreview)
@if($article->commentsAreEnabled())
<section id="comments" class="mt-8 rounded-[32px] border border-white/[0.06] bg-[linear-gradient(180deg,rgba(11,16,26,0.94),rgba(7,11,19,0.92))] p-6 shadow-[0_18px_45px_rgba(0,0,0,0.2)] sm:p-8">
<section id="comments" class="news-reading-panel mt-8 rounded-[32px] border border-white/[0.06] bg-[linear-gradient(180deg,rgba(11,16,26,0.94),rgba(7,11,19,0.92))] p-6 shadow-[0_18px_45px_rgba(0,0,0,0.2)] sm:p-8">
<div class="rounded-2xl border border-indigo-300/20 bg-indigo-400/10 px-5 py-4 text-sm text-indigo-100">
Comments are enabled for this article, but posting is disabled in preview mode.
</div>
</section>
@endif
@elseif($article->commentsAreEnabled())
<section id="comments" class="mt-8 rounded-[32px] border border-white/[0.06] bg-[linear-gradient(180deg,rgba(11,16,26,0.94),rgba(7,11,19,0.92))] p-6 shadow-[0_18px_45px_rgba(0,0,0,0.2)] sm:p-8">
<section id="comments" class="news-reading-panel mt-8 rounded-[32px] border border-white/[0.06] bg-[linear-gradient(180deg,rgba(11,16,26,0.94),rgba(7,11,19,0.92))] p-6 shadow-[0_18px_45px_rgba(0,0,0,0.2)] sm:p-8">
<div class="flex flex-col gap-3 border-b border-white/[0.06] pb-6 sm:flex-row sm:items-end sm:justify-between">
<div>
<p class="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/70">Conversation</p>

View File

@@ -1,5 +1,5 @@
@if(!empty($categories) && $categories->isNotEmpty())
<section class="rounded-[24px] border border-white/[0.06] bg-white/[0.025] p-5">
<section class="news-sidebar-panel rounded-[24px] border border-white/[0.06] bg-white/[0.025] p-5">
<div class="mb-4 flex items-center justify-between gap-3">
<h2 class="text-sm font-semibold uppercase tracking-[0.18em] text-white/45">Categories</h2>
<span class="text-xs text-white/30">{{ $categories->count() }}</span>
@@ -16,7 +16,7 @@
@endif
@if(!empty($trending) && $trending->isNotEmpty())
<section class="rounded-[24px] border border-white/[0.06] bg-white/[0.025] p-5">
<section class="news-sidebar-panel rounded-[24px] border border-white/[0.06] bg-white/[0.025] p-5">
<div class="mb-4 flex items-center gap-2 text-sm font-semibold uppercase tracking-[0.18em] text-white/45">
<i class="fa-solid fa-fire text-[11px] text-rose-300"></i>
Trending
@@ -36,7 +36,7 @@
@endif
@if(!empty($tags) && $tags->isNotEmpty())
<section class="rounded-[24px] border border-white/[0.06] bg-white/[0.025] p-5">
<section class="news-sidebar-panel rounded-[24px] border border-white/[0.06] bg-white/[0.025] p-5">
<div class="mb-4 flex items-center justify-between gap-3">
<div class="flex items-center gap-2 text-sm font-semibold uppercase tracking-[0.18em] text-white/45">
<i class="fa-solid fa-tags text-[11px] text-sky-300"></i>
@@ -54,7 +54,7 @@
</section>
@endif
<section class="rounded-[24px] border border-amber-400/20 bg-amber-500/10 p-5 text-center">
<section class="news-sidebar-panel news-rss-panel rounded-[24px] border border-amber-400/20 bg-amber-500/10 p-5 text-center">
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-amber-100/70">Stay updated</p>
<a href="{{ route('news.rss') }}" class="mt-3 inline-flex items-center gap-2 rounded-full border border-amber-300/25 bg-amber-500/10 px-4 py-2 text-sm font-medium text-amber-100 transition hover:bg-amber-500/20" target="_blank" rel="noopener noreferrer">
<i class="fa-solid fa-rss text-xs"></i>

View File

@@ -194,7 +194,7 @@
</div>
@endif
<div class="mt-6 rounded-[32px] border border-white/[0.06] bg-[linear-gradient(180deg,rgba(11,16,26,0.94),rgba(7,11,19,0.92))] p-6 shadow-[0_18px_45px_rgba(0,0,0,0.2)] sm:p-8">
<div class="news-reading-panel mt-6 rounded-[32px] border border-white/[0.06] bg-[linear-gradient(180deg,rgba(11,16,26,0.94),rgba(7,11,19,0.92))] p-6 shadow-[0_18px_45px_rgba(0,0,0,0.2)] sm:p-8">
<div class="flex flex-wrap items-center gap-x-4 gap-y-2 text-sm text-white/45">
<span>
@if($article->author?->username)

View File

@@ -97,6 +97,35 @@
width: 100%;
height: 100%;
}
@media (prefers-color-scheme: light) {
body {
background: #ffffff;
}
.story-text {
color: #0f172a;
text-shadow: none;
}
.story-kicker {
color: rgba(15,23,42,0.65);
opacity: .95;
}
.story-title {
color: #0f172a;
}
.story-body {
color: #0f172a;
}
.story-cta {
background: rgba(17,24,39,0.92);
color: #ffffff;
}
.overlay {
background: linear-gradient(to top, rgba(255,255,255,0.9), rgba(255,255,255,0.6), rgba(255,255,255,0.3));
}
.gradient-fill {
background: linear-gradient(180deg, rgba(255,255,255,0.95) 0%, rgba(14,165,233,0.08) 100%);
}
}
</style>
</head>
<body>

View File

@@ -16,7 +16,7 @@
<div class="flex gap-4 overflow-x-auto nb-scrollbar-none pb-2">
@foreach($spotlight as $item)
<a href="{{ !empty($item->id) ? route('art.show', ['id' => $item->id, 'slug' => $item->slug ?? null]) : '#' }}"
class="group relative flex-none w-44 md:w-52 rounded-xl overflow-hidden
class="explore-spotlight-card group relative flex-none w-44 md:w-52 rounded-xl overflow-hidden
bg-neutral-800 border border-white/10 hover:border-amber-400/40
hover:shadow-lg hover:shadow-amber-500/10 transition-all duration-200"
title="{{ $item->name ?? '' }}">

View File

@@ -5,7 +5,7 @@
@endphp
@if (!$heroArtwork)
<section class="relative flex min-h-[62vh] max-h-[420px] w-full items-end overflow-hidden bg-nova-900 md:min-h-[38vh] md:max-h-[460px]">
<section class="skinbase-dark-surface relative flex min-h-[62vh] max-h-[420px] w-full items-end overflow-hidden bg-nova-900 md:min-h-[38vh] md:max-h-[460px]">
<div class="pointer-events-none absolute inset-0 bg-gradient-to-t from-nova-900 via-nova-900/60 to-transparent"></div>
<div class="relative z-10 w-full px-6 pb-7 sm:px-10 lg:px-16">
<p class="mb-1.5 text-xs font-semibold uppercase tracking-widest text-accent">
@@ -23,7 +23,7 @@
</div>
</section>
@else
<section class="group relative flex min-h-[62vh] max-h-[420px] w-full items-end overflow-hidden bg-nova-900 md:min-h-[38vh] md:max-h-[460px]">
<section class="skinbase-dark-surface group relative flex min-h-[62vh] max-h-[420px] w-full items-end overflow-hidden bg-nova-900 md:min-h-[38vh] md:max-h-[460px]">
<x-artwork.featured-picture
:image="$heroFeaturedImage ?? [
'alt' => $heroArtwork['title'] ?? 'Featured artwork',

View File

@@ -40,7 +40,7 @@
@endif
</div>
@if (!empty(data_get($collection, 'owner.username')))
<span class="shrink-0 text-xs font-semibold uppercase tracking-[0.14em] text-sky-100/80">@{{ data_get($collection, 'owner.username') }}</span>
<span class="shrink-0 text-xs font-semibold uppercase tracking-[0.14em] text-sky-100/80">{{ '@'.data_get($collection, 'owner.username') }}</span>
@endif
</div>

View File

@@ -15,7 +15,7 @@
<a
href="{{ $creator['url'] ?? '#' }}"
aria-label="View {{ $creator['name'] ?? 'Creator' }} profile"
class="group relative flex min-h-[16rem] flex-col items-center overflow-hidden rounded-xl bg-panel p-5 text-center shadow-sm transition hover:ring-1 hover:ring-nova-500"
class="{{ !empty($creator['bg_thumb']) ? 'skinbase-dark-surface ' : '' }}group relative flex min-h-[16rem] flex-col items-center overflow-hidden rounded-xl bg-panel p-5 text-center shadow-sm transition hover:ring-1 hover:ring-nova-500"
@if (!empty($creator['bg_thumb']))
style="background-image: linear-gradient(to top, rgba(13, 19, 28, 0.96), rgba(13, 19, 28, 0.7)), url('{{ $creator['bg_thumb'] }}'); background-size: cover; background-position: center;"
@endif

View File

@@ -25,7 +25,7 @@
['key' => 'followers', 'label' => 'followers', 'value' => (int) data_get($group, 'counts.followers', 0)],
])->filter(fn ($item) => $item['value'] > 0)->values();
@endphp
<article class="group relative flex flex-col overflow-hidden rounded-xl bg-panel p-5 shadow-sm transition hover:ring-1 hover:ring-nova-500">
<article class="{{ !empty($group['banner_url']) ? 'skinbase-dark-surface ' : '' }}group relative flex flex-col overflow-hidden rounded-xl bg-panel p-5 shadow-sm transition hover:ring-1 hover:ring-nova-500">
@if (!empty($group['banner_url']))
<img src="{{ $group['banner_url'] }}" alt="" aria-hidden="true" class="pointer-events-none absolute inset-0 h-full w-full object-cover opacity-40 transition duration-500 group-hover:scale-105 group-hover:opacity-20" loading="lazy" decoding="async">
<div class="pointer-events-none absolute inset-0 bg-gradient-to-t from-panel via-panel/85 to-panel/70"></div>

View File

@@ -18,7 +18,7 @@
<div id="{{ $carouselId }}" class="news-carousel overflow-x-auto snap-x snap-proximity -mx-4 px-4 py-2">
<div class="flex gap-4">
@foreach ($newsItems as $item)
<article class="snap-start flex-shrink-0 w-[260px] group overflow-hidden rounded-[20px] border border-white/[0.06] bg-[linear-gradient(180deg,rgba(11,16,26,0.94),rgba(7,11,19,0.92))] shadow-[0_12px_30px_rgba(0,0,0,0.18)] transition hover:-translate-y-0.5 hover:border-white/[0.12]">
<article class="skinbase-dark-surface snap-start flex-shrink-0 w-[260px] group overflow-hidden rounded-[20px] border border-white/[0.06] bg-[linear-gradient(180deg,rgba(11,16,26,0.94),rgba(7,11,19,0.92))] shadow-[0_12px_30px_rgba(0,0,0,0.18)] transition hover:-translate-y-0.5 hover:border-white/[0.12]">
<a href="{{ $item['url'] ?? '#' }}" class="block">
<div class="relative aspect-[4/3] overflow-hidden bg-black/20">
@if (!empty($item['cover_url']))

View File

@@ -22,7 +22,7 @@
<div class="mt-3 w-full text-center">
<a href="{{ $creator['url'] ?? '#' }}" class="block truncate text-sm font-semibold text-white transition hover:text-accent">{{ $creator['name'] ?? 'Creator' }}</a>
@if (!empty($creator['username']))
<p class="truncate text-xs text-nova-400">@{{ $creator['username'] }}</p>
<p class="truncate text-xs text-nova-400">{{ '@' . $creator['username'] }}</p>
@endif
<div class="mt-2 flex items-center justify-center gap-3 text-xs text-nova-500">
@if ((int) ($creator['followers_count'] ?? 0) > 0)

View File

@@ -43,7 +43,8 @@
</x-slot>
</x-nova-page-header>
<div class="border-b border-white/10 bg-nova-900/90 backdrop-blur-md">
<div class="stories-index-page">
<div class="stories-index-page__tabs border-b border-white/10 bg-nova-900/90 backdrop-blur-md">
<div class="px-6 md:px-10">
<nav data-stories-tabs class="flex items-center gap-0 -mb-px nb-scrollbar-none overflow-x-auto" role="tablist" aria-label="Stories sections">
@foreach($storyTabs as $index => $tab)
@@ -61,7 +62,7 @@
</div>
</div>
<div class="border-b border-white/10 bg-nova-900/70">
<div class="stories-index-page__categories border-b border-white/10 bg-nova-900/70">
<div class="px-6 md:px-10 py-6">
<div class="flex gap-3 overflow-x-auto nb-scrollbar-none pb-1">
<a href="{{ route('stories.index') }}" class="whitespace-nowrap rounded-full px-3 py-1.5 text-sm font-semibold transition-colors {{ $currentCategory === '' ? 'bg-orange-500 text-white' : 'border border-white/10 bg-white/[0.05] text-white/70 hover:bg-white/[0.1] hover:text-white' }}">All</a>
@@ -77,7 +78,7 @@
<div class="px-6 pb-16 md:px-10">
<div class="space-y-10">
@if($featured)
<section id="featured" class="overflow-hidden rounded-xl border border-gray-700 bg-gray-800/70 shadow-lg">
<section id="featured" class="stories-index-page__featured overflow-hidden rounded-xl border border-gray-700 bg-gray-800/70 shadow-lg">
<a href="{{ route('stories.show', $featured->slug) }}" class="grid gap-0 lg:grid-cols-2">
<div class="aspect-video overflow-hidden bg-gray-900">
@if($featured->cover_url)
@@ -129,6 +130,7 @@
</section>
</div>
</div>
</div>
@endsection
@push('scripts')

View File

@@ -44,7 +44,24 @@
@endsection
@section('page-content')
<div class="mx-auto grid max-w-7xl gap-8 lg:grid-cols-12">
<div id="story-page" class="mx-auto grid max-w-7xl gap-8 lg:grid-cols-12">
<style>
@media (prefers-color-scheme: light) {
#story-page { color-scheme: light; }
#story-page .rounded-xl.border { background: #ffffff !important; border-color: #e6e6ef !important; }
#story-page img { background: transparent; }
#story-page h1.text-white { color: #0f172a !important; }
#story-page .text-white { color: #0f172a !important; }
#story-page .text-gray-300 { color: #6b7280 !important; }
#story-page .text-gray-400 { color: #9ca3af !important; }
#story-page .prose, #story-page .story-prose, #story-page .prose * { color: #0f172a !important; }
#story-page .prose a, #story-page a { color: #0f6fbf !important; }
#story-page .rounded-xl.border .p-6 { background: transparent !important; }
#story-page .rounded-xl.border.bg-gray-900\/50, #story-page .bg-gray-900\/50 { background: rgba(17,24,39,0.05) !important; }
#story-page textarea, #story-page button { color: inherit !important; }
#story-page button, #story-page .w-full.rounded-lg { background: #fef2f2 !important; color: #9f1239 !important; border-color: #fbcfe8 !important; }
}
</style>
<article class="lg:col-span-8">
<div class="overflow-hidden rounded-xl border border-gray-700 bg-gray-800/70">
@if($story->cover_url)

View File

@@ -38,6 +38,9 @@ use App\Http\Controllers\RSS\TagFeedController;
use App\Http\Controllers\RSS\CreatorFeedController;
use App\Http\Controllers\RSS\BlogFeedController;
use App\Http\Controllers\Admin\AdminController;
use App\Http\Controllers\Moderation\StaffApplicationsController;
use App\Http\Controllers\Moderation\StoriesController as ModerationStoriesController;
use App\Http\Controllers\Moderation\UsernameQueueController;
use App\Http\Controllers\Studio\StudioNewsController;
use App\Http\Controllers\Studio\StudioController;
use App\Http\Controllers\Studio\StudioWorldController;
@@ -189,6 +192,7 @@ Route::prefix('academy')->name('academy.')->group(function () {
Route::post('/challenges/{slug}/submit', [AcademyChallengeSubmissionController::class, 'store'])->name('challenges.submit.store');
Route::post('/checkout/{plan}', [AcademyBillingController::class, 'checkoutLegacy'])->name('checkout');
Route::post('/billing/checkout', [AcademyBillingController::class, 'checkout'])->name('billing.checkout');
Route::post('/billing/report-issue', [AcademyBillingController::class, 'reportIssue'])->middleware('throttle:6,1')->name('billing.report_issue');
Route::get('/billing/portal', [AcademyBillingController::class, 'portal'])->name('billing.portal');
Route::get('/billing', [AcademyBillingController::class, 'account'])->name('billing.account');
});
@@ -1094,9 +1098,13 @@ Route::middleware(['auth', 'admin.access'])
Route::get('/activity', [AdminController::class, 'dailyActivity'])->name('activity');
Route::get('/users', [AdminController::class, 'users'])->name('users');
Route::patch('/users/{user}/role', [AdminController::class, 'updateRole'])->name('users.role');
Route::get('/stories', [AdminController::class, 'stories'])->name('stories');
Route::get('/stories', [ModerationStoriesController::class, 'index'])->name('stories');
Route::get('/artworks', [AdminController::class, 'artworks'])->name('artworks');
Route::get('/usernames/moderation', [AdminController::class, 'usernameQueue'])->name('usernames');
Route::get('/usernames/moderation', [UsernameQueueController::class, 'index'])->name('usernames');
Route::get('/staff-applications', [StaffApplicationsController::class, 'index'])->name('staff-applications.index');
Route::get('/staff-applications/{staffApplication}', [StaffApplicationsController::class, 'show'])
->whereUuid('staffApplication')
->name('staff-applications.show');
Route::get('/uploads', [AdminController::class, 'uploadQueue'])->name('uploads');
Route::get('/settings', [AdminController::class, 'settings'])->name('settings');
Route::middleware('admin.role')->get('/auth-audit', [AdminController::class, 'authAudit'])->name('auth-audit');

View File

@@ -4,9 +4,12 @@ declare(strict_types=1);
namespace Tests\Feature\Academy;
use App\Mail\AcademyAccessIssue;
use App\Http\Middleware\ConditionalValidateCsrfToken;
use App\Models\StaffApplication;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Mail;
use Inertia\Testing\AssertableInertia;
use Laravel\Cashier\SubscriptionBuilder;
use Mockery;
@@ -182,6 +185,49 @@ final class AcademyBillingCheckoutTest extends TestCase
->assertRedirect(route('academy.billing.portal'));
}
public function test_support_report_sends_mail_immediately_and_stores_record(): void
{
Mail::fake();
config()->set('mail.from.address', 'info@skinbase.org');
$user = User::factory()->create([
'email_verified_at' => now(),
'name' => 'Billing Tester',
'email' => 'tester@example.com',
]);
$this->actingAs($user)
->from(route('academy.billing.account'))
->post(route('academy.billing.report_issue'), [
'issue_type' => 'access',
'contact_email' => 'reply@example.com',
'session_id' => 'cs_test_123',
'message' => 'I paid but access did not update.',
])
->assertRedirect(route('academy.billing.account'))
->assertSessionHas('success', 'Support request sent — we will verify and activate your access shortly.');
Mail::assertSent(AcademyAccessIssue::class, function (AcademyAccessIssue $mail) use ($user): bool {
return $mail->user->is($user)
&& $mail->issueType === 'access'
&& $mail->contactEmail === 'reply@example.com'
&& $mail->sessionId === 'cs_test_123'
&& $mail->message === 'I paid but access did not update.';
});
$this->assertDatabaseHas('staff_applications', [
'topic' => 'contact',
'email' => 'reply@example.com',
'role' => 'academy_billing_support',
]);
$application = StaffApplication::query()->latest('created_at')->first();
$this->assertNotNull($application);
$this->assertSame('academy_billing', data_get($application?->payload, 'data.source'));
$this->assertSame('access', data_get($application?->payload, 'data.issue_type'));
}
private function configureBilling(): void
{
config()->set('academy.enabled', true);

Some files were not shown because too many files have changed in this diff Show More