Optimize academy
This commit is contained in:
@@ -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=
|
||||
|
||||
@@ -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) : '';
|
||||
}
|
||||
}
|
||||
@@ -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 [
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -119,6 +119,6 @@ final class AcademyCourseLessonController extends Controller
|
||||
],
|
||||
'outline' => $courseOutline,
|
||||
],
|
||||
])->rootView('collections');
|
||||
])->rootView('academy');
|
||||
}
|
||||
}
|
||||
@@ -98,6 +98,6 @@ final class AcademyHomeController extends Controller
|
||||
'isGuest' => $request->user() === null,
|
||||
'isSubscriber' => $request->user()?->hasAcademyCreatorAccess() || $request->user()?->hasAcademyProAccess(),
|
||||
],
|
||||
])->rootView('collections');
|
||||
])->rootView('academy');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,6 +79,6 @@ final class AcademyPricingController extends Controller
|
||||
'isGuest' => $request->user() === null,
|
||||
'isSubscriber' => $request->user()?->hasAcademyCreatorAccess() || $request->user()?->hasAcademyProAccess(),
|
||||
],
|
||||
])->rootView('collections');
|
||||
])->rootView('academy');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
],
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
|
||||
@@ -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]),
|
||||
];
|
||||
}
|
||||
}
|
||||
105
app/Http/Controllers/Moderation/StoriesController.php
Normal file
105
app/Http/Controllers/Moderation/StoriesController.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
116
app/Http/Controllers/Moderation/UsernameQueueController.php
Normal file
116
app/Http/Controllers/Moderation/UsernameQueueController.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
47
app/Mail/AcademyAccessIssue.php
Normal file
47
app/Mail/AcademyAccessIssue.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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";
|
||||
@@ -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";
|
||||
@@ -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": [],
|
||||
|
||||
1588
bootstrap/ssr/ssr.js
1588
bootstrap/ssr/ssr.js
File diff suppressed because one or more lines are too long
12
config/theme.php
Normal file
12
config/theme.php
Normal 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),
|
||||
];
|
||||
@@ -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' => [
|
||||
|
||||
@@ -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
275
env
Normal 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=
|
||||
@@ -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' },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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]"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 2–3 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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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')}>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
|
||||
11
resources/js/Pages/Moderation/FeaturedArtworks.jsx
Normal file
11
resources/js/Pages/Moderation/FeaturedArtworks.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
148
resources/js/Pages/Moderation/StaffApplications/Index.jsx
Normal file
148
resources/js/Pages/Moderation/StaffApplications/Index.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
87
resources/js/Pages/Moderation/StaffApplications/Show.jsx
Normal file
87
resources/js/Pages/Moderation/StaffApplications/Show.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
168
resources/js/Pages/Moderation/Stories.jsx
Normal file
168
resources/js/Pages/Moderation/Stories.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
233
resources/js/Pages/Moderation/UsernameQueue.jsx
Normal file
233
resources/js/Pages/Moderation/UsernameQueue.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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
27
resources/js/academy.jsx
Normal 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)
|
||||
},
|
||||
})
|
||||
@@ -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
32
resources/js/bootstrap-lite.js
vendored
Normal 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)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mountInertiaRoot } from './bootstrap'
|
||||
import { mountInertiaRoot } from './bootstrap-lite'
|
||||
import React from 'react'
|
||||
import { createInertiaApp } from '@inertiajs/react'
|
||||
const pages = {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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" />}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
20
resources/views/academy.blade.php
Normal file
20
resources/views/academy.blade.php
Normal 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
|
||||
@@ -21,7 +21,7 @@
|
||||
this.open = false
|
||||
},
|
||||
}"
|
||||
class="relative"
|
||||
class="dashboard-filter-select relative"
|
||||
@click.outside="open = false"
|
||||
@keydown.escape.window="open = false"
|
||||
>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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">
|
||||
|
||||
21
resources/views/emails/academy_access_issue.blade.php
Normal file
21
resources/views/emails/academy_access_issue.blade.php
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 ?? '' }}">
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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']))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user