Implement academy analytics, billing, and web stories updates

This commit is contained in:
2026-05-26 07:27:29 +02:00
parent 456c3d6bb0
commit 0b33a1b074
177 changed files with 27360 additions and 2685 deletions

View File

@@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Academy;
use App\Http\Controllers\Controller;
use App\Services\Academy\AcademyAnalyticsContentResolver;
use App\Services\Academy\AcademyAnalyticsService;
use App\Support\AcademyAnalytics\AcademyAnalyticsContentType;
use App\Support\AcademyAnalytics\AcademyAnalyticsEventType;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
final class AcademyAnalyticsEventController extends Controller
{
public function __construct(
private readonly AcademyAnalyticsService $analytics,
private readonly AcademyAnalyticsContentResolver $resolver,
) {
}
public function store(Request $request): JsonResponse
{
abort_unless((bool) config('academy.enabled', true), 404);
abort_unless($request->expectsJson() || $request->isJson(), 422);
$validated = $request->validate([
'event_type' => ['required', 'string', Rule::in(AcademyAnalyticsEventType::values())],
'content_type' => ['nullable', 'string', Rule::in(AcademyAnalyticsContentType::values())],
'content_id' => ['nullable', 'integer', 'min:1'],
'metadata' => ['nullable', 'array'],
'visitor_id' => ['nullable', 'string', 'max:120'],
'session_id' => ['nullable', 'string', 'max:120'],
'url' => ['nullable', 'string', 'max:4000'],
'route_name' => ['nullable', 'string', 'max:255'],
'referrer' => ['nullable', 'string', 'max:4000'],
'utm_source' => ['nullable', 'string', 'max:255'],
'utm_medium' => ['nullable', 'string', 'max:255'],
'utm_campaign' => ['nullable', 'string', 'max:255'],
]);
if (isset($validated['metadata']) && strlen((string) json_encode($validated['metadata'])) > 8192) {
return response()->json([
'message' => 'Metadata payload is too large.',
], 422);
}
$contentType = $validated['content_type'] ?? null;
$contentId = $validated['content_id'] ?? null;
if ($contentType !== null && AcademyAnalyticsContentType::requiresContentId($contentType) && $contentId === null) {
return response()->json([
'message' => 'content_id is required for this content type.',
], 422);
}
if ($contentType !== null && $contentId !== null && ! $this->resolver->exists($contentType, (int) $contentId)) {
return response()->json([
'message' => 'Unknown Academy analytics content target.',
], 422);
}
if (($validated['event_type'] ?? null) === AcademyAnalyticsEventType::SEARCH_RESULT_CLICK) {
validator([
'content_type' => $contentType,
'content_id' => $contentId,
'metadata' => $validated['metadata'] ?? [],
], [
'content_type' => ['required', 'string', Rule::in([
AcademyAnalyticsContentType::PROMPT,
AcademyAnalyticsContentType::LESSON,
AcademyAnalyticsContentType::COURSE,
AcademyAnalyticsContentType::PROMPT_PACK,
AcademyAnalyticsContentType::CHALLENGE,
])],
'content_id' => ['required', 'integer', 'min:1'],
'metadata.query' => ['required', 'string', 'max:120'],
'metadata.normalized_query' => ['required', 'string', 'max:120'],
'metadata.results_count' => ['required', 'integer', 'min:0'],
'metadata.position' => ['nullable', 'integer', 'min:1'],
'metadata.source' => ['nullable', 'string', 'max:120'],
'metadata.filters' => ['nullable', 'array'],
])->validate();
}
$this->analytics->track($validated, $request->user(), $request);
return response()->json([
'ok' => true,
]);
}
}

View File

@@ -0,0 +1,422 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Academy;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Services\Academy\AcademyAccessService;
use App\Services\Academy\AcademyBillingPlanService;
use App\Support\AcademyAnalytics\AcademyAnalyticsContentType;
use App\Support\Seo\SeoFactory;
use Laravel\Cashier\Checkout;
use Laravel\Cashier\Subscription;
final class AcademyBillingController extends Controller
{
public function __construct(
private readonly AcademyAccessService $access,
private readonly AcademyBillingPlanService $plans,
) {}
public function pricing(\Illuminate\Http\Request $request): \Inertia\Response
{
\abort_unless((bool) \config('academy.enabled', true), 404);
$this->plans->assertConfigured();
/** @var User|null $user */
$user = $request->user();
$canonical = \route('academy.pricing');
$seo = \app(SeoFactory::class)
->collectionPage(
'Skinbase AI Academy Pricing — Skinbase',
'Compare Skinbase AI Academy Creator and Pro tiers, start free, and manage premium access through Stripe billing.',
$canonical,
)
->toArray();
$seo['og_type'] = 'website';
$activePlan = $user instanceof User ? $this->activePlan($user) : null;
return \Inertia\Inertia::render('Academy/Billing/Pricing', [
'seo' => $seo,
'billingEnabled' => $this->plans->enabled(),
'currentTier' => $this->access->currentTier($user),
'isSubscribed' => $user instanceof User ? $this->access->hasActiveAcademySubscription($user) : false,
'activePlanKey' => $activePlan['key'] ?? null,
'activePlanLabel' => $activePlan['label'] ?? null,
'catalog' => $this->catalog(),
'links' => [
'login' => \route('login'),
'pricing' => \route('academy.pricing'),
'billingAccount' => $user ? \route('academy.billing.account') : null,
'checkout' => $user ? \route('academy.billing.checkout') : null,
],
'analytics' => [
'enabled' => true,
'contentType' => AcademyAnalyticsContentType::UPGRADE,
'contentId' => null,
'eventUrl' => \route('academy.analytics.events.store'),
'pageName' => 'academy_billing_pricing',
'isPremium' => false,
'isGuest' => $user === null,
'isSubscriber' => $user?->hasAcademyCreatorAccess() || $user?->hasAcademyProAccess(),
],
])->rootView('collections');
}
public function checkout(\Illuminate\Http\Request $request): Checkout|\Illuminate\Http\JsonResponse|\Illuminate\Http\RedirectResponse
{
\abort_unless((bool) \config('academy.enabled', true), 404);
if (! $this->plans->enabled()) {
return $this->billingDisabledResponse($request, 'Academy billing is not enabled yet.');
}
$this->plans->assertConfigured();
$user = $request->user();
if (! $user instanceof User) {
return \redirect()->route('login');
}
if ($user->email_verified_at === null) {
throw \Illuminate\Validation\ValidationException::withMessages([
'plan' => 'Verify your email address before starting Academy billing.',
]);
}
$validated = $request->validate([
'plan' => ['required', 'string'],
]);
$plan = $this->plans->plan((string) $validated['plan']);
if ($plan === null) {
throw \Illuminate\Validation\ValidationException::withMessages([
'plan' => 'Select a valid Academy billing plan.',
]);
}
if (! ($plan['configured'] ?? false)) {
return $this->missingPriceIdResponse($request, (string) $plan['key']);
}
if (! ($plan['price_id_valid'] ?? false)) {
return $this->invalidPriceIdResponse($request, (string) $plan['key']);
}
if ($this->access->hasActiveAcademySubscription($user)) {
return \redirect()->route('academy.billing.portal');
}
try {
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 $exception) {
\report($exception);
return $this->checkoutErrorResponse($request, $exception);
}
}
public function checkoutLegacy(\Illuminate\Http\Request $request, string $plan): Checkout|\Illuminate\Http\JsonResponse|\Illuminate\Http\RedirectResponse
{
$request->merge([
'plan' => $this->plans->normalizePlanKey($plan),
]);
return $this->checkout($request);
}
public function portal(\Illuminate\Http\Request $request): \Illuminate\Http\RedirectResponse
{
\abort_unless((bool) \config('academy.enabled', true), 404);
\abort_unless($this->plans->enabled(), 404);
/** @var User|null $user */
$user = $request->user();
if (! $user instanceof User || \blank($user->stripe_id)) {
return \redirect()->route('academy.billing.account')->with('error', 'No Stripe billing profile is connected to this account yet.');
}
return $user->redirectToBillingPortal(\route('academy.billing.account'));
}
public function success(\Illuminate\Http\Request $request): \Inertia\Response
{
\abort_unless((bool) \config('academy.enabled', true), 404);
/** @var User|null $user */
$user = $request->user();
$currentTier = $this->access->currentTier($user);
return \Inertia\Inertia::render('Academy/Billing/Success', [
'message' => 'Payment is being confirmed. Your access will update automatically.',
'currentTier' => $currentTier,
'isSubscribed' => $user instanceof User ? $this->access->hasActiveAcademySubscription($user) : false,
'links' => [
'pricing' => \route('academy.pricing'),
'account' => $user ? \route('academy.billing.account') : null,
'academy' => \route('academy.index'),
],
'sessionId' => $request->query('session_id'),
])->rootView('collections');
}
public function cancel(): \Inertia\Response
{
\abort_unless((bool) \config('academy.enabled', true), 404);
return \Inertia\Inertia::render('Academy/Billing/Cancel', [
'message' => 'Checkout was canceled. No payment was made.',
'links' => [
'pricing' => \route('academy.pricing'),
'academy' => \route('academy.index'),
],
])->rootView('collections');
}
public function account(\Illuminate\Http\Request $request): \Inertia\Response
{
\abort_unless((bool) \config('academy.enabled', true), 404);
\abort_unless($this->plans->enabled(), 404);
/** @var User $user */
$user = $request->user();
$subscription = $this->academySubscription($user);
$activePlan = $this->activePlan($user);
return \Inertia\Inertia::render('Academy/Billing/Account', [
'currentTier' => $this->access->currentTier($user),
'isSubscribed' => $this->access->hasActiveAcademySubscription($user),
'activePlan' => $activePlan ? [
'key' => $activePlan['key'],
'label' => $activePlan['label'],
'price_display' => $activePlan['price_display'] ?? null,
'tier' => $activePlan['tier'],
] : null,
'subscription' => $subscription ? [
'name' => $subscription->type,
'status' => $subscription->stripe_status,
'active' => $subscription->active(),
'onGracePeriod' => $subscription->onGracePeriod(),
'endsAt' => $subscription->ends_at?->toISOString(),
'priceIds' => $subscription->items->pluck('stripe_price')->filter()->values()->all(),
] : null,
'links' => [
'portal' => \route('academy.billing.portal'),
'pricing' => \route('academy.pricing'),
'academy' => \route('academy.index'),
],
])->rootView('collections');
}
/**
* @return array<int, array<string, mixed>>
*/
private function catalog(): array
{
$definitions = [
'creator' => [
'name' => 'Creator',
'description' => 'Entry premium access for prompt systems, creator lessons, and saved Academy workflows.',
'badge' => 'Paid',
'featured' => false,
'features' => [
'Creator lessons and walkthroughs',
'Full Creator prompt templates',
'Prompt save and reuse flows',
'Upgrade path into Pro later',
],
],
'pro' => [
'name' => 'Pro',
'description' => 'Full Academy access across Creator and Pro lessons, prompts, and future premium drops.',
'badge' => 'Recommended',
'featured' => true,
'features' => [
'Everything in Creator',
'Advanced Pro lessons and prompt systems',
'Priority access to future Academy premium features',
'Stripe billing portal for upgrades and invoices',
],
],
];
return collect($definitions)
->map(function (array $definition, string $tier): array {
$plan = $this->plans->plan($tier.'_monthly');
$plans = $plan !== null ? [[
'key' => $plan['key'],
'label' => $plan['label'],
'interval' => $plan['interval'],
'amount' => $plan['amount'],
'currency' => $plan['currency'],
'price_display' => $plan['price_display'],
'configured' => $plan['configured'],
'price_id_valid' => $plan['price_id_valid'],
]] : [];
return [
'tier' => $tier,
'name' => $definition['name'],
'description' => $definition['description'],
'badge' => $definition['badge'],
'featured' => $definition['featured'],
'features' => $definition['features'],
'plans' => $plans,
];
})
->values()
->all();
}
private function academySubscription(User $user): ?Subscription
{
$subscription = $user->subscription($this->plans->subscriptionName());
return $subscription instanceof Subscription
? $subscription->loadMissing('items')
: null;
}
/**
* @return array<string, mixed>|null
*/
private function activePlan(User $user): ?array
{
$subscription = $this->academySubscription($user);
if (! $subscription instanceof Subscription || (! $subscription->active() && ! $subscription->onGracePeriod())) {
return null;
}
$matchedPlan = null;
foreach ($subscription->items as $item) {
$priceId = trim((string) $item->stripe_price);
if ($priceId === '') {
continue;
}
$plan = $this->plans->planForPriceId($priceId);
if ($plan === null) {
continue;
}
if ($matchedPlan === null || $this->planRank((string) $plan['tier']) > $this->planRank((string) $matchedPlan['tier'])) {
$matchedPlan = $plan;
}
}
if ($matchedPlan !== null) {
return $matchedPlan;
}
$fallbackPriceId = trim((string) $subscription->stripe_price);
return $fallbackPriceId !== '' ? $this->plans->planForPriceId($fallbackPriceId) : null;
}
private function planRank(string $tier): int
{
return match (strtolower(trim($tier))) {
'admin' => 40,
'pro' => 30,
'creator' => 20,
default => 10,
};
}
private function billingDisabledResponse(\Illuminate\Http\Request $request, string $message): \Illuminate\Http\JsonResponse|\Illuminate\Http\RedirectResponse
{
$payload = [
'ok' => false,
'code' => 'academy_payments_disabled',
'message' => $message,
];
if ($request->expectsJson()) {
return \response()->json($payload, 423);
}
return \redirect()->route('academy.pricing')->with('error', $message);
}
private function missingPriceIdResponse(\Illuminate\Http\Request $request, string $planKey): \Illuminate\Http\JsonResponse|\Illuminate\Http\RedirectResponse
{
$message = 'The selected Academy plan is not configured yet. Please try again later.';
if ($request->expectsJson()) {
return \response()->json([
'ok' => false,
'code' => 'academy_billing_price_missing',
'message' => $message,
'plan' => $planKey,
], 422);
}
return \redirect()->route('academy.pricing')->with('error', $message);
}
private function invalidPriceIdResponse(\Illuminate\Http\Request $request, string $planKey): \Illuminate\Http\JsonResponse|\Illuminate\Http\RedirectResponse
{
$message = 'The selected Academy plan is misconfigured. Please contact support before continuing.';
if ($request->expectsJson()) {
return \response()->json([
'ok' => false,
'code' => 'academy_billing_price_invalid',
'message' => $message,
'plan' => $planKey,
], 422);
}
return \redirect()->route('academy.pricing')->with('error', $message);
}
private function checkoutErrorResponse(\Illuminate\Http\Request $request, \Throwable $exception): \Illuminate\Http\JsonResponse|\Illuminate\Http\RedirectResponse
{
$message = 'Academy checkout could not be started right now.';
if (app()->hasDebugModeEnabled() && trim($exception->getMessage()) !== '') {
$message .= ' '.$exception->getMessage();
}
if ($request->expectsJson()) {
return \response()->json([
'ok' => false,
'code' => 'academy_billing_checkout_failed',
'message' => $message,
], 422);
}
return \redirect()->route('academy.pricing')->with('error', $message);
}
}

View File

@@ -7,6 +7,8 @@ namespace App\Http\Controllers\Academy;
use App\Http\Controllers\Controller;
use App\Models\AcademyChallenge;
use App\Services\Academy\AcademyAccessService;
use App\Services\Academy\AcademyInteractionService;
use App\Support\AcademyAnalytics\AcademyAnalyticsContentType;
use App\Support\Seo\SeoFactory;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
@@ -15,7 +17,10 @@ use Inertia\Response;
final class AcademyChallengeController extends Controller
{
public function __construct(private readonly AcademyAccessService $access)
public function __construct(
private readonly AcademyAccessService $access,
private readonly AcademyInteractionService $interactions,
)
{
}
@@ -49,6 +54,16 @@ final class AcademyChallengeController extends Controller
'filters' => [],
'categories' => [],
'pricingUrl' => route('academy.pricing'),
'analytics' => [
'enabled' => true,
'contentType' => null,
'contentId' => null,
'eventUrl' => route('academy.analytics.events.store'),
'pageName' => 'academy_challenges_index',
'isPremium' => false,
'isGuest' => $request->user() === null,
'isSubscriber' => $request->user()?->hasAcademyCreatorAccess() || $request->user()?->hasAcademyProAccess(),
],
])->rootView('collections');
}
@@ -86,12 +101,31 @@ final class AcademyChallengeController extends Controller
$challenge->cover_image,
)->toArray();
$interaction = $this->interactions->getInteractionState($request->user(), AcademyAnalyticsContentType::CHALLENGE, (int) $challenge->id);
return Inertia::render('Academy/Show', [
'pageType' => 'challenge',
'item' => $payload,
'seo' => $seo,
'pricingUrl' => route('academy.pricing'),
'submitUrl' => $request->user() ? route('academy.challenges.submit', ['slug' => $challenge->slug]) : null,
'interaction' => $interaction,
'interactionRoutes' => [
'like' => route('academy.interactions.like'),
'save' => route('academy.interactions.save'),
],
'loginUrl' => route('login'),
'analytics' => [
'enabled' => true,
'contentType' => AcademyAnalyticsContentType::CHALLENGE,
'contentId' => (int) $challenge->id,
'eventUrl' => route('academy.analytics.events.store'),
'pageName' => 'academy_challenge_show',
'isPremium' => (string) ($challenge->access_level ?? 'free') !== 'free',
'isGuest' => $request->user() === null,
'isSubscriber' => $request->user()?->hasAcademyCreatorAccess() || $request->user()?->hasAcademyProAccess(),
'isLocked' => (bool) ($payload['locked'] ?? false),
],
])->rootView('collections');
}
}

View File

@@ -8,9 +8,11 @@ use App\Http\Controllers\Controller;
use App\Models\AcademyCourse;
use App\Models\AcademyCourseLesson;
use App\Services\Academy\AcademyAccessService;
use App\Services\Academy\AcademyInteractionService;
use App\Services\Academy\AcademyCacheService;
use App\Services\Academy\AcademyCourseNavigationService;
use App\Services\Academy\AcademyCourseProgressService;
use App\Support\AcademyAnalytics\AcademyAnalyticsContentType;
use App\Support\Seo\SeoFactory;
use Illuminate\Http\Request;
use Inertia\Inertia;
@@ -23,6 +25,7 @@ final class AcademyCourseController extends Controller
private readonly AcademyCacheService $cache,
private readonly AcademyCourseNavigationService $navigation,
private readonly AcademyCourseProgressService $progress,
private readonly AcademyInteractionService $interactions,
) {
}
@@ -82,6 +85,16 @@ final class AcademyCourseController extends Controller
'featuredCourses' => $featuredCourses->all(),
'filters' => $filters,
'pricingUrl' => route('academy.pricing'),
'analytics' => [
'enabled' => true,
'contentType' => null,
'contentId' => null,
'eventUrl' => route('academy.analytics.events.store'),
'pageName' => 'academy_courses_index',
'isPremium' => false,
'isGuest' => $request->user() === null,
'isSubscriber' => $request->user()?->hasAcademyCreatorAccess() || $request->user()?->hasAcademyProAccess(),
],
])->rootView('collections');
}
@@ -172,6 +185,8 @@ final class AcademyCourseController extends Controller
)
->toArray();
$interaction = $this->interactions->getInteractionState($request->user(), AcademyAnalyticsContentType::COURSE, (int) $course->id);
return Inertia::render('Academy/CoursesShow', [
'seo' => $seo,
'course' => $coursePayload,
@@ -179,6 +194,23 @@ final class AcademyCourseController extends Controller
'unsectionedLessons' => $unsectionedLessons,
'pricingUrl' => route('academy.pricing'),
'startUrl' => $request->user() ? route('academy.courses.start', ['course' => $course->slug]) : null,
'interaction' => $interaction,
'interactionRoutes' => [
'like' => route('academy.interactions.like'),
'save' => route('academy.interactions.save'),
],
'loginUrl' => route('login'),
'analytics' => [
'enabled' => true,
'contentType' => AcademyAnalyticsContentType::COURSE,
'contentId' => (int) $course->id,
'eventUrl' => route('academy.analytics.events.store'),
'pageName' => 'academy_course_show',
'isPremium' => (string) ($course->access_level ?? 'free') !== 'free',
'isGuest' => $request->user() === null,
'isSubscriber' => $request->user()?->hasAcademyCreatorAccess() || $request->user()?->hasAcademyProAccess(),
'isLocked' => false,
],
])->rootView('collections');
}
}

View File

@@ -6,14 +6,17 @@ namespace App\Http\Controllers\Academy;
use App\Http\Controllers\Controller;
use App\Models\AcademyCourse;
use App\Services\Academy\AcademyProgressService;
use App\Services\Academy\AcademyCourseProgressService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
final class AcademyCourseEnrollmentController extends Controller
{
public function __construct(private readonly AcademyCourseProgressService $progress)
{
public function __construct(
private readonly AcademyCourseProgressService $progress,
private readonly AcademyProgressService $academyProgress,
) {
}
public function start(Request $request, AcademyCourse $course): RedirectResponse
@@ -21,7 +24,7 @@ final class AcademyCourseEnrollmentController extends Controller
abort_unless((bool) config('academy.enabled', true), 404);
abort_unless($course->isPublished(), 404);
$this->progress->markEnrollmentStarted($request->user(), $course);
$this->academyProgress->startCourse($request->user(), (int) $course->id, $request);
$continueLesson = $this->progress->getContinueLesson($request->user(), $course);
if ($continueLesson?->lesson) {

View File

@@ -8,8 +8,10 @@ use App\Http\Controllers\Controller;
use App\Models\AcademyCourse;
use App\Models\AcademyLesson;
use App\Services\Academy\AcademyAccessService;
use App\Services\Academy\AcademyInteractionService;
use App\Services\Academy\AcademyCourseNavigationService;
use App\Services\Academy\AcademyCourseProgressService;
use App\Support\AcademyAnalytics\AcademyAnalyticsContentType;
use App\Support\Seo\SeoFactory;
use Illuminate\Support\Str;
use Illuminate\Http\Request;
@@ -22,6 +24,7 @@ final class AcademyCourseLessonController extends Controller
private readonly AcademyAccessService $access,
private readonly AcademyCourseNavigationService $navigation,
private readonly AcademyCourseProgressService $progress,
private readonly AcademyInteractionService $interactions,
) {
}
@@ -68,6 +71,8 @@ final class AcademyCourseLessonController extends Controller
(string) $course->title,
)->toArray();
$interaction = $this->interactions->getInteractionState($request->user(), AcademyAnalyticsContentType::LESSON, (int) $lesson->id);
return Inertia::render('Academy/Show', [
'pageType' => 'lesson',
'item' => $payload,
@@ -79,6 +84,26 @@ final class AcademyCourseLessonController extends Controller
'pricingUrl' => route('academy.pricing'),
'completeUrl' => $request->user() ? route('academy.lessons.complete', ['lesson' => $lesson->id]) : null,
'completed' => $request->user()?->academyLessonProgress()->where('lesson_id', $lesson->id)->whereNotNull('completed_at')->exists() ?? false,
'interaction' => $interaction,
'interactionRoutes' => [
'like' => route('academy.interactions.like'),
'save' => route('academy.interactions.save'),
],
'loginUrl' => route('login'),
'progressRoutes' => [
'startLesson' => $request->user() ? route('academy.progress.lesson.start') : null,
],
'analytics' => [
'enabled' => true,
'contentType' => AcademyAnalyticsContentType::LESSON,
'contentId' => (int) $lesson->id,
'eventUrl' => route('academy.analytics.events.store'),
'pageName' => 'academy_course_lesson_show',
'isPremium' => (string) ($payload['access_level'] ?? 'free') !== 'free',
'isGuest' => $request->user() === null,
'isSubscriber' => $request->user()?->hasAcademyCreatorAccess() || $request->user()?->hasAcademyProAccess(),
'isLocked' => (bool) ($payload['locked'] ?? false),
],
'courseContext' => [
'id' => (int) $course->id,
'title' => (string) $course->title,

View File

@@ -11,6 +11,7 @@ use App\Models\AcademyLesson;
use App\Models\AcademyPromptTemplate;
use App\Services\Academy\AcademyAccessService;
use App\Services\Academy\AcademyCacheService;
use App\Support\AcademyAnalytics\AcademyAnalyticsContentType;
use App\Support\Seo\SeoFactory;
use Illuminate\Http\Request;
use Inertia\Inertia;
@@ -81,6 +82,16 @@ final class AcademyHomeController extends Controller
'featuredLessons' => collect($home['featuredLessons'])->map(fn (AcademyLesson $lesson): array => $this->access->lessonPayload($lesson, $request->user()))->values()->all(),
'featuredPrompts' => collect($home['featuredPrompts'])->map(fn (AcademyPromptTemplate $prompt): array => $this->access->promptPayload($prompt, $request->user()))->values()->all(),
'featuredChallenges' => collect($home['featuredChallenges'])->map(fn (AcademyChallenge $challenge): array => $this->access->challengePayload($challenge, $request->user(), true))->values()->all(),
'analytics' => [
'enabled' => true,
'contentType' => AcademyAnalyticsContentType::HOME,
'contentId' => null,
'eventUrl' => route('academy.analytics.events.store'),
'pageName' => 'academy_home',
'isPremium' => false,
'isGuest' => $request->user() === null,
'isSubscriber' => $request->user()?->hasAcademyCreatorAccess() || $request->user()?->hasAcademyProAccess(),
],
])->rootView('collections');
}
}

View File

@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Academy;
use App\Http\Controllers\Controller;
use App\Services\Academy\AcademyInteractionService;
use App\Support\AcademyAnalytics\AcademyAnalyticsContentType;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
use InvalidArgumentException;
final class AcademyInteractionController extends Controller
{
public function __construct(private readonly AcademyInteractionService $interactions)
{
}
public function like(Request $request): JsonResponse
{
abort_unless((bool) config('academy.enabled', true), 404);
$validated = $this->validatePayload($request);
try {
$payload = $this->interactions->toggleLike($request->user(), (string) $validated['content_type'], (int) $validated['content_id'], $request);
} catch (InvalidArgumentException $exception) {
return response()->json(['message' => $exception->getMessage()], 422);
}
return response()->json($payload);
}
public function save(Request $request): JsonResponse
{
abort_unless((bool) config('academy.enabled', true), 404);
$validated = $this->validatePayload($request);
try {
$payload = $this->interactions->toggleSave($request->user(), (string) $validated['content_type'], (int) $validated['content_id'], $request);
} catch (InvalidArgumentException $exception) {
return response()->json(['message' => $exception->getMessage()], 422);
}
return response()->json($payload);
}
/**
* @return array<string, mixed>
*/
private function validatePayload(Request $request): array
{
return $request->validate([
'content_type' => ['required', 'string', Rule::in([
AcademyAnalyticsContentType::PROMPT,
AcademyAnalyticsContentType::LESSON,
AcademyAnalyticsContentType::COURSE,
AcademyAnalyticsContentType::PROMPT_PACK,
AcademyAnalyticsContentType::CHALLENGE,
])],
'content_id' => ['required', 'integer', 'min:1'],
]);
}
}

View File

@@ -8,7 +8,10 @@ use App\Http\Controllers\Controller;
use App\Models\AcademyCourse;
use App\Models\AcademyLesson;
use App\Services\Academy\AcademyAccessService;
use App\Services\Academy\AcademyAnalyticsService;
use App\Services\Academy\AcademyCacheService;
use App\Services\Academy\AcademyInteractionService;
use App\Support\AcademyAnalytics\AcademyAnalyticsContentType;
use App\Support\Seo\SeoFactory;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
@@ -20,6 +23,8 @@ final class AcademyLessonController extends Controller
public function __construct(
private readonly AcademyAccessService $access,
private readonly AcademyCacheService $cache,
private readonly AcademyAnalyticsService $analytics,
private readonly AcademyInteractionService $interactions,
) {}
public function index(Request $request): Response
@@ -56,6 +61,10 @@ final class AcademyLessonController extends Controller
$lessons = $query->paginate(12)->withQueryString();
$lessons->getCollection()->transform(fn (AcademyLesson $lesson): array => $this->access->lessonPayload($lesson, $request->user()));
if (filled($filters['q'] ?? null)) {
$this->analytics->trackSearch((string) $filters['q'], (int) $lessons->total(), array_filter($filters), $request);
}
$seo = app(SeoFactory::class)
->collectionListing(
'Academy Lessons — Skinbase',
@@ -73,6 +82,20 @@ final class AcademyLessonController extends Controller
'filters' => $filters,
'categories' => $this->cache->categoriesByType('lesson'),
'pricingUrl' => route('academy.pricing'),
'analytics' => [
'enabled' => true,
'contentType' => filled($filters['q'] ?? null) ? AcademyAnalyticsContentType::SEARCH : null,
'contentId' => null,
'eventUrl' => route('academy.analytics.events.store'),
'pageName' => 'academy_lessons_index',
'search' => filled($filters['q'] ?? null) ? [
'query' => (string) $filters['q'],
'resultsCount' => (int) $lessons->total(),
] : null,
'isPremium' => false,
'isGuest' => $request->user() === null,
'isSubscriber' => $request->user()?->hasAcademyCreatorAccess() || $request->user()?->hasAcademyProAccess(),
],
])->rootView('collections');
}
@@ -148,6 +171,8 @@ final class AcademyLessonController extends Controller
(string) ($lesson->series_name ?: $lesson->category?->name ?: 'Academy'),
)->toArray();
$interaction = $this->interactions->getInteractionState($request->user(), AcademyAnalyticsContentType::LESSON, (int) $lesson->id);
return Inertia::render('Academy/Show', [
'pageType' => 'lesson',
'item' => $payload,
@@ -159,6 +184,26 @@ final class AcademyLessonController extends Controller
'pricingUrl' => route('academy.pricing'),
'completeUrl' => $request->user() ? route('academy.lessons.complete', ['lesson' => $lesson->id]) : null,
'completed' => $request->user()?->academyLessonProgress()->where('lesson_id', $lesson->id)->whereNotNull('completed_at')->exists() ?? false,
'interaction' => $interaction,
'interactionRoutes' => [
'like' => route('academy.interactions.like'),
'save' => route('academy.interactions.save'),
],
'loginUrl' => route('login'),
'progressRoutes' => [
'startLesson' => $request->user() ? route('academy.progress.lesson.start') : null,
],
'analytics' => [
'enabled' => true,
'contentType' => AcademyAnalyticsContentType::LESSON,
'contentId' => (int) $lesson->id,
'eventUrl' => route('academy.analytics.events.store'),
'pageName' => 'academy_lesson_show',
'isPremium' => (string) ($lesson->access_level ?? 'free') !== 'free',
'isGuest' => $request->user() === null,
'isSubscriber' => $request->user()?->hasAcademyCreatorAccess() || $request->user()?->hasAcademyProAccess(),
'isLocked' => (bool) ($payload['locked'] ?? false),
],
])->rootView('collections');
}
}

View File

@@ -5,13 +5,15 @@ declare(strict_types=1);
namespace App\Http\Controllers\Academy;
use App\Http\Controllers\Controller;
use App\Support\AcademyAnalytics\AcademyAnalyticsContentType;
use App\Support\Seo\SeoFactory;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
final class AcademyPricingController extends Controller
{
public function index(): Response
public function index(Request $request): Response
{
abort_unless((bool) config('academy.enabled', true), 404);
@@ -67,6 +69,16 @@ final class AcademyPricingController extends Controller
],
],
],
'analytics' => [
'enabled' => true,
'contentType' => AcademyAnalyticsContentType::UPGRADE,
'contentId' => null,
'eventUrl' => route('academy.analytics.events.store'),
'pageName' => 'academy_pricing',
'isPremium' => false,
'isGuest' => $request->user() === null,
'isSubscriber' => $request->user()?->hasAcademyCreatorAccess() || $request->user()?->hasAcademyProAccess(),
],
])->rootView('collections');
}
}

View File

@@ -20,6 +20,32 @@ final class AcademyProgressController extends Controller
) {
}
public function startLesson(Request $request): JsonResponse
{
abort_unless((bool) config('academy.enabled', true), 404);
$validated = $request->validate([
'lesson_id' => ['required', 'integer', 'min:1'],
'course_id' => ['nullable', 'integer', 'min:1'],
]);
$lesson = AcademyLesson::query()->findOrFail((int) $validated['lesson_id']);
abort_unless($this->access->canAccessLesson($request->user(), $lesson), 403);
$courseId = $request->filled('course_id') ? (int) $validated['course_id'] : null;
if ($courseId !== null) {
$course = AcademyCourse::query()->published()->findOrFail($courseId);
abort_unless($course->courseLessons()->where('lesson_id', $lesson->id)->exists(), 403);
}
$record = $this->progress->startLesson($request->user(), (int) $lesson->id, $courseId, $request);
return response()->json([
'ok' => true,
'status' => (string) $record->status,
]);
}
public function complete(Request $request, AcademyLesson $lesson): JsonResponse
{
abort_unless((bool) config('academy.enabled', true), 404);
@@ -31,7 +57,7 @@ final class AcademyProgressController extends Controller
$course = AcademyCourse::query()->published()->find($request->integer('course_id'));
}
$record = $this->progress->markLessonComplete($request->user(), $lesson, $course);
$record = $this->progress->markLessonComplete($request->user(), $lesson, $course, $request);
return response()->json([
'ok' => true,
@@ -39,4 +65,55 @@ final class AcademyProgressController extends Controller
'completed_at' => $record->completed_at?->toISOString(),
]);
}
public function completeLesson(Request $request): JsonResponse
{
abort_unless((bool) config('academy.enabled', true), 404);
$validated = $request->validate([
'lesson_id' => ['required', 'integer', 'min:1'],
'course_id' => ['nullable', 'integer', 'min:1'],
]);
$lesson = AcademyLesson::query()->findOrFail((int) $validated['lesson_id']);
return $this->complete($request, $lesson);
}
public function startCourse(Request $request): JsonResponse
{
abort_unless((bool) config('academy.enabled', true), 404);
$validated = $request->validate([
'course_id' => ['required', 'integer', 'min:1'],
]);
$course = AcademyCourse::query()->published()->findOrFail((int) $validated['course_id']);
$record = $this->progress->startCourse($request->user(), (int) $course->id, $request);
return response()->json([
'ok' => true,
'status' => (string) $record->status,
'progress_percent' => (int) $record->progress_percent,
]);
}
public function completeCourse(Request $request): JsonResponse
{
abort_unless((bool) config('academy.enabled', true), 404);
$validated = $request->validate([
'course_id' => ['required', 'integer', 'min:1'],
]);
$course = AcademyCourse::query()->published()->findOrFail((int) $validated['course_id']);
$record = $this->progress->completeCourse($request->user(), (int) $course->id, $request);
return response()->json([
'ok' => true,
'status' => (string) $record->status,
'progress_percent' => (int) $record->progress_percent,
'completed' => (string) $record->status === 'completed',
]);
}
}

View File

@@ -7,9 +7,13 @@ namespace App\Http\Controllers\Academy;
use App\Http\Controllers\Controller;
use App\Models\AcademyPromptTemplate;
use App\Services\Academy\AcademyAccessService;
use App\Services\Academy\AcademyAnalyticsService;
use App\Services\Academy\AcademyCacheService;
use App\Services\Academy\AcademyInteractionService;
use App\Support\AcademyAnalytics\AcademyAnalyticsContentType;
use App\Support\Seo\SeoFactory;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Str;
use Inertia\Inertia;
use Inertia\Response;
@@ -19,10 +23,12 @@ final class AcademyPromptController extends Controller
public function __construct(
private readonly AcademyAccessService $access,
private readonly AcademyCacheService $cache,
private readonly AcademyAnalyticsService $analytics,
private readonly AcademyInteractionService $interactions,
) {
}
public function index(Request $request): Response
public function index(Request $request): Response|JsonResponse
{
abort_unless((bool) config('academy.enabled', true), 404);
@@ -62,6 +68,14 @@ final class AcademyPromptController extends Controller
$prompts = $query->paginate(12)->withQueryString();
$prompts->getCollection()->transform(fn (AcademyPromptTemplate $prompt): array => $this->access->promptPayload($prompt, $request->user()));
if (filled($filters['q'] ?? null)) {
$this->analytics->trackSearch((string) $filters['q'], (int) $prompts->total(), array_filter($filters), $request);
}
if ($request->expectsJson()) {
return response()->json($prompts);
}
$seo = app(SeoFactory::class)
->collectionListing(
'Academy Prompts — Skinbase',
@@ -79,6 +93,20 @@ final class AcademyPromptController extends Controller
'filters' => $filters,
'categories' => $this->cache->categoriesByType('prompt'),
'pricingUrl' => route('academy.pricing'),
'analytics' => [
'enabled' => true,
'contentType' => filled($filters['q'] ?? null) ? AcademyAnalyticsContentType::SEARCH : null,
'contentId' => null,
'eventUrl' => route('academy.analytics.events.store'),
'pageName' => 'academy_prompts_index',
'search' => filled($filters['q'] ?? null) ? [
'query' => (string) $filters['q'],
'resultsCount' => (int) $prompts->total(),
] : null,
'isPremium' => false,
'isGuest' => $request->user() === null,
'isSubscriber' => $request->user()?->hasAcademyCreatorAccess() || $request->user()?->hasAcademyProAccess(),
],
])->rootView('collections');
}
@@ -102,15 +130,75 @@ final class AcademyPromptController extends Controller
$canonical,
$payload['preview_image'] ?? null,
)->toArray();
$existingSchemas = $seo['json_ld'] ?? [];
if (! is_array($existingSchemas) || ! array_is_list($existingSchemas)) {
$existingSchemas = [$existingSchemas];
}
$seo['json_ld'] = [
...$existingSchemas,
$this->promptStructuredData($payload, $canonical, $description),
];
$canSavePrompt = $request->user() !== null && $this->access->canAccessPrompt($request->user(), $prompt);
$interaction = $this->interactions->getInteractionState($request->user(), AcademyAnalyticsContentType::PROMPT, (int) $prompt->id);
return Inertia::render('Academy/Show', [
'pageType' => 'prompt',
'item' => $payload,
'seo' => $seo,
'pricingUrl' => route('academy.pricing'),
'saveUrl' => $request->user() ? route('academy.prompts.save', ['prompt' => $prompt->id]) : null,
'unsaveUrl' => $request->user() ? route('academy.prompts.unsave', ['prompt' => $prompt->id]) : null,
'saved' => $request->user()?->academySavedPrompts()->where('prompt_template_id', $prompt->id)->exists() ?? false,
'saveUrl' => $canSavePrompt ? route('academy.prompts.save', ['prompt' => $prompt->id]) : null,
'unsaveUrl' => $canSavePrompt ? route('academy.prompts.unsave', ['prompt' => $prompt->id]) : null,
'saved' => $canSavePrompt ? ($request->user()?->academySavedPrompts()->where('prompt_template_id', $prompt->id)->exists() ?? false) : false,
'interaction' => $interaction,
'interactionRoutes' => [
'like' => route('academy.interactions.like'),
'save' => route('academy.interactions.save'),
],
'loginUrl' => route('login'),
'analytics' => [
'enabled' => true,
'contentType' => AcademyAnalyticsContentType::PROMPT,
'contentId' => (int) $prompt->id,
'eventUrl' => route('academy.analytics.events.store'),
'pageName' => 'academy_prompt_show',
'isPremium' => (string) ($prompt->access_level ?? 'free') !== 'free',
'isGuest' => $request->user() === null,
'isSubscriber' => $request->user()?->hasAcademyCreatorAccess() || $request->user()?->hasAcademyProAccess(),
'isLocked' => (bool) ($payload['locked'] ?? false),
],
])->rootView('collections');
}
/**
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
private function promptStructuredData(array $payload, string $canonical, string $description): array
{
$imageUrls = array_values(array_unique(array_filter([
$payload['preview_image'] ?? null,
...collect((array) ($payload['public_examples'] ?? []))
->map(fn (array $example): ?string => $example['image_url'] ?? $example['thumb_url'] ?? null)
->filter()
->values()
->all(),
], fn (mixed $value): bool => is_string($value) && $value !== '')));
$isFree = (string) ($payload['access_level'] ?? 'free') === 'free';
return array_filter([
'@context' => 'https://schema.org',
'@type' => ['CreativeWork', 'LearningResource'],
'name' => (string) ($payload['title'] ?? 'Skinbase Academy prompt'),
'description' => $description,
'url' => $canonical,
'image' => $imageUrls !== [] ? $imageUrls : null,
'isAccessibleForFree' => $isFree,
'hasPart' => $isFree ? null : [
'@type' => 'WebPageElement',
'isAccessibleForFree' => false,
'cssSelector' => '.academy-paywalled-content',
],
], fn (mixed $value): bool => $value !== null && $value !== '' && $value !== []);
}
}

View File

@@ -7,6 +7,8 @@ namespace App\Http\Controllers\Academy;
use App\Http\Controllers\Controller;
use App\Models\AcademyPromptPack;
use App\Services\Academy\AcademyAccessService;
use App\Services\Academy\AcademyInteractionService;
use App\Support\AcademyAnalytics\AcademyAnalyticsContentType;
use App\Support\Seo\SeoFactory;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
@@ -15,7 +17,10 @@ use Inertia\Response;
final class AcademyPromptPackController extends Controller
{
public function __construct(private readonly AcademyAccessService $access)
public function __construct(
private readonly AcademyAccessService $access,
private readonly AcademyInteractionService $interactions,
)
{
}
@@ -50,6 +55,16 @@ final class AcademyPromptPackController extends Controller
'filters' => [],
'categories' => [],
'pricingUrl' => route('academy.pricing'),
'analytics' => [
'enabled' => true,
'contentType' => null,
'contentId' => null,
'eventUrl' => route('academy.analytics.events.store'),
'pageName' => 'academy_packs_index',
'isPremium' => false,
'isGuest' => $request->user() === null,
'isSubscriber' => $request->user()?->hasAcademyCreatorAccess() || $request->user()?->hasAcademyProAccess(),
],
])->rootView('collections');
}
@@ -72,11 +87,30 @@ final class AcademyPromptPackController extends Controller
$pack->cover_image,
)->toArray();
$interaction = $this->interactions->getInteractionState($request->user(), AcademyAnalyticsContentType::PROMPT_PACK, (int) $pack->id);
return Inertia::render('Academy/Show', [
'pageType' => 'pack',
'item' => $payload,
'seo' => $seo,
'pricingUrl' => route('academy.pricing'),
'interaction' => $interaction,
'interactionRoutes' => [
'like' => route('academy.interactions.like'),
'save' => route('academy.interactions.save'),
],
'loginUrl' => route('login'),
'analytics' => [
'enabled' => true,
'contentType' => AcademyAnalyticsContentType::PROMPT_PACK,
'contentId' => (int) $pack->id,
'eventUrl' => route('academy.analytics.events.store'),
'pageName' => 'academy_pack_show',
'isPremium' => (string) ($pack->access_level ?? 'free') !== 'free',
'isGuest' => $request->user() === null,
'isSubscriber' => $request->user()?->hasAcademyCreatorAccess() || $request->user()?->hasAcademyProAccess(),
'isLocked' => (bool) ($payload['locked'] ?? false),
],
])->rootView('collections');
}
}

View File

@@ -11,11 +11,21 @@ use App\Services\ThumbnailPresenter;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Str;
use Carbon\Carbon;
use Illuminate\Contracts\Pagination\Paginator;
class LatestCommentsApiController extends Controller
{
private const PER_PAGE = 20;
private function paginationMeta(Paginator $paginator): array
{
return [
'current_page' => $paginator->currentPage(),
'per_page' => $paginator->perPage(),
'has_more' => $paginator->hasMorePages(),
];
}
public function index(Request $request): JsonResponse
{
$type = $request->query('type', 'all');
@@ -66,15 +76,21 @@ class LatestCommentsApiController extends Controller
$cacheKey = 'comments.latest.all.page1';
$ttl = 120; // 2 minutes
$paginator = Cache::remember($cacheKey, $ttl, fn () => $query->paginate(self::PER_PAGE));
$paginator = Cache::remember($cacheKey, $ttl, fn () => $query
->orderByDesc('artwork_comments.id')
->simplePaginate(self::PER_PAGE));
} else {
$paginator = $query->paginate(self::PER_PAGE);
$paginator = $query
->orderByDesc('artwork_comments.id')
->simplePaginate(self::PER_PAGE);
}
break;
}
if (! isset($paginator)) {
$paginator = $query->paginate(self::PER_PAGE);
$paginator = $query
->orderByDesc('artwork_comments.id')
->simplePaginate(self::PER_PAGE);
}
$items = $paginator->getCollection()->map(function (ArtworkComment $c) {
@@ -113,13 +129,7 @@ class LatestCommentsApiController extends Controller
return response()->json([
'data' => $items,
'meta' => [
'current_page' => $paginator->currentPage(),
'last_page' => $paginator->lastPage(),
'per_page' => $paginator->perPage(),
'total' => $paginator->total(),
'has_more' => $paginator->hasMorePages(),
],
'meta' => $this->paginationMeta($paginator),
]);
}
}

View File

@@ -10,11 +10,21 @@ use App\Services\ThumbnailPresenter;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Str;
use Carbon\Carbon;
use Illuminate\Contracts\Pagination\Paginator;
class LatestCommentsController extends Controller
{
private const PER_PAGE = 20;
private function paginationMeta(Paginator $paginator): array
{
return [
'current_page' => $paginator->currentPage(),
'per_page' => $paginator->perPage(),
'has_more' => $paginator->hasMorePages(),
];
}
public function index(Request $request)
{
$page_title = 'Latest Comments';
@@ -38,7 +48,8 @@ class LatestCommentsController extends Controller
$q->public()->published()->whereNull('deleted_at');
})
->orderByDesc('artwork_comments.created_at')
->paginate(self::PER_PAGE);
->orderByDesc('artwork_comments.id')
->simplePaginate(self::PER_PAGE);
});
$items = $initialData->getCollection()->map(function (ArtworkComment $c) {
@@ -76,13 +87,7 @@ class LatestCommentsController extends Controller
$props = [
'initialComments' => $items->values()->all(),
'initialMeta' => [
'current_page' => $initialData->currentPage(),
'last_page' => $initialData->lastPage(),
'per_page' => $initialData->perPage(),
'total' => $initialData->total(),
'has_more' => $initialData->hasMorePages(),
],
'initialMeta' => $this->paginationMeta($initialData),
'isAuthenticated' => (bool) auth()->user(),
];

View File

@@ -0,0 +1,470 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Settings;
use App\Http\Controllers\Controller;
use App\Models\AcademyContentMetricDaily;
use App\Models\AcademySearchLog;
use App\Services\Academy\AcademyAnalyticsContentResolver;
use App\Services\Academy\AcademyContentIntelligenceService;
use App\Services\Academy\AcademyPopularityService;
use App\Support\AcademyAnalytics\AcademyAnalyticsContentType;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use Inertia\Inertia;
use Inertia\Response;
final class AcademyAdminAnalyticsController extends Controller
{
public function __construct(
private readonly AcademyPopularityService $popularity,
private readonly AcademyAnalyticsContentResolver $resolver,
private readonly AcademyContentIntelligenceService $intelligence,
) {}
public function overview(Request $request): Response
{
[$from, $to, $range] = $this->resolveDateRange($request);
$summary = $this->metricsQuery($from, $to)
->selectRaw('sum(views) as views, sum(unique_visitors) as unique_visitors, sum(user_views) as user_views, sum(guest_views) as guest_views, sum(subscriber_views) as subscriber_views, sum(prompt_copies) as prompt_copies, sum(likes) as likes, sum(saves) as saves, sum(completions) as completions, sum(starts) as starts, sum(upgrade_clicks) as upgrade_clicks')
->first();
return Inertia::render('Admin/Academy/AnalyticsOverview', [
'nav' => $this->nav(),
'range' => $this->rangePayload($range, $from, $to),
'stats' => [
'views' => (int) ($summary?->views ?? 0),
'uniqueVisitors' => (int) ($summary?->unique_visitors ?? 0),
'userViews' => (int) ($summary?->user_views ?? 0),
'guestViews' => (int) ($summary?->guest_views ?? 0),
'subscriberViews' => (int) ($summary?->subscriber_views ?? 0),
'promptCopies' => (int) ($summary?->prompt_copies ?? 0),
'likes' => (int) ($summary?->likes ?? 0),
'saves' => (int) ($summary?->saves ?? 0),
'lessonCompletions' => (int) ($summary?->completions ?? 0),
'courseStarts' => (int) ($summary?->starts ?? 0),
'upgradeClicks' => (int) ($summary?->upgrade_clicks ?? 0),
],
'topContent' => $this->serializeContentRows($this->popularity->topContent($from, $to, 8)),
'topWeek' => $this->serializeContentRows($this->popularity->topContent(now()->subDays(6)->startOfDay(), now()->endOfDay(), 8)),
]);
}
public function content(Request $request): Response
{
return $this->renderContentPage($request, null, 'Content performance', 'Cross-module performance across prompts, lessons, courses, packs, and challenges.');
}
public function prompts(Request $request): Response
{
return $this->renderContentPage($request, AcademyAnalyticsContentType::PROMPT, 'Prompt analytics', 'Copy-heavy prompt performance, save rates, and upgrade interest.');
}
public function lessons(Request $request): Response
{
return $this->renderContentPage($request, AcademyAnalyticsContentType::LESSON, 'Lesson analytics', 'Lesson engagement, starts, completions, and drop-off signals.');
}
public function courses(Request $request): Response
{
return $this->renderContentPage($request, AcademyAnalyticsContentType::COURSE, 'Course analytics', 'Course views, starts, completion progress, and upgrade intent.');
}
public function search(Request $request): Response
{
[$from, $to, $range] = $this->resolveDateRange($request);
$searchQuery = AcademySearchLog::query()->whereBetween('created_at', [$from, $to]);
$searchLogs = (clone $searchQuery)->latest('created_at')->limit(500)->get();
return Inertia::render('Admin/Academy/AnalyticsSearch', [
'nav' => $this->nav(),
'range' => $this->rangePayload($range, $from, $to),
'summary' => [
'searches' => (int) (clone $searchQuery)->count(),
'zeroResultSearches' => (int) (clone $searchQuery)->where('results_count', 0)->count(),
'loggedInSearches' => (int) (clone $searchQuery)->where('is_logged_in', true)->count(),
'subscriberSearches' => (int) (clone $searchQuery)->where('is_subscriber', true)->count(),
'searchesWithClicks' => (int) (clone $searchQuery)->whereNotNull('clicked_content_id')->count(),
],
'topSearches' => (clone $searchQuery)
->selectRaw('normalized_query, max(query) as query, count(*) as searches, sum(results_count = 0) as zero_result_hits, avg(results_count) as avg_results, sum(case when clicked_content_id is not null then 1 else 0 end) as clicks')
->groupBy('normalized_query')
->orderByDesc('searches')
->limit(20)
->get()
->map(fn ($row): array => [
'query' => (string) ($row->query ?: $row->normalized_query),
'normalized_query' => (string) $row->normalized_query,
'searches' => (int) $row->searches,
'zero_result_hits' => (int) $row->zero_result_hits,
'avg_results' => round((float) $row->avg_results, 1),
'clicks' => (int) ($row->clicks ?? 0),
'click_through_rate' => (int) $row->searches > 0 ? round((((int) ($row->clicks ?? 0)) / (int) $row->searches) * 100, 1) : 0,
])
->all(),
'zeroResults' => (clone $searchQuery)
->selectRaw('normalized_query, max(query) as query, count(*) as searches')
->where('results_count', 0)
->groupBy('normalized_query')
->orderByDesc('searches')
->limit(20)
->get()
->map(fn ($row): array => [
'query' => (string) ($row->query ?: $row->normalized_query),
'searches' => (int) $row->searches,
])
->all(),
'lowClickThroughSearches' => (clone $searchQuery)
->selectRaw('normalized_query, max(query) as query, count(*) as searches, sum(case when clicked_content_id is not null then 1 else 0 end) as clicks, avg(results_count) as avg_results')
->groupBy('normalized_query')
->havingRaw('count(*) >= 2')
->orderByRaw('case when count(*) = 0 then 1 else (sum(case when clicked_content_id is not null then 1 else 0 end) * 1.0 / count(*)) end asc')
->limit(20)
->get()
->map(fn ($row): array => [
'query' => (string) ($row->query ?: $row->normalized_query),
'searches' => (int) $row->searches,
'clicks' => (int) ($row->clicks ?? 0),
'avg_results' => round((float) $row->avg_results, 1),
'click_through_rate' => (int) $row->searches > 0 ? round((((int) ($row->clicks ?? 0)) / (int) $row->searches) * 100, 1) : 0,
])
->all(),
'highestClickThroughSearches' => (clone $searchQuery)
->selectRaw('normalized_query, max(query) as query, count(*) as searches, sum(case when clicked_content_id is not null then 1 else 0 end) as clicks, avg(results_count) as avg_results')
->groupBy('normalized_query')
->havingRaw('count(*) >= 2')
->orderByRaw('(sum(case when clicked_content_id is not null then 1 else 0 end) * 1.0 / count(*)) desc')
->limit(20)
->get()
->map(fn ($row): array => [
'query' => (string) ($row->query ?: $row->normalized_query),
'searches' => (int) $row->searches,
'clicks' => (int) ($row->clicks ?? 0),
'avg_results' => round((float) $row->avg_results, 1),
'click_through_rate' => (int) $row->searches > 0 ? round((((int) ($row->clicks ?? 0)) / (int) $row->searches) * 100, 1) : 0,
])
->all(),
'searchesWithResultsNoClicks' => (clone $searchQuery)
->selectRaw('normalized_query, max(query) as query, count(*) as searches, avg(results_count) as avg_results')
->where('results_count', '>', 0)
->whereNull('clicked_content_id')
->groupBy('normalized_query')
->orderByDesc('searches')
->limit(20)
->get()
->map(fn ($row): array => [
'query' => (string) ($row->query ?: $row->normalized_query),
'searches' => (int) $row->searches,
'avg_results' => round((float) $row->avg_results, 1),
'clicks' => 0,
'click_through_rate' => 0,
])
->all(),
'topClickedResults' => (clone $searchQuery)
->selectRaw('clicked_content_type, clicked_content_id, count(*) as clicks')
->whereNotNull('clicked_content_type')
->whereNotNull('clicked_content_id')
->groupBy('clicked_content_type', 'clicked_content_id')
->orderByDesc('clicks')
->limit(20)
->get()
->map(fn ($row): array => [
'title' => $this->resolver->title((string) $row->clicked_content_type, (int) $row->clicked_content_id),
'content_type' => (string) $row->clicked_content_type,
'content_id' => (int) $row->clicked_content_id,
'clicks' => (int) $row->clicks,
])
->all(),
'filterUsage' => $this->summarizeSearchFilters($searchLogs),
'recentSearches' => (clone $searchQuery)
->latest('created_at')
->limit(25)
->get()
->map(fn (AcademySearchLog $log): array => [
'query' => (string) $log->query,
'results_count' => (int) $log->results_count,
'logged_in' => (bool) $log->is_logged_in,
'subscriber' => (bool) $log->is_subscriber,
'clicked_content_type' => $log->clicked_content_type,
'has_click' => $log->clicked_content_id !== null,
'created_at' => $log->created_at?->toISOString(),
])
->all(),
]);
}
public function intelligence(Request $request): Response
{
[$from, $to, $range] = $this->resolveDateRange($request, '30d');
$filters = [
'from' => $from,
'to' => $to,
'limit' => 25,
];
return Inertia::render('Admin/Academy/AnalyticsIntelligence', [
'nav' => $this->nav(),
'range' => $this->rangePayload($range, $from, $to),
'contentOpportunities' => $this->intelligence->getContentOpportunities($filters),
'searchGaps' => $this->intelligence->getSearchGaps($filters),
'promptInsights' => $this->intelligence->getPromptInsights($filters),
'lessonDropoffs' => $this->intelligence->getLessonDropoffs($filters),
'courseHealth' => $this->intelligence->getCourseHealth($filters),
'premiumInterest' => $this->intelligence->getPremiumInterest($filters),
'editorialRecommendations' => $this->intelligence->getEditorialRecommendations($filters),
]);
}
/**
* @param Collection<int, AcademySearchLog> $logs
* @return list<array<string, int|string>>
*/
private function summarizeSearchFilters(Collection $logs): array
{
$counts = [];
foreach ($logs as $log) {
$filters = is_array($log->filters) ? $log->filters : [];
foreach ($filters as $key => $value) {
if ($value === null || $value === '' || $key === 'q') {
continue;
}
$values = is_array($value) ? $value : [$value];
foreach ($values as $rawValue) {
$label = trim((string) $rawValue);
if ($label === '') {
continue;
}
$bucket = sprintf('%s:%s', $key, $label);
$counts[$bucket] = [
'filter' => (string) $key,
'value' => $label,
'uses' => (int) (($counts[$bucket]['uses'] ?? 0) + 1),
];
}
}
}
usort($counts, static fn (array $left, array $right): int => $right['uses'] <=> $left['uses']);
return array_slice(array_values($counts), 0, 20);
}
public function funnel(Request $request): Response
{
[$from, $to, $range] = $this->resolveDateRange($request);
$summary = $this->metricsQuery($from, $to)
->selectRaw('sum(unique_visitors) as unique_visitors, sum(premium_preview_views) as premium_preview_views, sum(upgrade_clicks) as upgrade_clicks, sum(starts) as starts, sum(completions) as completions')
->first();
$bestConverters = $this->metricsQuery($from, $to)
->selectRaw('content_type, content_id, sum(unique_visitors) as unique_visitors, sum(premium_preview_views) as premium_preview_views, sum(upgrade_clicks) as upgrade_clicks, sum(conversion_score) as conversion_score')
->groupBy('content_type', 'content_id')
->havingRaw('sum(upgrade_clicks) > 0')
->orderByDesc('conversion_score')
->limit(12)
->get();
return Inertia::render('Admin/Academy/AnalyticsFunnel', [
'nav' => $this->nav(),
'range' => $this->rangePayload($range, $from, $to),
'summary' => [
'academyVisitors' => (int) ($summary?->unique_visitors ?? 0),
'premiumPreviewViews' => (int) ($summary?->premium_preview_views ?? 0),
'upgradeClicks' => (int) ($summary?->upgrade_clicks ?? 0),
'starts' => (int) ($summary?->starts ?? 0),
'completions' => (int) ($summary?->completions ?? 0),
'checkoutStarts' => 0,
'subscriptions' => 0,
],
'bestConverters' => $this->serializeContentRows($bestConverters, includeConversion: true),
]);
}
private function renderContentPage(Request $request, ?string $forcedContentType, string $title, string $subtitle): Response
{
[$from, $to, $range] = $this->resolveDateRange($request);
$sort = (string) $request->query('sort', 'popularity_score');
$direction = strtolower((string) $request->query('direction', 'desc')) === 'asc' ? 'asc' : 'desc';
$access = trim((string) $request->query('access', ''));
$contentType = $forcedContentType ?: (trim((string) $request->query('content_type', '')) ?: null);
$query = $this->metricsQuery($from, $to)
->selectRaw('content_type, content_id, sum(views) as views, sum(unique_visitors) as unique_visitors, sum(engaged_views) as engaged_views, sum(likes) as likes, sum(saves) as saves, sum(prompt_copies) as prompt_copies, sum(starts) as starts, sum(completions) as completions, sum(upgrade_clicks) as upgrade_clicks, sum(popularity_score) as popularity_score, sum(conversion_score) as conversion_score')
->groupBy('content_type', 'content_id');
if ($contentType !== null) {
$query->where('content_type', $contentType);
}
$rows = $query->get();
$serializedRows = $this->serializeContentRows($rows, includeConversion: true)
->filter(function (array $row) use ($access): bool {
if ($access === '') {
return true;
}
return strtolower((string) ($row['access_level'] ?? '')) === strtolower($access);
})
->sortBy($sort, SORT_REGULAR, $direction === 'desc')
->values()
->all();
return Inertia::render('Admin/Academy/AnalyticsContent', [
'nav' => $this->nav(),
'range' => $this->rangePayload($range, $from, $to),
'title' => $title,
'subtitle' => $subtitle,
'filters' => [
'sort' => $sort,
'direction' => $direction,
'access' => $access,
'content_type' => $contentType,
],
'rows' => $serializedRows,
'contentTypeOptions' => [
['value' => '', 'label' => 'All content'],
['value' => AcademyAnalyticsContentType::PROMPT, 'label' => 'Prompts'],
['value' => AcademyAnalyticsContentType::LESSON, 'label' => 'Lessons'],
['value' => AcademyAnalyticsContentType::COURSE, 'label' => 'Courses'],
['value' => AcademyAnalyticsContentType::PROMPT_PACK, 'label' => 'Prompt packs'],
['value' => AcademyAnalyticsContentType::CHALLENGE, 'label' => 'Challenges'],
],
'sortOptions' => [
['value' => 'views', 'label' => 'Views'],
['value' => 'unique_visitors', 'label' => 'Unique visitors'],
['value' => 'likes', 'label' => 'Likes'],
['value' => 'saves', 'label' => 'Saves'],
['value' => 'prompt_copies', 'label' => 'Copies'],
['value' => 'completions', 'label' => 'Completions'],
['value' => 'upgrade_clicks', 'label' => 'Upgrade clicks'],
['value' => 'popularity_score', 'label' => 'Popularity score'],
['value' => 'conversion_score', 'label' => 'Conversion score'],
],
]);
}
private function metricsQuery(Carbon $from, Carbon $to)
{
return AcademyContentMetricDaily::query()
->whereBetween('date', [$from->toDateString(), $to->toDateString()]);
}
/**
* @param Collection<int, mixed> $rows
* @return Collection<int, array<string, mixed>>
*/
private function serializeContentRows(Collection $rows, bool $includeConversion = false): Collection
{
return $rows->map(function ($row) use ($includeConversion): array {
$contentType = (string) $row->content_type;
$contentId = $row->content_id ? (int) $row->content_id : null;
$title = $this->resolver->title($contentType, $contentId);
$accessLevel = $this->resolver->accessLevel($contentType, $contentId);
$uniqueVisitors = max(0, (int) ($row->unique_visitors ?? 0));
$promptCopies = max(0, (int) ($row->prompt_copies ?? 0));
$likes = max(0, (int) ($row->likes ?? 0));
$saves = max(0, (int) ($row->saves ?? 0));
$starts = max(0, (int) ($row->starts ?? 0));
$completions = max(0, (int) ($row->completions ?? 0));
$premiumPreviewViews = max(0, (int) ($row->premium_preview_views ?? 0));
$upgradeClicks = max(0, (int) ($row->upgrade_clicks ?? 0));
return [
'content_type' => $contentType,
'content_type_label' => (string) Str::of(str_replace('academy_', '', $contentType))->replace('_', ' ')->headline(),
'content_id' => $contentId,
'title' => $title,
'access_level' => $accessLevel,
'views' => (int) ($row->views ?? 0),
'unique_visitors' => $uniqueVisitors,
'engaged_views' => (int) ($row->engaged_views ?? 0),
'likes' => $likes,
'saves' => $saves,
'prompt_copies' => $promptCopies,
'starts' => $starts,
'completions' => $completions,
'upgrade_clicks' => $upgradeClicks,
'popularity_score' => round((float) ($row->popularity_score ?? 0), 2),
'conversion_score' => round((float) ($row->conversion_score ?? 0), 2),
'copy_rate' => $uniqueVisitors > 0 ? round(($promptCopies / $uniqueVisitors) * 100, 1) : 0,
'save_rate' => $uniqueVisitors > 0 ? round(($saves / $uniqueVisitors) * 100, 1) : 0,
'like_rate' => $uniqueVisitors > 0 ? round(($likes / $uniqueVisitors) * 100, 1) : 0,
'completion_rate' => $starts > 0 ? round(($completions / $starts) * 100, 1) : 0,
'upgrade_rate' => max(1, $premiumPreviewViews) > 0 ? round(($upgradeClicks / max(1, $premiumPreviewViews)) * 100, 1) : 0,
'trend' => ((float) ($row->popularity_score ?? 0)) >= 100 ? 'High momentum' : (((float) ($row->popularity_score ?? 0)) >= 25 ? 'Building' : 'Early'),
'include_conversion' => $includeConversion,
];
});
}
/**
* @return array{0: Carbon, 1: Carbon, 2: string}
*/
private function resolveDateRange(Request $request, string $defaultRange = '7d'): array
{
$range = trim((string) $request->query('range', $defaultRange));
return match ($range) {
'today' => [now()->startOfDay(), now()->endOfDay(), 'today'],
'yesterday' => [now()->subDay()->startOfDay(), now()->subDay()->endOfDay(), 'yesterday'],
'30d' => [now()->subDays(29)->startOfDay(), now()->endOfDay(), '30d'],
'90d' => [now()->subDays(89)->startOfDay(), now()->endOfDay(), '90d'],
'custom' => [
Carbon::parse((string) $request->query('from', now()->subDays(6)->toDateString()))->startOfDay(),
Carbon::parse((string) $request->query('to', now()->toDateString()))->endOfDay(),
'custom',
],
default => [now()->subDays(6)->startOfDay(), now()->endOfDay(), '7d'],
};
}
/**
* @return list<array<string, string|bool>>
*/
private function nav(): array
{
return [
['label' => 'Overview', 'href' => route('admin.academy.analytics.overview')],
['label' => 'Intelligence', 'href' => route('admin.academy.analytics.intelligence')],
['label' => 'Content', 'href' => route('admin.academy.analytics.content')],
['label' => 'Prompts', 'href' => route('admin.academy.analytics.prompts')],
['label' => 'Lessons', 'href' => route('admin.academy.analytics.lessons')],
['label' => 'Courses', 'href' => route('admin.academy.analytics.courses')],
['label' => 'Search', 'href' => route('admin.academy.analytics.search')],
['label' => 'Funnel', 'href' => route('admin.academy.analytics.funnel')],
];
}
/**
* @return array<string, mixed>
*/
private function rangePayload(string $activeRange, Carbon $from, Carbon $to): array
{
return [
'active' => $activeRange,
'from' => $from->toDateString(),
'to' => $to->toDateString(),
'options' => [
['value' => 'today', 'label' => 'Today'],
['value' => 'yesterday', 'label' => 'Yesterday'],
['value' => '7d', 'label' => 'Last 7 days'],
['value' => '30d', 'label' => 'Last 30 days'],
['value' => '90d', 'label' => 'Last 90 days'],
['value' => 'custom', 'label' => 'Custom range'],
],
];
}
}

View File

@@ -19,6 +19,7 @@ use App\Models\AcademyChallenge;
use App\Models\AcademyChallengeSubmission;
use App\Models\AcademyCourse;
use App\Models\AcademyCourseLesson;
use App\Models\AcademyCourseSection;
use App\Models\AcademyLesson;
use App\Models\AcademyLessonBlock;
use App\Models\AcademyLessonRevision;
@@ -26,6 +27,7 @@ use App\Models\AcademyPromptPack;
use App\Models\AcademyPromptPackItem;
use App\Models\AcademyPromptTemplate;
use App\Models\User;
use App\Services\Academy\AcademyAdminBillingOverviewService;
use App\Services\Academy\AcademyCacheService;
use App\Services\Academy\AcademyCourseLessonOrderingService;
use App\Services\Academy\AcademyLessonMarkdownRenderer;
@@ -38,6 +40,7 @@ use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
use Inertia\Response;
@@ -48,7 +51,13 @@ final class AcademyAdminController extends Controller
private const PROMPT_PREVIEW_PREFIX = 'academy-prompts/previews';
private const PROMPT_PREVIEW_VARIANT_WIDTHS = [
'thumb' => 480,
'md' => 960,
];
public function __construct(
private readonly AcademyAdminBillingOverviewService $billingOverview,
private readonly AcademyCacheService $cache,
private readonly AcademyCourseLessonOrderingService $courseLessonOrdering,
private readonly AcademyLessonMarkdownRenderer $lessonMarkdownRenderer,
@@ -56,6 +65,8 @@ final class AcademyAdminController extends Controller
public function dashboard(): Response
{
$billingSummary = $this->billingOverview->summary();
return Inertia::render('Admin/Academy/Dashboard', [
'stats' => [
'courses' => AcademyCourse::query()->count(),
@@ -65,11 +76,13 @@ final class AcademyAdminController extends Controller
'challenges' => AcademyChallenge::query()->count(),
'submissions' => AcademyChallengeSubmission::query()->count(),
'badges' => AcademyBadge::query()->count(),
'creator_subscribers' => 0,
'pro_subscribers' => 0,
'mrr' => 0,
'active_subscribers' => (int) ($billingSummary['active_subscribers'] ?? 0),
'creator_subscribers' => (int) ($billingSummary['creator_subscribers'] ?? 0),
'pro_subscribers' => (int) ($billingSummary['pro_subscribers'] ?? 0),
'grace_period_subscribers' => (int) ($billingSummary['grace_period_subscribers'] ?? 0),
],
'links' => [
'billing' => route('admin.academy.billing'),
'courses' => route('admin.academy.courses.index'),
'categories' => route('admin.academy.categories.index'),
'lessons' => route('admin.academy.lessons.index'),
@@ -83,6 +96,22 @@ final class AcademyAdminController extends Controller
]);
}
public function billing(): Response
{
$summary = $this->billingOverview->summary();
return Inertia::render('Admin/Academy/Billing', [
'summary' => $summary,
'planBreakdown' => $summary['plan_breakdown'] ?? [],
'recentEvents' => $this->billingOverview->recentEvents(),
'links' => [
'dashboard' => route('admin.academy.dashboard'),
'pricing' => route('academy.pricing'),
'account' => route('academy.billing.account'),
],
]);
}
public function categoriesIndex(): Response
{
return $this->renderIndex('categories');
@@ -100,13 +129,20 @@ final class AcademyAdminController extends Controller
public function coursesStore(UpsertAcademyCourseRequest $request): RedirectResponse
{
$course = new AcademyCourse;
$course->fill($this->persistCourseAttributes($request))->save();
$course = $this->saveCourseFromRequest($request);
$this->cache->clearAll();
return redirect()->route('admin.academy.courses.edit', ['academyCourse' => $course])->with('success', 'Academy course created.');
}
public function coursesStoreJson(UpsertAcademyCourseRequest $request): RedirectResponse
{
$course = $this->saveCourseFromRequest($request);
$this->cache->clearAll();
return redirect()->route('admin.academy.courses.edit', ['academyCourse' => $course])->with('success', 'Academy course created from JSON.');
}
public function coursesEdit(AcademyCourse $academyCourse): Response
{
return $this->renderForm('courses', $academyCourse);
@@ -114,12 +150,94 @@ final class AcademyAdminController extends Controller
public function coursesUpdate(UpsertAcademyCourseRequest $request, AcademyCourse $academyCourse): RedirectResponse
{
$academyCourse->fill($this->persistCourseAttributes($request, $academyCourse))->save();
$this->saveCourseFromRequest($request, $academyCourse);
$this->cache->clearAll();
return redirect()->route('admin.academy.courses.edit', ['academyCourse' => $academyCourse])->with('success', 'Academy course updated.');
}
public function coursesImportLessons(Request $request, AcademyCourse $academyCourse): RedirectResponse
{
$difficultyLevels = array_values(array_filter(array_map('strval', (array) config('academy.difficulty_levels', []))));
$validated = $request->validate([
'defaults' => ['nullable', 'array'],
'defaults.category_id' => ['nullable', 'integer', 'exists:academy_categories,id'],
'defaults.category_slug' => ['nullable', 'string', 'max:180'],
'defaults.category' => ['nullable', 'string', 'max:180'],
'defaults.difficulty' => ['nullable', 'string', Rule::in($difficultyLevels)],
'defaults.access_level' => ['nullable', 'string', Rule::in(['free', 'creator', 'pro'])],
'defaults.lesson_type' => ['nullable', 'string', 'max:80'],
'defaults.active' => ['nullable', 'boolean'],
'defaults.series_name' => ['nullable', 'string', 'max:120'],
'lessons' => ['required', 'array', 'min:1', 'max:250'],
'lessons.*.title' => ['required', 'string', 'max:180'],
'lessons.*.slug' => ['nullable', 'string', 'max:180'],
'lessons.*.goal' => ['nullable', 'string'],
'lessons.*.excerpt' => ['nullable', 'string'],
'lessons.*.category_id' => ['nullable', 'integer', 'exists:academy_categories,id'],
'lessons.*.category_slug' => ['nullable', 'string', 'max:180'],
'lessons.*.category' => ['nullable', 'string', 'max:180'],
'lessons.*.difficulty' => ['nullable', 'string', Rule::in($difficultyLevels)],
'lessons.*.access_level' => ['nullable', 'string', Rule::in(['free', 'creator', 'pro'])],
'lessons.*.lesson_type' => ['nullable', 'string', 'max:80'],
'lessons.*.active' => ['nullable', 'boolean'],
'lessons.*.series_name' => ['nullable', 'string', 'max:120'],
'lessons.*.tags' => ['nullable', 'array'],
'lessons.*.tags.*' => ['string', 'max:60'],
]);
$defaults = (array) ($validated['defaults'] ?? []);
$lessons = array_values((array) ($validated['lessons'] ?? []));
if ($lessons === []) {
throw ValidationException::withMessages([
'lessons' => 'Provide at least one lesson row to import.',
]);
}
DB::transaction(function () use ($academyCourse, $defaults, $lessons): void {
$reservedSlugs = AcademyLesson::query()
->pluck('slug')
->filter(fn ($slug): bool => is_string($slug) && trim($slug) !== '')
->map(fn ($slug): string => trim((string) $slug))
->values()
->all();
$nextOrder = (int) ((AcademyCourseLesson::query()->where('course_id', $academyCourse->id)->max('order_num') ?? -1) + 1);
foreach ($lessons as $lessonData) {
$attributes = $this->buildImportedCourseLessonAttributes($academyCourse, (array) $lessonData, $defaults, $reservedSlugs);
$lesson = new AcademyLesson;
$lesson->fill($attributes)->save();
AcademyCourseLesson::query()->create([
'course_id' => $academyCourse->id,
'lesson_id' => $lesson->id,
'section_id' => null,
'order_num' => $nextOrder,
'is_required' => true,
'access_override' => null,
'unlock_after_lesson_id' => null,
]);
$nextOrder++;
}
$this->courseLessonOrdering->syncCourse($academyCourse);
$academyCourse->forceFill([
'lessons_count_cache' => (int) AcademyCourseLesson::query()->where('course_id', $academyCourse->id)->count(),
])->save();
});
$this->cache->clearAll();
return redirect()->route('admin.academy.courses.edit', ['academyCourse' => $academyCourse])
->with('success', sprintf('%d lesson%s imported into the course.', count($lessons), count($lessons) === 1 ? '' : 's'));
}
public function coursesDestroy(AcademyCourse $academyCourse): RedirectResponse
{
$this->deleteStoredLessonCoverIfLocal((string) $academyCourse->cover_image);
@@ -484,12 +602,40 @@ final class AcademyAdminController extends Controller
private function renderIndex(string $resource): Response
{
$meta = $this->resourceMeta($resource);
$query = $meta['model']::query()->latest('updated_at');
$search = trim((string) request()->query('search', ''));
$query = $meta['model']::query();
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);
});
}
$query->orderByDesc('is_featured')
->orderBy('order_num')
->orderByDesc('updated_at')
->orderByDesc('id');
} else {
$query->latest('updated_at');
}
if ($resource === 'prompts') {
$query->with('category');
}
if ($resource === 'lessons') {
$query->with('courses:id,title');
}
$items = $query->paginate(25)->withQueryString();
$items->getCollection()->transform(fn (Model $model): array => $this->serializeIndexItem($resource, $model));
@@ -500,6 +646,45 @@ final class AcademyAdminController extends Controller
'items' => $items,
'columns' => $meta['columns'],
'createUrl' => route($meta['route_base'].'.create'),
'filters' => [
'search' => $search,
],
'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,
]);
}
@@ -538,6 +723,9 @@ final class AcademyAdminController extends Controller
'outlineSummary' => $record instanceof AcademyCourse && $record->exists
? $this->serializeCourseOutlineSummary($record)
: null,
'courseSections' => $record instanceof AcademyCourse && $record->exists
? $this->serializeCourseEditorSections($record)
: [],
'courseLessons' => $record instanceof AcademyCourse && $record->exists
? $this->serializeCourseEditorLessons($record)
: [],
@@ -547,9 +735,19 @@ final class AcademyAdminController extends Controller
'attachLessonUrl' => $record instanceof AcademyCourse && $record->exists
? route('admin.academy.courses.lessons.attach', ['academyCourse' => $record])
: null,
'importLessonsUrl' => $record instanceof AcademyCourse && $record->exists
? route('admin.academy.courses.lessons.import', ['academyCourse' => $record])
: null,
'sectionStoreUrl' => $record instanceof AcademyCourse && $record->exists
? route('admin.academy.courses.sections.store', ['academyCourse' => $record])
: null,
'reorderUrl' => $record instanceof AcademyCourse && $record->exists
? route('admin.academy.courses.reorder', ['academyCourse' => $record])
: null,
'courseImportUrl' => $record instanceof AcademyCourse && ! $record->exists
? route('admin.academy.courses.import-json')
: null,
'lessonCategoryOptions' => $this->categoriesForEditor('lesson'),
];
}
@@ -656,7 +854,7 @@ final class AcademyAdminController extends Controller
'singular' => 'lesson',
'subtitle' => 'Create and publish Academy lessons.',
'route_base' => 'admin.academy.lessons',
'columns' => ['title', 'difficulty', 'access_level', 'featured', 'active'],
'columns' => ['title', 'course_names', 'course_order', 'difficulty', 'access_level', 'active'],
'fields' => [
['name' => 'category_id', 'label' => 'Category', 'type' => 'select', 'options' => $this->categoryOptions('lesson')],
['name' => 'course_ids', 'label' => 'Courses', 'type' => 'multiselect', 'options' => $this->courseOptions()],
@@ -694,6 +892,10 @@ final class AcademyAdminController extends Controller
['name' => 'negative_prompt', 'label' => 'Negative Prompt', 'type' => 'textarea'],
['name' => 'usage_notes', 'label' => 'Usage Notes', 'type' => 'textarea'],
['name' => 'workflow_notes', 'label' => 'Workflow Notes', 'type' => 'textarea'],
['name' => 'documentation', 'label' => 'Documentation JSON', 'type' => 'json'],
['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' => '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'],
@@ -785,10 +987,17 @@ final class AcademyAdminController extends Controller
'courses' => [
'id' => (int) $model->id,
'title' => (string) $model->title,
'slug' => (string) $model->slug,
'subtitle' => (string) ($model->subtitle ?? ''),
'excerpt' => (string) ($model->excerpt ?? ''),
'cover_image_url' => $this->resolveLessonCoverImageUrl((string) ($model->cover_image ?? '')),
'lessons_count' => (int) ($model->lessons_count_cache ?? $model->course_lessons_count ?? 0),
'difficulty' => (string) $model->difficulty,
'access_level' => (string) $model->access_level,
'status' => (string) $model->status,
'is_featured' => (bool) $model->is_featured,
'published_at' => optional($model->published_at)->toIso8601String(),
'updated_at' => optional($model->updated_at)->toIso8601String(),
'edit_url' => route('admin.academy.courses.edit', ['academyCourse' => $model]),
'destroy_url' => route('admin.academy.courses.destroy', ['academyCourse' => $model]),
'builder_url' => route('admin.academy.courses.builder.edit', ['academyCourse' => $model]),
@@ -805,6 +1014,8 @@ final class AcademyAdminController extends Controller
'lessons' => [
'id' => (int) $model->id,
'title' => (string) $model->title,
'course_names' => $model->courses->pluck('title')->filter()->values()->all(),
'course_order' => $model->course_order,
'difficulty' => (string) $model->difficulty,
'access_level' => (string) $model->access_level,
'featured' => (bool) $model->featured,
@@ -941,6 +1152,10 @@ final class AcademyAdminController extends Controller
'negative_prompt' => (string) ($record->negative_prompt ?? ''),
'usage_notes' => (string) ($record->usage_notes ?? ''),
'workflow_notes' => (string) ($record->workflow_notes ?? ''),
'documentation' => $this->encodePrettyJsonForForm($record->documentation),
'placeholders' => $this->encodePrettyJsonForForm($record->placeholders),
'helper_prompts' => $this->encodePrettyJsonForForm($record->helper_prompts),
'prompt_variants' => $this->encodePrettyJsonForForm($record->prompt_variants),
'difficulty' => (string) ($record->difficulty ?? 'beginner'),
'access_level' => (string) ($record->access_level ?? 'free'),
'aspect_ratio' => (string) ($record->aspect_ratio ?? ''),
@@ -1464,9 +1679,46 @@ final class AcademyAdminController extends Controller
return $validated;
}
private function saveCourseFromRequest(UpsertAcademyCourseRequest $request, ?AcademyCourse $course = null): AcademyCourse
{
$course ??= new AcademyCourse;
$course->fill($this->persistCourseAttributes($request, $course))->save();
return $course;
}
/**
* @return array<string, mixed>
*/
/**
* @return array<int, array<string, mixed>>
*/
private function serializeCourseEditorSections(AcademyCourse $course): array
{
$course->loadMissing(['sections']);
return $course->sections
->sortBy([['order_num', 'asc'], ['id', 'asc']])
->values()
->map(fn (AcademyCourseSection $section): array => [
'id' => (int) $section->id,
'title' => (string) $section->title,
'slug' => (string) ($section->slug ?? ''),
'description' => (string) ($section->description ?? ''),
'order_num' => (int) ($section->order_num ?? 0),
'is_visible' => (bool) ($section->is_visible ?? true),
'update_url' => route('admin.academy.courses.sections.update', [
'academyCourse' => $course,
'academyCourseSection' => $section,
]),
'destroy_url' => route('admin.academy.courses.sections.destroy', [
'academyCourse' => $course,
'academyCourseSection' => $section,
]),
])
->all();
}
/**
* @return array<int, array<string, mixed>>
*/
@@ -1479,12 +1731,17 @@ final class AcademyAdminController extends Controller
->values()
->map(function (AcademyCourseLesson $courseLesson) use ($course): array {
$lesson = $courseLesson->lesson;
$publicationMeta = $this->serializeLessonPublicationMeta($lesson instanceof AcademyLesson ? $lesson : null);
return [
return array_merge([
'id' => (int) $courseLesson->id,
'lesson_id' => (int) $courseLesson->lesson_id,
'title' => (string) ($lesson?->title ?? 'Untitled lesson'),
'slug' => (string) ($lesson?->slug ?? ''),
'cover_image' => (string) ($lesson?->cover_image ?? ''),
'cover_image_url' => $lesson instanceof AcademyLesson
? $this->resolveLessonCoverImageUrl((string) ($lesson->cover_image ?: $lesson->article_cover_image ?? ''))
: null,
'section_id' => $courseLesson->section_id ? (int) $courseLesson->section_id : null,
'section_title' => (string) ($courseLesson->section?->title ?? ''),
'order_num' => (int) ($courseLesson->order_num ?? 0),
@@ -1493,6 +1750,7 @@ final class AcademyAdminController extends Controller
'is_required' => (bool) $courseLesson->is_required,
'difficulty' => (string) ($lesson?->difficulty ?? ''),
'access_level' => (string) ($lesson?->access_level ?? ''),
'active' => (bool) ($lesson?->active ?? false),
'destroy_url' => route('admin.academy.courses.lessons.destroy', [
'academyCourse' => $course,
'academyCourseLesson' => $courseLesson,
@@ -1500,42 +1758,208 @@ final class AcademyAdminController extends Controller
'edit_url' => $lesson instanceof AcademyLesson
? route('admin.academy.lessons.edit', ['academyLesson' => $lesson])
: null,
];
], $publicationMeta);
})
->all();
}
/**
* @param array<string, mixed> $lessonData
* @param array<string, mixed> $defaults
* @param array<int, string> $reservedSlugs
* @return array<string, mixed>
*/
private function buildImportedCourseLessonAttributes(AcademyCourse $course, array $lessonData, array $defaults, array &$reservedSlugs): array
{
$title = trim((string) ($lessonData['title'] ?? ''));
$slugSource = $this->nullableTrimmedString($lessonData['slug'] ?? null) ?? $title;
$excerpt = $this->nullableTrimmedString($lessonData['excerpt'] ?? null)
?? $this->nullableTrimmedString($lessonData['goal'] ?? null);
$difficulty = $this->nullableTrimmedString($lessonData['difficulty'] ?? null)
?? $this->nullableTrimmedString($defaults['difficulty'] ?? null)
?? $this->nullableTrimmedString($course->difficulty)
?? 'beginner';
$accessLevel = $this->nullableTrimmedString($lessonData['access_level'] ?? null)
?? $this->nullableTrimmedString($defaults['access_level'] ?? null)
?? 'free';
$lessonType = $this->nullableTrimmedString($lessonData['lesson_type'] ?? null)
?? $this->nullableTrimmedString($defaults['lesson_type'] ?? null)
?? 'article';
$seriesName = $this->nullableTrimmedString($lessonData['series_name'] ?? null)
?? $this->nullableTrimmedString($defaults['series_name'] ?? null)
?? $this->nullableTrimmedString($course->title);
$active = array_key_exists('active', $lessonData)
? (bool) $lessonData['active']
: (array_key_exists('active', $defaults) ? (bool) $defaults['active'] : false);
return [
'category_id' => $this->resolveImportedLessonCategoryId($lessonData, $defaults),
'title' => $title,
'slug' => $this->reserveImportedLessonSlug($slugSource, $reservedSlugs),
'lesson_number' => null,
'course_order' => null,
'series_name' => $seriesName,
'excerpt' => $excerpt,
'content' => null,
'content_markdown' => null,
'difficulty' => $difficulty,
'access_level' => $accessLevel,
'lesson_type' => $lessonType,
'cover_image' => null,
'article_cover_image' => null,
'tags' => collect((array) ($lessonData['tags'] ?? []))
->map(fn ($tag): string => trim((string) $tag))
->filter(fn (string $tag): bool => $tag !== '')
->values()
->all(),
'video_url' => null,
'reading_minutes' => 5,
'featured' => false,
'active' => $active,
'published_at' => null,
'seo_title' => null,
'seo_description' => $excerpt,
];
}
/**
* @param array<string, mixed> $lessonData
* @param array<string, mixed> $defaults
*/
private function resolveImportedLessonCategoryId(array $lessonData, array $defaults): ?int
{
foreach ([$lessonData, $defaults] as $source) {
if ($source === []) {
continue;
}
$categoryId = $source['category_id'] ?? null;
if ($categoryId !== null && AcademyCategory::query()->where('type', 'lesson')->whereKey((int) $categoryId)->exists()) {
return (int) $categoryId;
}
$categorySlug = $this->nullableTrimmedString($source['category_slug'] ?? null);
if ($categorySlug !== null) {
$category = AcademyCategory::query()->where('type', 'lesson')->where('slug', $categorySlug)->first();
if ($category instanceof AcademyCategory) {
return (int) $category->id;
}
}
$categoryName = $this->nullableTrimmedString($source['category'] ?? null);
if ($categoryName !== null) {
$category = AcademyCategory::query()->where('type', 'lesson')->whereRaw('lower(name) = ?', [Str::lower($categoryName)])->first();
if ($category instanceof AcademyCategory) {
return (int) $category->id;
}
}
}
return null;
}
/**
* @param array<int, string> $reservedSlugs
*/
private function reserveImportedLessonSlug(string $source, array &$reservedSlugs): string
{
$base = Str::slug($source);
if ($base === '') {
$base = 'academy-lesson';
}
$candidate = $base;
$suffix = 2;
while (in_array($candidate, $reservedSlugs, true)) {
$candidate = $base.'-'.$suffix;
$suffix++;
}
$reservedSlugs[] = $candidate;
return $candidate;
}
/**
* @return array<int, array<string, mixed>>
*/
private function categoriesForEditor(string $type): array
{
return AcademyCategory::query()
->where('type', $type)
->orderBy('order_num')
->orderBy('name')
->get()
->map(fn (AcademyCategory $category): array => $this->serializeCategoryOption($category))
->values()
->all();
}
/**
* @return array<int, array<string, mixed>>
*/
private function serializeCourseAvailableLessons(AcademyCourse $course): array
{
$course->loadMissing(['courseLessons']);
$attachedLessonIds = $course->courseLessons
->pluck('lesson_id')
->map(fn ($id): int => (int) $id)
->flip()
->all();
return AcademyLesson::query()
->whereDoesntHave('courseLessons')
->with('category')
->orderBy('title')
->get()
->map(fn (AcademyLesson $lesson): array => [
'id' => (int) $lesson->id,
'title' => (string) $lesson->title,
'slug' => (string) $lesson->slug,
'difficulty' => (string) $lesson->difficulty,
'access_level' => (string) $lesson->access_level,
'active' => (bool) $lesson->active,
'category' => $lesson->category ? (string) $lesson->category->name : '',
'attached' => isset($attachedLessonIds[(int) $lesson->id]),
])
->map(function (AcademyLesson $lesson): array {
$publicationMeta = $this->serializeLessonPublicationMeta($lesson);
return array_merge([
'id' => (int) $lesson->id,
'title' => (string) $lesson->title,
'slug' => (string) $lesson->slug,
'cover_image' => (string) ($lesson->cover_image ?? ''),
'cover_image_url' => $this->resolveLessonCoverImageUrl((string) ($lesson->cover_image ?: $lesson->article_cover_image ?? '')),
'difficulty' => (string) $lesson->difficulty,
'access_level' => (string) $lesson->access_level,
'active' => (bool) $lesson->active,
'category' => $lesson->category ? (string) $lesson->category->name : '',
'edit_url' => route('admin.academy.lessons.edit', ['academyLesson' => $lesson]),
'attached' => false,
], $publicationMeta);
})
->values()
->all();
}
/**
* @return array<string, string|null>
*/
private function serializeLessonPublicationMeta(?AcademyLesson $lesson): array
{
$publishedAt = $lesson?->published_at instanceof Carbon
? $lesson->published_at->copy()
: null;
if (! $publishedAt) {
return [
'published_at' => null,
'publication_state' => 'draft',
'publication_label' => 'Unscheduled',
];
}
if ($publishedAt->isFuture()) {
return [
'published_at' => $publishedAt->toIso8601String(),
'publication_state' => 'scheduled',
'publication_label' => 'Publishes '.$publishedAt->format('Y-m-d H:i'),
];
}
return [
'published_at' => $publishedAt->toIso8601String(),
'publication_state' => 'published',
'publication_label' => 'Published',
];
}
private function serializeCourseOutlineSummary(AcademyCourse $course): array
{
$course->loadMissing(['sections', 'courseLessons']);
@@ -1734,6 +2158,10 @@ final class AcademyAdminController extends Controller
$validated['category_id'] = $this->resolveOrCreatePromptCategoryId($newCategoryName);
}
$validated['documentation'] = $this->normalizePromptDocumentation($validated['documentation'] ?? null);
$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['tool_notes'] = $this->normalizePromptToolNotes((array) ($validated['tool_notes'] ?? []));
$previousToolNotes = $this->normalizePromptToolNotes((array) ($prompt?->tool_notes ?? []));
@@ -1803,6 +2231,172 @@ final class AcademyAdminController extends Controller
->all();
}
private function encodePrettyJsonForForm(mixed $value): string
{
if ($value === null || $value === [] || $value === '') {
return '';
}
return (string) json_encode($value, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
}
/**
* @return array<string, mixed>|null
*/
private function normalizePromptDocumentation(mixed $documentation): ?array
{
if (! is_array($documentation)) {
return null;
}
$listFields = ['best_for', 'how_to_use', 'required_inputs', 'workflow', 'tips', 'common_mistakes', 'data_accuracy_notes'];
$normalized = [
'summary' => $this->nullableTrimmedString($documentation['summary'] ?? null),
'display_notes' => $this->nullableTrimmedString($documentation['display_notes'] ?? null),
];
foreach ($listFields as $field) {
$normalized[$field] = $this->normalizePromptStringList($documentation[$field] ?? []);
}
$hasContent = $normalized['summary'] !== null
|| $normalized['display_notes'] !== null
|| collect($listFields)->contains(fn (string $field): bool => $normalized[$field] !== []);
return $hasContent ? $normalized : null;
}
/**
* @return array<int, array<string, mixed>>
*/
private function normalizePromptPlaceholders(mixed $placeholders): array
{
if (! is_array($placeholders)) {
return [];
}
return collect($placeholders)
->filter(static fn ($placeholder): bool => is_array($placeholder))
->map(function (array $placeholder): array {
return [
'key' => $this->nullableTrimmedString($placeholder['key'] ?? null),
'label' => $this->nullableTrimmedString($placeholder['label'] ?? null),
'description' => $this->nullableTrimmedString($placeholder['description'] ?? null),
'required' => filter_var($placeholder['required'] ?? false, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE) ?? false,
'example' => $this->normalizePromptJsonValue($placeholder['example'] ?? null),
'default' => $this->normalizePromptJsonValue($placeholder['default'] ?? null),
'type' => $this->nullableTrimmedString($placeholder['type'] ?? null),
];
})
->filter(function (array $placeholder): bool {
return collect([
$placeholder['key'] ?? null,
$placeholder['label'] ?? null,
$placeholder['description'] ?? null,
$placeholder['example'] ?? null,
$placeholder['default'] ?? null,
$placeholder['type'] ?? null,
])->contains(fn ($item): bool => $item !== null && $item !== '' && $item !== []);
})
->values()
->all();
}
/**
* @return array<int, array<string, mixed>>
*/
private function normalizePromptHelperPrompts(mixed $helperPrompts): array
{
if (! is_array($helperPrompts)) {
return [];
}
return collect($helperPrompts)
->filter(static fn ($helperPrompt): bool => is_array($helperPrompt))
->map(function (array $helperPrompt): array {
return [
'title' => $this->nullableTrimmedString($helperPrompt['title'] ?? null),
'type' => $this->nullableTrimmedString($helperPrompt['type'] ?? null) ?? 'other',
'description' => $this->nullableTrimmedString($helperPrompt['description'] ?? null),
'prompt' => $this->nullableTrimmedString($helperPrompt['prompt'] ?? null),
'expected_output' => $this->nullableTrimmedString($helperPrompt['expected_output'] ?? null) ?? 'text',
'active' => filter_var($helperPrompt['active'] ?? true, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE) ?? true,
];
})
->filter(function (array $helperPrompt): bool {
return collect([
$helperPrompt['title'] ?? null,
$helperPrompt['description'] ?? null,
$helperPrompt['prompt'] ?? null,
])->contains(fn ($item): bool => $item !== null && $item !== '');
})
->values()
->all();
}
/**
* @return array<int, array<string, mixed>>
*/
private function normalizePromptVariants(mixed $variants): array
{
if (! is_array($variants)) {
return [];
}
return collect($variants)
->filter(static fn ($variant): bool => is_array($variant))
->map(function (array $variant): array {
return [
'title' => $this->nullableTrimmedString($variant['title'] ?? null),
'slug' => $this->nullableTrimmedString($variant['slug'] ?? null),
'description' => $this->nullableTrimmedString($variant['description'] ?? null),
'prompt' => $this->nullableTrimmedString($variant['prompt'] ?? null),
'negative_prompt' => $this->nullableTrimmedString($variant['negative_prompt'] ?? null),
'recommended' => filter_var($variant['recommended'] ?? false, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE) ?? false,
'recommended_for' => $this->normalizePromptStringList($variant['recommended_for'] ?? []),
'risk_notes' => $this->normalizePromptStringList($variant['risk_notes'] ?? []),
'active' => filter_var($variant['active'] ?? true, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE) ?? true,
];
})
->filter(function (array $variant): bool {
return collect([
$variant['title'] ?? null,
$variant['description'] ?? null,
$variant['prompt'] ?? null,
$variant['negative_prompt'] ?? null,
])->contains(fn ($item): bool => $item !== null && $item !== '');
})
->values()
->all();
}
/**
* @return array<int, string>
*/
private function normalizePromptStringList(mixed $value): array
{
if (! is_array($value)) {
$value = $value === null ? [] : [$value];
}
return collect($value)
->map(fn ($item): string => trim((string) $item))
->filter(static fn (string $item): bool => $item !== '')
->values()
->all();
}
private function normalizePromptJsonValue(mixed $value): mixed
{
if (! is_string($value)) {
return $value;
}
$trimmed = trim($value);
return $trimmed !== '' ? $trimmed : null;
}
/**
* @param array<int, mixed> $notes
* @return array<int, array<string, string>>
@@ -1966,6 +2560,23 @@ final class AcademyAdminController extends Controller
$storedPath = self::PROMPT_PREVIEW_PREFIX.'/'.pathinfo(Str::replace('\\', '/', $file->hashName()), PATHINFO_FILENAME).'.webp';
Storage::disk($this->promptPreviewImageDisk())->put($storedPath, $webpBinary, ['visibility' => 'public']);
$sourceWidth = imagesx($image);
$sourceHeight = imagesy($image);
foreach (self::PROMPT_PREVIEW_VARIANT_WIDTHS as $variant => $targetWidth) {
$variantBinary = $this->encodePromptPreviewVariant($image, $targetWidth, $sourceWidth, $sourceHeight);
if ($variantBinary === null) {
continue;
}
Storage::disk($this->promptPreviewImageDisk())->put(
$this->promptPreviewVariantPath($storedPath, $variant),
$variantBinary,
['visibility' => 'public']
);
}
} finally {
imagedestroy($image);
}
@@ -1973,6 +2584,62 @@ final class AcademyAdminController extends Controller
return $storedPath;
}
private function encodePromptPreviewVariant(\GdImage $source, int $targetWidth, int $sourceWidth, int $sourceHeight): ?string
{
if ($sourceWidth <= $targetWidth || $sourceWidth < 1 || $sourceHeight < 1) {
return null;
}
$targetHeight = max(1, (int) round(($sourceHeight / $sourceWidth) * $targetWidth));
$variant = imagecreatetruecolor($targetWidth, $targetHeight);
if (! $variant instanceof \GdImage) {
throw ValidationException::withMessages([
'preview_image_file' => 'The uploaded preview image could not be resized. Please try a different image.',
]);
}
imagealphablending($variant, false);
imagesavealpha($variant, true);
$transparent = imagecolorallocatealpha($variant, 0, 0, 0, 127);
imagefilledrectangle($variant, 0, 0, $targetWidth, $targetHeight, $transparent);
imagecopyresampled($variant, $source, 0, 0, 0, 0, $targetWidth, $targetHeight, $sourceWidth, $sourceHeight);
try {
ob_start();
$converted = imagewebp($variant, null, self::PROMPT_PREVIEW_WEBP_QUALITY);
$webpBinary = ob_get_clean();
if (! $converted || ! is_string($webpBinary) || $webpBinary === '') {
throw ValidationException::withMessages([
'preview_image_file' => 'The uploaded preview image could not be converted to WebP. Please try a different image.',
]);
}
return $webpBinary;
} finally {
imagedestroy($variant);
}
}
private function promptPreviewVariantPath(string $path, string $variant): string
{
$directory = pathinfo($path, PATHINFO_DIRNAME);
$filename = pathinfo($path, PATHINFO_FILENAME);
$baseFilename = preg_replace('/-(thumb|md)$/', '', $filename) ?? $filename;
return sprintf('%s/%s-%s.webp', $directory, $baseFilename, $variant);
}
private function canonicalPromptPreviewPath(string $path): string
{
$directory = pathinfo($path, PATHINFO_DIRNAME);
$filename = pathinfo($path, PATHINFO_FILENAME);
$baseFilename = preg_replace('/-(thumb|md)$/', '', $filename) ?? $filename;
return sprintf('%s/%s.webp', $directory, $baseFilename);
}
private function deleteStoredPromptPreviewIfLocal(?string $path): void
{
$path = trim((string) $path);
@@ -1985,10 +2652,14 @@ final class AcademyAdminController extends Controller
}
$disk = $this->promptPreviewImageDisk();
$basePath = $this->canonicalPromptPreviewPath($path);
$paths = [
$basePath,
$this->promptPreviewVariantPath($basePath, 'thumb'),
$this->promptPreviewVariantPath($basePath, 'md'),
];
if (Storage::disk($disk)->exists($path)) {
Storage::disk($disk)->delete($path);
}
Storage::disk($disk)->delete(array_values(array_unique($paths)));
}
private function promptPreviewImageUploadErrorMessage(UploadedFile $file): string

View File

@@ -26,6 +26,11 @@ final class AcademyLessonMediaApiController extends Controller
private const ASSET_CACHE_TTL_MINUTES = 15;
private const RESPONSIVE_VARIANT_WIDTHS = [
'thumb' => 480,
'md' => 960,
];
private ?ImageManager $manager = null;
public function __construct()
@@ -68,6 +73,18 @@ final class AcademyLessonMediaApiController extends Controller
'slot' => $slot,
'path' => $stored['path'],
'url' => $this->publicUrlForPath($stored['path']),
'thumb_path' => $stored['thumb_path'],
'thumb_url' => $this->publicUrlForPath($stored['thumb_path']),
'thumb_width' => $stored['thumb_width'],
'thumb_height' => $stored['thumb_height'],
'medium_path' => $stored['medium_path'],
'medium_url' => $stored['medium_path'] !== '' ? $this->publicUrlForPath($stored['medium_path']) : null,
'medium_width' => $stored['medium_width'],
'medium_height' => $stored['medium_height'],
'srcset' => $this->buildResponsiveSrcset([
['path' => $stored['thumb_path'], 'width' => $stored['thumb_width']],
['path' => $stored['medium_path'], 'width' => $stored['medium_width']],
]),
'width' => $stored['width'],
'height' => $stored['height'],
'mime_type' => 'image/webp',
@@ -161,7 +178,7 @@ final class AcademyLessonMediaApiController extends Controller
}
/**
* @return array{path:string,width:int,height:int,size_bytes:int}
* @return array{path:string,thumb_path:string,thumb_width:int,thumb_height:int,medium_path:string,medium_width:int|null,medium_height:int|null,width:int,height:int,size_bytes:int}
*/
private function storeMediaFile(UploadedFile $file, string $slot): array
{
@@ -202,14 +219,99 @@ final class AcademyLessonMediaApiController extends Controller
));
}
$image = $this->manager->read($raw)->scaleDown(width: $constraints['max_width'], height: $constraints['max_height']);
$encoded = (string) $image->encode(new WebpEncoder(85));
$encodedImage = $this->encodeScaledMedia($raw, $constraints['max_width'], $constraints['max_height']);
$encoded = $encodedImage['binary'];
$hash = hash('sha256', $encoded);
$path = $this->mediaPath($hash, $slot);
$disk = Storage::disk($this->mediaDiskName());
$written = $disk->put($path, $encoded, [
$this->writeMediaBinary($disk, $path, $encoded);
$thumbVariant = $this->storeResponsiveVariant(
$disk,
$raw,
$constraints,
$path,
'thumb',
self::RESPONSIVE_VARIANT_WIDTHS['thumb'],
$encodedImage['width'],
$encodedImage['height'],
);
$mediumVariant = $this->storeResponsiveVariant(
$disk,
$raw,
$constraints,
$path,
'md',
self::RESPONSIVE_VARIANT_WIDTHS['md'],
$encodedImage['width'],
$encodedImage['height'],
);
return [
'path' => $path,
'thumb_path' => $thumbVariant['path'] ?? $path,
'thumb_width' => $thumbVariant['width'] ?? $encodedImage['width'],
'thumb_height' => $thumbVariant['height'] ?? $encodedImage['height'],
'medium_path' => $mediumVariant['path'] ?? '',
'medium_width' => $mediumVariant['width'] ?? null,
'medium_height' => $mediumVariant['height'] ?? null,
'width' => $encodedImage['width'],
'height' => $encodedImage['height'],
'size_bytes' => strlen($encoded),
];
}
/**
* @return array{binary:string,width:int,height:int}
*/
private function encodeScaledMedia(string $raw, int $maxWidth, int $maxHeight): array
{
$image = $this->manager->read($raw)->scaleDown(width: $maxWidth, height: $maxHeight);
$encoded = (string) $image->encode(new WebpEncoder(85));
if ($encoded === '') {
throw new RuntimeException('Unable to encode image to WebP.');
}
return [
'binary' => $encoded,
'width' => (int) $image->width(),
'height' => (int) $image->height(),
];
}
/**
* @param array{max_width:int,max_height:int} $constraints
* @return array{path:string,width:int,height:int}|null
*/
private function storeResponsiveVariant($disk, string $raw, array $constraints, string $path, string $variant, int $targetWidth, int $sourceWidth, int $sourceHeight): ?array
{
if ($sourceWidth <= $targetWidth && $sourceHeight <= $constraints['max_height']) {
return null;
}
$encodedVariant = $this->encodeScaledMedia($raw, $targetWidth, $constraints['max_height']);
if ($encodedVariant['width'] >= $sourceWidth && $encodedVariant['height'] >= $sourceHeight) {
return null;
}
$variantPath = $this->responsiveVariantPath($path, $variant);
$this->writeMediaBinary($disk, $variantPath, $encodedVariant['binary']);
return [
'path' => $variantPath,
'width' => $encodedVariant['width'],
'height' => $encodedVariant['height'],
];
}
private function writeMediaBinary($disk, string $path, string $binary): void
{
$written = $disk->put($path, $binary, [
'visibility' => 'public',
'CacheControl' => 'public, max-age=31536000, immutable',
'ContentType' => 'image/webp',
@@ -218,13 +320,6 @@ final class AcademyLessonMediaApiController extends Controller
if ($written !== true) {
throw new RuntimeException('Unable to store image in object storage.');
}
return [
'path' => $path,
'width' => (int) $image->width(),
'height' => (int) $image->height(),
'size_bytes' => strlen($encoded),
];
}
private function authorizeStaff(Request $request): void
@@ -255,6 +350,54 @@ final class AcademyLessonMediaApiController extends Controller
return rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/') . '/' . ltrim($path, '/');
}
/**
* @param array<int, array{path:string,width:int|null}> $variants
*/
private function buildResponsiveSrcset(array $variants): ?string
{
$entries = collect($variants)
->filter(function (array $variant): bool {
return trim((string) ($variant['path'] ?? '')) !== '' && (int) ($variant['width'] ?? 0) > 0;
})
->unique(fn (array $variant): string => trim((string) ($variant['path'] ?? '')))
->map(fn (array $variant): string => sprintf('%s %dw', $this->publicUrlForPath((string) $variant['path']), (int) $variant['width']))
->values()
->all();
return $entries !== [] ? implode(', ', $entries) : null;
}
private function responsiveVariantPath(string $path, string $variant): string
{
$directory = pathinfo($path, PATHINFO_DIRNAME);
$filename = pathinfo($path, PATHINFO_FILENAME);
return sprintf(
'%s/%s-%s.webp',
$directory === '.' ? '' : $directory,
preg_replace('/-(thumb|md)$/', '', $filename) ?? $filename,
$variant,
);
}
private function canonicalMediaPath(string $path): string
{
$directory = pathinfo($path, PATHINFO_DIRNAME);
$filename = pathinfo($path, PATHINFO_FILENAME);
$baseFilename = preg_replace('/-(thumb|md)$/', '', $filename) ?? $filename;
return sprintf(
'%s/%s.webp',
$directory === '.' ? '' : $directory,
$baseFilename,
);
}
private function isResponsiveVariantPath(string $path): bool
{
return preg_match('/-(thumb|md)\.webp$/i', $path) === 1;
}
private function academyAssetManifest(): Collection
{
return Cache::remember($this->academyAssetCacheKey(), now()->addMinutes(self::ASSET_CACHE_TTL_MINUTES), function (): Collection {
@@ -262,6 +405,7 @@ final class AcademyLessonMediaApiController extends Controller
return collect($disk->allFiles('academy/lessons'))
->filter(fn (string $path): bool => Str::endsWith(Str::lower($path), ['.webp', '.jpg', '.jpeg', '.png']))
->reject(fn (string $path): bool => $this->isResponsiveVariantPath($path))
->map(function (string $path) use ($disk): array {
$modifiedAt = null;
@@ -323,7 +467,14 @@ final class AcademyLessonMediaApiController extends Controller
return;
}
Storage::disk($this->mediaDiskName())->delete($trimmed);
$basePath = $this->canonicalMediaPath($trimmed);
$paths = [
$basePath,
$this->responsiveVariantPath($basePath, 'thumb'),
$this->responsiveVariantPath($basePath, 'md'),
];
Storage::disk($this->mediaDiskName())->delete(array_values(array_unique($paths)));
}
private function normalizeSlot(mixed $slot): string
@@ -346,8 +497,8 @@ final class AcademyLessonMediaApiController extends Controller
}
return [
'min_width' => 1200,
'min_height' => 630,
'min_width' => 600,
'min_height' => 315,
'max_width' => 2200,
'max_height' => 1400,
];

View File

@@ -0,0 +1,512 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Settings;
use App\Http\Controllers\Controller;
use App\Models\World;
use App\Models\WorldWebStory;
use App\Models\WorldWebStoryPage;
use App\Services\WebStories\WorldWebStoryAssetService;
use App\Services\WebStories\WorldWebStoryGenerator;
use App\Services\WebStories\WorldWebStoryValidationService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
use Inertia\Response;
final class WorldWebStoryAdminController extends Controller
{
private const PER_PAGE = 20;
public function __construct(
private readonly WorldWebStoryGenerator $generator,
private readonly WorldWebStoryAssetService $assets,
private readonly WorldWebStoryValidationService $validation,
) {
}
public function index(Request $request): Response
{
$filters = [
'q' => trim((string) $request->query('q', '')),
'status' => trim((string) $request->query('status', 'all')),
];
$stories = WorldWebStory::query()
->with('world')
->when($filters['q'] !== '', function ($query) use ($filters): void {
$query->where(function ($nested) use ($filters): void {
$nested->where('title', 'like', '%' . $filters['q'] . '%')
->orWhere('slug', 'like', '%' . $filters['q'] . '%')
->orWhereHas('world', fn ($worldQuery) => $worldQuery->where('title', 'like', '%' . $filters['q'] . '%')->orWhere('slug', 'like', '%' . $filters['q'] . '%'));
});
})
->when($filters['status'] !== 'all', fn ($query) => $query->where('status', $filters['status']))
->orderByDesc('published_at')
->orderByDesc('updated_at')
->paginate(self::PER_PAGE)
->withQueryString()
->through(fn (WorldWebStory $story): array => $this->mapStoryListItem($story));
return Inertia::render('Moderation/WorldWebStoriesIndex', [
'title' => 'World Web Stories',
'stories' => $stories,
'filters' => $filters,
'stats' => [
'total' => WorldWebStory::query()->count(),
'published' => WorldWebStory::query()->where('status', WorldWebStory::STATUS_PUBLISHED)->count(),
'draft' => WorldWebStory::query()->where('status', WorldWebStory::STATUS_DRAFT)->count(),
'hidden' => WorldWebStory::query()->where('noindex', true)->orWhere('active', false)->count(),
],
'worldOptions' => $this->worldOptions(),
'endpoints' => [
'index' => route('admin.web-stories.index'),
'create' => route('admin.web-stories.create'),
'editPattern' => route('admin.web-stories.edit', ['story' => '__STORY__']),
'destroyPattern' => route('admin.web-stories.destroy', ['story' => '__STORY__']),
'publishPattern' => route('admin.web-stories.publish', ['story' => '__STORY__']),
'unpublishPattern' => route('admin.web-stories.unpublish', ['story' => '__STORY__']),
'generatePattern' => route('admin.web-stories.generate', ['world' => '__WORLD__']),
],
])->rootView('moderation');
}
public function create(): Response
{
return Inertia::render('Moderation/WorldWebStoryEditor', [
'story' => $this->blankStoryPayload(),
'worldOptions' => $this->worldOptions(),
'endpoints' => $this->editorEndpoints(),
'isNew' => true,
])->rootView('moderation');
}
public function store(Request $request): RedirectResponse
{
$attributes = $this->validatedStoryAttributes($request);
$story = new WorldWebStory();
$story->fill($attributes + [
'created_by' => (int) $request->user()->id,
'updated_by' => (int) $request->user()->id,
]);
$this->normalizeStatusTimestamps($story);
$this->assertPublishedStateIsValid($story);
$story->save();
return redirect()->route('admin.web-stories.edit', ['story' => $story])->with('success', 'Web story created.');
}
public function edit(WorldWebStory $story): Response
{
$story->load(['world', 'orderedPages.artwork']);
return Inertia::render('Moderation/WorldWebStoryEditor', [
'story' => $this->mapStoryEditorPayload($story),
'worldOptions' => $this->worldOptions(),
'endpoints' => $this->editorEndpoints($story),
'isNew' => false,
])->rootView('moderation');
}
public function update(Request $request, WorldWebStory $story): RedirectResponse
{
$story->fill($this->validatedStoryAttributes($request) + [
'updated_by' => (int) $request->user()->id,
]);
$this->normalizeStatusTimestamps($story);
$this->assertPublishedStateIsValid($story);
$story->save();
return back()->with('success', 'Web story updated.');
}
public function destroy(WorldWebStory $story): JsonResponse
{
$story->delete();
return response()->json([
'ok' => true,
'message' => 'Web story deleted.',
]);
}
public function storePage(Request $request, WorldWebStory $story): JsonResponse
{
$attributes = $this->validatedPageAttributes($request, $story, null);
$page = $story->pages()->create($attributes);
return response()->json([
'ok' => true,
'message' => 'Page created.',
'page' => $this->mapPage($page->fresh('artwork')),
]);
}
public function updatePage(Request $request, WorldWebStory $story, WorldWebStoryPage $page): JsonResponse
{
abort_unless((int) $page->story_id === (int) $story->id, 404);
$page->fill($this->validatedPageAttributes($request, $story, $page));
$page->save();
return response()->json([
'ok' => true,
'message' => 'Page updated.',
'page' => $this->mapPage($page->fresh('artwork')),
]);
}
public function destroyPage(WorldWebStory $story, WorldWebStoryPage $page): JsonResponse
{
abort_unless((int) $page->story_id === (int) $story->id, 404);
$page->delete();
return response()->json([
'ok' => true,
'message' => 'Page deleted.',
]);
}
public function reorderPages(Request $request, WorldWebStory $story): JsonResponse
{
$validated = $request->validate([
'page_ids' => ['required', 'array', 'min:1'],
'page_ids.*' => ['integer'],
]);
$ids = collect($validated['page_ids'])->map(fn ($id): int => (int) $id)->values();
$pages = $story->orderedPages()->whereIn('id', $ids)->get()->keyBy('id');
abort_unless($pages->count() === $ids->count(), 422);
foreach ($ids as $index => $id) {
$pages[$id]->forceFill(['position' => $index + 1])->save();
}
return response()->json([
'ok' => true,
'message' => 'Page order updated.',
]);
}
public function generateFromWorld(Request $request, World $world): JsonResponse
{
$validated = $request->validate([
'force' => ['nullable', 'boolean'],
'publish' => ['nullable', 'boolean'],
'dry_run' => ['nullable', 'boolean'],
'pages' => ['nullable', 'integer', 'min:5', 'max:10'],
]);
$result = $this->generator->generateFromWorld(
$world,
$request->user(),
(int) ($validated['pages'] ?? 7),
(bool) ($validated['force'] ?? false),
(bool) ($validated['publish'] ?? false),
(bool) ($validated['dry_run'] ?? false),
);
return response()->json([
'ok' => true,
'message' => $result['created'] ? 'Web story draft generated.' : 'Web story draft regenerated.',
'story' => [
'id' => $result['story']->id,
'slug' => $result['story']->slug,
'edit_url' => $result['story']->exists ? route('admin.web-stories.edit', ['story' => $result['story']->id]) : null,
],
'validation' => $result['validation'],
]);
}
public function publish(WorldWebStory $story): JsonResponse
{
$this->assets->buildAssets($story, force: false);
$story->refresh()->load('orderedPages');
$this->validation->assertPublishable($story);
$story->forceFill([
'status' => WorldWebStory::STATUS_PUBLISHED,
'published_at' => $story->published_at ?: now(),
])->save();
return response()->json([
'ok' => true,
'message' => 'Web story published.',
]);
}
public function unpublish(WorldWebStory $story): JsonResponse
{
$story->forceFill([
'status' => WorldWebStory::STATUS_DRAFT,
'published_at' => null,
])->save();
return response()->json([
'ok' => true,
'message' => 'Web story reverted to draft.',
]);
}
/**
* @return array<string, mixed>
*/
private function validatedStoryAttributes(Request $request, ?WorldWebStory $story = null): array
{
$validated = $request->validate([
'world_id' => ['nullable', 'integer', Rule::exists('worlds', 'id')],
'slug' => ['required', 'string', 'max:120', Rule::unique('world_web_stories', 'slug')->ignore($story?->id)],
'title' => ['required', 'string', 'max:255'],
'subtitle' => ['nullable', 'string', 'max:255'],
'excerpt' => ['nullable', 'string', 'max:400'],
'description' => ['nullable', 'string', 'max:2000'],
'seo_title' => ['nullable', 'string', 'max:255'],
'seo_description' => ['nullable', 'string', 'max:400'],
'poster_portrait_path' => ['nullable', 'string', 'max:2048'],
'poster_square_path' => ['nullable', 'string', 'max:2048'],
'publisher_logo_path' => ['nullable', 'string', 'max:2048'],
'status' => ['required', Rule::in([WorldWebStory::STATUS_DRAFT, WorldWebStory::STATUS_PUBLISHED, WorldWebStory::STATUS_ARCHIVED])],
'featured' => ['required', 'boolean'],
'active' => ['required', 'boolean'],
'noindex' => ['required', 'boolean'],
'published_at' => ['nullable', 'date'],
'starts_at' => ['nullable', 'date'],
'ends_at' => ['nullable', 'date', 'after_or_equal:starts_at'],
]);
return $validated;
}
/**
* @return array<string, mixed>
*/
private function validatedPageAttributes(Request $request, WorldWebStory $story, ?WorldWebStoryPage $page): array
{
$validated = $request->validate([
'artwork_id' => ['nullable', 'integer', Rule::exists('artworks', 'id')],
'position' => ['nullable', 'integer', 'min:1'],
'layout' => ['required', Rule::in([
WorldWebStoryPage::LAYOUT_COVER,
WorldWebStoryPage::LAYOUT_ARTWORK,
WorldWebStoryPage::LAYOUT_CREATOR,
WorldWebStoryPage::LAYOUT_MOOD,
WorldWebStoryPage::LAYOUT_COLLECTION,
WorldWebStoryPage::LAYOUT_CTA,
])],
'background_type' => ['required', Rule::in([
WorldWebStoryPage::BACKGROUND_IMAGE,
WorldWebStoryPage::BACKGROUND_VIDEO,
WorldWebStoryPage::BACKGROUND_GRADIENT,
])],
'background_path' => ['nullable', 'string', 'max:2048'],
'background_mobile_path' => ['nullable', 'string', 'max:2048'],
'headline' => ['nullable', 'string', 'max:255'],
'body' => ['nullable', 'string', 'max:180'],
'cta_label' => ['nullable', 'string', 'max:120'],
'cta_url' => ['nullable', 'string', 'max:2048'],
'alt_text' => ['required', 'string', 'max:255'],
'caption' => ['nullable', 'string', 'max:120'],
'credit_text' => ['nullable', 'string', 'max:255'],
'text_position' => ['required', Rule::in(['top', 'center', 'bottom'])],
'overlay_strength' => ['required', 'integer', 'min:0', 'max:100'],
'animation' => ['nullable', Rule::in(['fade-in', 'fly-in-bottom', 'pulse', 'pan-left', 'pan-right'])],
'active' => ['required', 'boolean'],
]);
$validated['position'] = (int) ($validated['position'] ?? ($story->orderedPages()->max('position') + ($page ? 0 : 1) ?: 1));
$pageErrors = $this->validation->validatePagePayload($validated);
if ($pageErrors !== []) {
throw ValidationException::withMessages($pageErrors);
}
return $validated;
}
private function normalizeStatusTimestamps(WorldWebStory $story): void
{
if ((string) $story->status === WorldWebStory::STATUS_PUBLISHED && $story->published_at === null) {
$story->published_at = now();
}
if ((string) $story->status === WorldWebStory::STATUS_DRAFT) {
$story->published_at = null;
}
}
private function assertPublishedStateIsValid(WorldWebStory $story): void
{
if ((string) $story->status !== WorldWebStory::STATUS_PUBLISHED) {
return;
}
$story->loadMissing('orderedPages');
$this->validation->assertPublishable($story);
}
/**
* @return array<int, array{value:int,label:string,description:string}>
*/
private function worldOptions(): array
{
return World::query()
->orderByDesc('published_at')
->orderBy('title')
->limit(200)
->get(['id', 'title', 'slug'])
->map(fn (World $world): array => [
'value' => (int) $world->id,
'label' => (string) $world->title,
'description' => (string) $world->slug,
])
->all();
}
/**
* @return array<string, mixed>
*/
private function blankStoryPayload(): array
{
return [
'id' => null,
'world_id' => null,
'slug' => '',
'title' => '',
'subtitle' => '',
'excerpt' => '',
'description' => '',
'seo_title' => '',
'seo_description' => '',
'poster_portrait_path' => '',
'poster_square_path' => '',
'publisher_logo_path' => $this->assets->defaultPublisherLogoPath(),
'status' => WorldWebStory::STATUS_DRAFT,
'featured' => false,
'active' => true,
'noindex' => false,
'published_at' => null,
'starts_at' => null,
'ends_at' => null,
'world' => null,
'pages' => [],
'public_url' => null,
'validation' => ['valid' => false, 'errors' => [], 'warnings' => [], 'page_count' => 0],
];
}
/**
* @return array<string, mixed>
*/
private function mapStoryEditorPayload(WorldWebStory $story): array
{
return [
'id' => (int) $story->id,
'world_id' => $story->world_id ? (int) $story->world_id : null,
'slug' => (string) $story->slug,
'title' => (string) $story->title,
'subtitle' => (string) ($story->subtitle ?? ''),
'excerpt' => (string) ($story->excerpt ?? ''),
'description' => (string) ($story->description ?? ''),
'seo_title' => (string) ($story->seo_title ?? ''),
'seo_description' => (string) ($story->seo_description ?? ''),
'poster_portrait_path' => (string) ($story->poster_portrait_path ?? ''),
'poster_square_path' => (string) ($story->poster_square_path ?? ''),
'publisher_logo_path' => (string) ($story->publisher_logo_path ?? ''),
'status' => (string) $story->status,
'featured' => (bool) $story->featured,
'active' => (bool) $story->active,
'noindex' => (bool) $story->noindex,
'published_at' => optional($story->published_at)?->toIso8601String(),
'starts_at' => optional($story->starts_at)?->toIso8601String(),
'ends_at' => optional($story->ends_at)?->toIso8601String(),
'world' => $story->world ? [
'id' => (int) $story->world->id,
'title' => (string) $story->world->title,
'slug' => (string) $story->world->slug,
] : null,
'pages' => $story->orderedPages->map(fn (WorldWebStoryPage $page): array => $this->mapPage($page))->all(),
'public_url' => route('web-stories.show', ['slug' => $story->slug]),
'validation' => $this->validation->validate($story),
];
}
/**
* @return array<string, mixed>
*/
private function mapStoryListItem(WorldWebStory $story): array
{
return [
'id' => (int) $story->id,
'slug' => (string) $story->slug,
'title' => (string) $story->title,
'excerpt' => (string) ($story->excerpt ?? ''),
'status' => (string) $story->status,
'active' => (bool) $story->active,
'noindex' => (bool) $story->noindex,
'featured' => (bool) $story->featured,
'page_count' => (int) ($story->pages()->count()),
'published_at' => optional($story->published_at)?->toIso8601String(),
'poster_portrait_url' => $story->posterPortraitUrl(),
'world' => $story->world ? [
'id' => (int) $story->world->id,
'title' => (string) $story->world->title,
'slug' => (string) $story->world->slug,
] : null,
'public_url' => route('web-stories.show', ['slug' => $story->slug]),
];
}
/**
* @return array<string, mixed>
*/
private function mapPage(WorldWebStoryPage $page): array
{
return [
'id' => (int) $page->id,
'artwork_id' => $page->artwork_id ? (int) $page->artwork_id : null,
'position' => (int) $page->position,
'layout' => (string) $page->layout,
'background_type' => (string) $page->background_type,
'background_path' => (string) ($page->background_path ?? ''),
'background_mobile_path' => (string) ($page->background_mobile_path ?? ''),
'headline' => (string) ($page->headline ?? ''),
'body' => (string) ($page->body ?? ''),
'cta_label' => (string) ($page->cta_label ?? ''),
'cta_url' => (string) ($page->cta_url ?? ''),
'alt_text' => (string) ($page->alt_text ?? ''),
'caption' => (string) ($page->caption ?? ''),
'credit_text' => (string) ($page->credit_text ?? ''),
'text_position' => (string) ($page->text_position ?? 'bottom'),
'overlay_strength' => (int) ($page->overlay_strength ?? 35),
'animation' => (string) ($page->animation ?? ''),
'active' => (bool) $page->active,
'background_url' => $page->backgroundUrl(),
];
}
/**
* @return array<string, string>
*/
private function editorEndpoints(?WorldWebStory $story = null): array
{
return [
'store' => route('admin.web-stories.store'),
'update' => $story ? route('admin.web-stories.update', ['story' => $story]) : '',
'destroy' => $story ? route('admin.web-stories.destroy', ['story' => $story]) : '',
'pagesStore' => $story ? route('admin.web-stories.pages.store', ['story' => $story]) : '',
'pagesUpdatePattern' => $story ? route('admin.web-stories.pages.update', ['story' => $story, 'page' => '__PAGE__']) : '',
'pagesDestroyPattern' => $story ? route('admin.web-stories.pages.destroy', ['story' => $story, 'page' => '__PAGE__']) : '',
'pagesReorder' => $story ? route('admin.web-stories.pages.reorder', ['story' => $story]) : '',
'publish' => $story ? route('admin.web-stories.publish', ['story' => $story]) : '',
'unpublish' => $story ? route('admin.web-stories.unpublish', ['story' => $story]) : '',
'generateFromWorldPattern' => route('admin.web-stories.generate', ['world' => '__WORLD__']),
'index' => route('admin.web-stories.index'),
];
}
}

View File

@@ -32,7 +32,7 @@ final class StudioNewsController extends Controller
return Inertia::render('Studio/StudioNewsIndex', [
'title' => 'Newsroom',
'description' => 'Plan announcements, publish editorial stories, and connect articles to the rest of Nova.',
'listing' => $this->news->studioListing($request->only(['q', 'status', 'type', 'category_id', 'per_page', 'page'])),
'listing' => $this->news->studioListing($request->only(['q', 'status', 'type', 'category_id', 'per_page', 'page', 'order', 'direction'])),
'statusOptions' => $this->news->editorialStatusOptions(),
'typeOptions' => $this->news->articleTypeOptions(),
'categoryOptions' => $this->news->categoryOptions(),
@@ -56,7 +56,7 @@ final class StudioNewsController extends Controller
'statusOptions' => $this->news->editorialStatusOptions(),
'categoryOptions' => $this->news->categoryOptions(),
'tagOptions' => $this->news->tagOptions(),
'newsTagLimit' => 12,
'newsTagLimit' => 30,
'relationTypeOptions' => $this->news->relationTypeOptions(),
'storeUrl' => route('studio.news.store'),
'coverUploadUrl' => route('api.studio.news.media.upload'),
@@ -92,7 +92,7 @@ final class StudioNewsController extends Controller
'statusOptions' => $this->news->editorialStatusOptions(),
'categoryOptions' => $this->news->categoryOptions(),
'tagOptions' => $this->news->tagOptions(),
'newsTagLimit' => 12,
'newsTagLimit' => 30,
'relationTypeOptions' => $this->news->relationTypeOptions(),
'coverUploadUrl' => route('api.studio.news.media.upload'),
'coverDeleteUrl' => route('api.studio.news.media.destroy'),
@@ -367,21 +367,11 @@ final class StudioNewsController extends Controller
'comments_enabled' => ['nullable', 'boolean'],
'tag_ids' => ['nullable', 'array'],
'tag_ids.*' => ['integer', 'exists:news_tags,id'],
'new_tag_names' => ['nullable', 'array', 'max:12'],
'new_tag_names' => ['nullable', 'array', 'max:30'],
'new_tag_names.*' => ['string', 'max:80'],
'meta_title' => ['nullable', 'string', 'max:255'],
'meta_description' => ['nullable', 'string', 'max:300'],
'meta_keywords' => ['nullable', 'string', 'max:255'],
'canonical_url' => ['nullable', 'string', 'max:2048', function (string $attribute, mixed $value, \Closure $fail): void {
if ($value === '' || $value === null) {
return;
}
$isAbsolute = filter_var($value, FILTER_VALIDATE_URL) !== false;
$isRelative = str_starts_with($value, '/');
if (! $isAbsolute && ! $isRelative) {
$fail('The canonical URL must be a valid URL or a relative path starting with /.');
}
}],
'og_title' => ['nullable', 'string', 'max:255'],
'og_description' => ['nullable', 'string', 'max:300'],
'og_image' => ['nullable', 'string', 'max:2048'],

View File

@@ -227,10 +227,11 @@ final class SimilarArtworksPageController extends Controller
->public()
->published()
->with([
'categories:id,slug,name',
'categories:id,slug,name,content_type_id',
'categories.contentType:id,name,slug',
'user:id,name,username',
'user.profile:user_id,avatar_hash',
'group:id,name,slug,avatar_path',
])
->get()
->keyBy('id');
@@ -268,6 +269,14 @@ final class SimilarArtworksPageController extends Controller
'sort' => ['trending_score_7d:desc', 'created_at:desc'],
])->paginate(self::PER_PAGE, 'page', $page);
$results->getCollection()->load([
'categories:id,slug,name,content_type_id',
'categories.contentType:id,name,slug',
'user:id,name,username',
'user.profile:user_id,avatar_hash',
'group:id,name,slug,avatar_path',
]);
$results->getCollection()->transform(fn ($a) => $this->presentArtwork($a));
return $results;

View File

@@ -51,7 +51,7 @@ final class TagController extends Controller
$artworks = $this->search->byTag($tag->slug, $perPage, $sort);
// Eager-load relations used by the gallery presenter and thumbnails.
$artworks->getCollection()->each(fn($m) => $m->loadMissing(['user.profile', 'categories']));
$artworks->getCollection()->loadMissing(['user.profile', 'categories.contentType']);
// Sidebar: main content type links (same as browse gallery)
$mainCategories = ContentType::ordered()->where('hide_from_menu', false)->get(['name', 'slug'])

View File

@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use App\Models\WorldWebStory;
use App\Services\WebStories\WorldWebStorySeoService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\View\View;
final class WorldWebStoryController extends Controller
{
public function __construct(private readonly WorldWebStorySeoService $seo)
{
}
public function index(Request $request): View
{
$stories = Cache::remember('web_story_index', 300, fn () => WorldWebStory::query()
->with('world')
->visible()
->orderByDesc('featured')
->orderByDesc('published_at')
->paginate(12)
->withQueryString());
return view('web-stories.index', [
'stories' => $stories,
'seo' => $this->seo->indexSeo(),
'useUnifiedSeo' => true,
]);
}
public function show(string $slug): View
{
$story = Cache::remember('web_story:' . $slug, 300, fn () => WorldWebStory::query()
->with(['world', 'orderedPages.artwork.user'])
->visible()
->where('slug', $slug)
->first());
abort_unless($story instanceof WorldWebStory, 404);
return view('web-stories.show', [
'story' => $story,
'meta' => $this->seo->storyMeta($story),
]);
}
}

View File

@@ -94,6 +94,9 @@ final class HandleInertiaRequests extends Middleware
{
$canReadSessionAuth = $this->canReadSessionAuth($request);
$user = $canReadSessionAuth ? $request->user() : null;
$sessionFlash = static fn (string $key): ?string => $canReadSessionAuth
? $request->session()->get($key)
: null;
return array_merge(parent::share($request), [
'auth' => [
@@ -108,6 +111,11 @@ final class HandleInertiaRequests extends Middleware
'is_moderator' => $user->isModerator(),
] : null,
],
'flash' => [
'success' => fn (): ?string => $sessionFlash('success'),
'error' => fn (): ?string => $sessionFlash('error'),
'warning' => fn (): ?string => $sessionFlash('warning'),
],
'cdn' => [
'files_url' => config('cdn.files_url'),
],

View File

@@ -111,7 +111,7 @@ class UpsertAcademyLessonRequest extends FormRequest
'cover_image' => ['nullable', 'string', 'max:2048'],
'article_cover_image' => ['nullable', 'string', 'max:2048'],
'tags' => ['nullable', 'array'],
'tags.*' => ['string', 'max:60'],
'tags.*' => ['string', 'max:100'],
'video_url' => ['nullable', 'string', 'max:2048'],
'reading_minutes' => ['required', 'integer', 'min:1', 'max:999'],
'featured' => ['required', 'boolean'],

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Http\Requests\Academy;
use JsonException;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
@@ -22,6 +23,10 @@ class UpsertAcademyPromptTemplateRequest extends FormRequest
'active' => $this->boolean('active', true),
'new_category_name' => trim((string) $this->input('new_category_name', '')),
'tags' => array_values(array_filter((array) $this->input('tags', []))),
'documentation' => $this->normalizeDocumentation($this->input('documentation')),
'placeholders' => $this->normalizePlaceholders($this->input('placeholders')),
'helper_prompts' => $this->normalizeHelperPrompts($this->input('helper_prompts')),
'prompt_variants' => $this->normalizePromptVariants($this->input('prompt_variants')),
'tool_notes' => collect($this->input('tool_notes', []))
->filter(static fn ($note): bool => is_array($note) || is_string($note))
->map(function ($note): array|string {
@@ -30,6 +35,7 @@ class UpsertAcademyPromptTemplateRequest extends FormRequest
}
return [
'display_type' => $note['display_type'] ?? null,
'provider' => $note['provider'] ?? null,
'model_name' => $note['model_name'] ?? null,
'notes' => $note['notes'] ?? null,
@@ -62,12 +68,57 @@ class UpsertAcademyPromptTemplateRequest extends FormRequest
'negative_prompt' => ['nullable', 'string'],
'usage_notes' => ['nullable', 'string'],
'workflow_notes' => ['nullable', 'string'],
'documentation' => ['nullable', 'array'],
'documentation.summary' => ['nullable', 'string'],
'documentation.best_for' => ['nullable', 'array'],
'documentation.best_for.*' => ['nullable', 'string'],
'documentation.how_to_use' => ['nullable', 'array'],
'documentation.how_to_use.*' => ['nullable', 'string'],
'documentation.required_inputs' => ['nullable', 'array'],
'documentation.required_inputs.*' => ['nullable', 'string'],
'documentation.workflow' => ['nullable', 'array'],
'documentation.workflow.*' => ['nullable', 'string'],
'documentation.tips' => ['nullable', 'array'],
'documentation.tips.*' => ['nullable', 'string'],
'documentation.common_mistakes' => ['nullable', 'array'],
'documentation.common_mistakes.*' => ['nullable', 'string'],
'documentation.data_accuracy_notes' => ['nullable', 'array'],
'documentation.data_accuracy_notes.*' => ['nullable', 'string'],
'documentation.display_notes' => ['nullable', 'string'],
'placeholders' => ['nullable', 'array'],
'placeholders.*.key' => ['nullable', 'string', 'max:120'],
'placeholders.*.label' => ['nullable', 'string', 'max:180'],
'placeholders.*.description' => ['nullable', 'string'],
'placeholders.*.required' => ['nullable', 'boolean'],
'placeholders.*.example' => ['nullable'],
'placeholders.*.default' => ['nullable'],
'placeholders.*.type' => ['nullable', 'string', 'max:120'],
'helper_prompts' => ['nullable', 'array'],
'helper_prompts.*.title' => ['required_with:helper_prompts', 'string', 'max:180'],
'helper_prompts.*.type' => ['nullable', 'string', Rule::in(['data_collection', 'prompt_preparation', 'refinement', 'validation', 'variation', 'translation', 'seo', 'other'])],
'helper_prompts.*.description' => ['nullable', 'string'],
'helper_prompts.*.prompt' => ['required_with:helper_prompts', 'string'],
'helper_prompts.*.expected_output' => ['nullable', 'string', Rule::in(['json', 'text', 'markdown', 'image_prompt'])],
'helper_prompts.*.active' => ['nullable', 'boolean'],
'prompt_variants' => ['nullable', 'array'],
'prompt_variants.*.title' => ['required_with:prompt_variants', 'string', 'max:180'],
'prompt_variants.*.slug' => ['nullable', 'string', 'max:180'],
'prompt_variants.*.description' => ['nullable', 'string'],
'prompt_variants.*.prompt' => ['required_with:prompt_variants', 'string'],
'prompt_variants.*.negative_prompt' => ['nullable', 'string'],
'prompt_variants.*.recommended' => ['nullable', 'boolean'],
'prompt_variants.*.recommended_for' => ['nullable', 'array'],
'prompt_variants.*.recommended_for.*' => ['nullable', 'string'],
'prompt_variants.*.risk_notes' => ['nullable', 'array'],
'prompt_variants.*.risk_notes.*' => ['nullable', 'string'],
'prompt_variants.*.active' => ['nullable', 'boolean'],
'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'],
'tags' => ['nullable', 'array'],
'tags.*' => ['string', 'max:60'],
'tool_notes' => ['nullable', 'array'],
'tool_notes.*.display_type' => ['nullable', 'string', 'max:50'],
'tool_notes.*.provider' => ['nullable', 'string', 'max:100'],
'tool_notes.*.model_name' => ['nullable', 'string', 'max:150'],
'tool_notes.*.notes' => ['nullable', 'string'],
@@ -89,4 +140,251 @@ class UpsertAcademyPromptTemplateRequest extends FormRequest
'seo_description' => ['nullable', 'string', 'max:255'],
];
}
private function decodeStructuredInput(mixed $value): mixed
{
if (! is_string($value)) {
return $value;
}
$trimmed = trim($value);
if ($trimmed === '') {
return null;
}
try {
return json_decode($trimmed, true, 512, JSON_THROW_ON_ERROR);
} catch (JsonException) {
return $value;
}
}
private function normalizeDocumentation(mixed $value): mixed
{
$value = $this->decodeStructuredInput($value);
if ($value === null) {
return null;
}
if (! is_array($value)) {
return $value;
}
$listFields = ['best_for', 'how_to_use', 'required_inputs', 'workflow', 'tips', 'common_mistakes', 'data_accuracy_notes'];
$documentation = [
'summary' => $this->normalizeOptionalString($value['summary'] ?? null),
'display_notes' => $this->normalizeOptionalString($value['display_notes'] ?? null),
];
foreach ($listFields as $field) {
$documentation[$field] = $this->normalizeStringList($value[$field] ?? []);
}
$hasContent = $documentation['summary'] !== null
|| $documentation['display_notes'] !== null
|| collect($listFields)->contains(fn (string $field): bool => $documentation[$field] !== []);
return $hasContent ? $documentation : null;
}
private function normalizePlaceholders(mixed $value): mixed
{
$value = $this->decodeStructuredInput($value);
if ($value === null) {
return [];
}
if (! is_array($value)) {
return $value;
}
$value = $this->normalizeStructuredObjectList($value, ['key', 'label', 'description', 'required', 'example', 'default', 'type']);
return collect($value)
->values()
->map(function ($placeholder): mixed {
if (! is_array($placeholder)) {
return $placeholder;
}
return [
'key' => $this->normalizeOptionalString($placeholder['key'] ?? null),
'label' => $this->normalizeOptionalString($placeholder['label'] ?? null),
'description' => $this->normalizeOptionalString($placeholder['description'] ?? null),
'required' => filter_var($placeholder['required'] ?? false, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE) ?? false,
'example' => $this->normalizeJsonScalar($placeholder['example'] ?? null),
'default' => $this->normalizeJsonScalar($placeholder['default'] ?? null),
'type' => $this->normalizeOptionalString($placeholder['type'] ?? null),
];
})
->filter(function ($placeholder): bool {
if (! is_array($placeholder)) {
return true;
}
return collect([
$placeholder['key'] ?? null,
$placeholder['label'] ?? null,
$placeholder['description'] ?? null,
$placeholder['example'] ?? null,
$placeholder['default'] ?? null,
$placeholder['type'] ?? null,
])->contains(fn ($item): bool => $item !== null && $item !== '' && $item !== []);
})
->values()
->all();
}
private function normalizeHelperPrompts(mixed $value): mixed
{
$value = $this->decodeStructuredInput($value);
if ($value === null) {
return [];
}
if (! is_array($value)) {
return $value;
}
$value = $this->normalizeStructuredObjectList($value, ['title', 'type', 'description', 'prompt', 'expected_output', 'active']);
return collect($value)
->values()
->map(function ($helperPrompt): mixed {
if (! is_array($helperPrompt)) {
return $helperPrompt;
}
return [
'title' => $this->normalizeOptionalString($helperPrompt['title'] ?? null),
'type' => $this->normalizeOptionalString($helperPrompt['type'] ?? null) ?? 'other',
'description' => $this->normalizeOptionalString($helperPrompt['description'] ?? null),
'prompt' => $this->normalizeOptionalString($helperPrompt['prompt'] ?? null),
'expected_output' => $this->normalizeOptionalString($helperPrompt['expected_output'] ?? null) ?? 'text',
'active' => filter_var($helperPrompt['active'] ?? true, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE) ?? true,
];
})
->filter(function ($helperPrompt): bool {
if (! is_array($helperPrompt)) {
return true;
}
return collect([
$helperPrompt['title'] ?? null,
$helperPrompt['description'] ?? null,
$helperPrompt['prompt'] ?? null,
])->contains(fn ($item): bool => $item !== null && $item !== '');
})
->values()
->all();
}
private function normalizePromptVariants(mixed $value): mixed
{
$value = $this->decodeStructuredInput($value);
if ($value === null) {
return [];
}
if (! is_array($value)) {
return $value;
}
$value = $this->normalizeStructuredObjectList($value, ['title', 'slug', 'description', 'prompt', 'negative_prompt', 'recommended', 'recommended_for', 'risk_notes', 'active']);
return collect($value)
->values()
->map(function ($variant): mixed {
if (! is_array($variant)) {
return $variant;
}
return [
'title' => $this->normalizeOptionalString($variant['title'] ?? null),
'slug' => $this->normalizeOptionalString($variant['slug'] ?? null),
'description' => $this->normalizeOptionalString($variant['description'] ?? null),
'prompt' => $this->normalizeOptionalString($variant['prompt'] ?? null),
'negative_prompt' => $this->normalizeOptionalString($variant['negative_prompt'] ?? null),
'recommended' => filter_var($variant['recommended'] ?? false, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE) ?? false,
'recommended_for' => $this->normalizeStringList($variant['recommended_for'] ?? []),
'risk_notes' => $this->normalizeStringList($variant['risk_notes'] ?? []),
'active' => filter_var($variant['active'] ?? true, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE) ?? true,
];
})
->filter(function ($variant): bool {
if (! is_array($variant)) {
return true;
}
return collect([
$variant['title'] ?? null,
$variant['description'] ?? null,
$variant['prompt'] ?? null,
$variant['negative_prompt'] ?? null,
])->contains(fn ($item): bool => $item !== null && $item !== '');
})
->values()
->all();
}
private function normalizeStringList(mixed $value): array
{
if (! is_array($value)) {
$value = $value === null ? [] : [$value];
}
return collect($value)
->map(fn ($item): string => trim((string) $item))
->filter(static fn (string $item): bool => $item !== '')
->values()
->all();
}
private function normalizeOptionalString(mixed $value): ?string
{
if ($value === null) {
return null;
}
$normalized = trim((string) $value);
return $normalized !== '' ? $normalized : null;
}
private function normalizeJsonScalar(mixed $value): mixed
{
if (! is_string($value)) {
return $value;
}
$trimmed = trim($value);
return $trimmed !== '' ? $trimmed : null;
}
/**
* @param array<int|string, mixed> $value
* @param array<int, string> $expectedKeys
* @return array<int|string, mixed>
*/
private function normalizeStructuredObjectList(array $value, array $expectedKeys): array
{
if (array_is_list($value)) {
return $value;
}
$keys = array_keys($value);
$normalizedKeys = array_map(static fn ($key): string => (string) $key, $keys);
if ($normalizedKeys === [] || array_intersect($normalizedKeys, $expectedKeys) === []) {
return $value;
}
return [$value];
}
}