[ 'lessons' => AcademyLesson::query()->count(), 'prompts' => AcademyPromptTemplate::query()->count(), 'packs' => AcademyPromptPack::query()->count(), 'challenges' => AcademyChallenge::query()->count(), 'submissions' => AcademyChallengeSubmission::query()->count(), 'badges' => AcademyBadge::query()->count(), 'creator_subscribers' => 0, 'pro_subscribers' => 0, 'mrr' => 0, ], 'links' => [ 'categories' => route('admin.academy.categories.index'), 'lessons' => route('admin.academy.lessons.index'), 'prompts' => route('admin.academy.prompts.index'), 'packs' => route('admin.academy.packs.index'), 'challenges' => route('admin.academy.challenges.index'), 'submissions' => route('admin.academy.submissions.index'), 'badges' => route('admin.academy.badges.index'), ], ]); } public function categoriesIndex(): Response { return $this->renderIndex('categories'); } public function categoriesCreate(): Response { return $this->renderForm('categories', new AcademyCategory); } public function categoriesStore(UpsertAcademyCategoryRequest $request): RedirectResponse { $category = new AcademyCategory; $category->fill($request->validated())->save(); $this->cache->clearAll(); return redirect()->route('admin.academy.categories.edit', ['academyCategory' => $category])->with('success', 'Academy category created.'); } public function categoriesStoreJson(UpsertAcademyCategoryRequest $request): JsonResponse { $category = new AcademyCategory; $category->fill($request->validated())->save(); $this->cache->clearAll(); return response()->json([ 'success' => true, 'category' => $this->serializeCategoryOption($category), ]); } public function categoriesEdit(AcademyCategory $academyCategory): Response { return $this->renderForm('categories', $academyCategory); } public function categoriesUpdate(UpsertAcademyCategoryRequest $request, AcademyCategory $academyCategory): RedirectResponse { $academyCategory->fill($request->validated())->save(); $this->cache->clearAll(); return redirect()->route('admin.academy.categories.edit', ['academyCategory' => $academyCategory])->with('success', 'Academy category updated.'); } public function categoriesDestroy(AcademyCategory $academyCategory): RedirectResponse { $academyCategory->delete(); $this->cache->clearAll(); return redirect()->route('admin.academy.categories.index')->with('success', 'Academy category deleted.'); } public function lessonsIndex(): Response { return $this->renderIndex('lessons'); } public function lessonsCreate(): Response { return $this->renderForm('lessons', new AcademyLesson); } public function lessonsStore(UpsertAcademyLessonRequest $request): RedirectResponse { $lesson = DB::transaction(function () use ($request): AcademyLesson { $lesson = new AcademyLesson; $lesson->fill($this->persistLessonAttributes($request))->save(); $this->syncLessonBlocks($lesson, $request->validated('blocks', [])); return $lesson; }); $this->cache->clearAll(); return redirect()->route('admin.academy.lessons.edit', ['academyLesson' => $lesson])->with('success', 'Academy lesson created.'); } public function lessonsEdit(AcademyLesson $academyLesson): Response { $academyLesson->load(['blocks.comparisonResults']); return $this->renderForm('lessons', $academyLesson); } public function lessonsUpdate(UpsertAcademyLessonRequest $request, AcademyLesson $academyLesson): RedirectResponse { DB::transaction(function () use ($request, $academyLesson): void { $academyLesson->fill($this->persistLessonAttributes($request, $academyLesson))->save(); $this->syncLessonBlocks($academyLesson, $request->validated('blocks', [])); }); $this->cache->clearAll(); return redirect()->route('admin.academy.lessons.edit', ['academyLesson' => $academyLesson])->with('success', 'Academy lesson updated.'); } public function lessonsDestroy(AcademyLesson $academyLesson): RedirectResponse { $academyLesson->load(['blocks.comparisonResults']); foreach ($academyLesson->blocks as $block) { foreach ($block->comparisonResults as $result) { $this->deleteStoredLessonMediaIfLocal($result->image_path); $this->deleteStoredLessonMediaIfLocal($result->thumb_path); } } $this->deleteStoredLessonCoverIfLocal((string) $academyLesson->cover_image); $academyLesson->delete(); $this->cache->clearAll(); return redirect()->route('admin.academy.lessons.index')->with('success', 'Academy lesson deleted.'); } public function promptsIndex(): Response { return $this->renderIndex('prompts'); } public function promptsCreate(): Response { return $this->renderForm('prompts', new AcademyPromptTemplate); } public function promptsStore(UpsertAcademyPromptTemplateRequest $request): RedirectResponse { $prompt = new AcademyPromptTemplate; $prompt->forceFill($this->persistPromptAttributes($request, $prompt))->save(); $this->cache->clearAll(); return redirect()->route('admin.academy.prompts.edit', ['academyPromptTemplate' => $prompt])->with('success', 'Academy prompt created.'); } public function promptsEdit(AcademyPromptTemplate $academyPromptTemplate): Response { return $this->renderForm('prompts', $academyPromptTemplate); } public function promptsUpdate(UpsertAcademyPromptTemplateRequest $request, AcademyPromptTemplate $academyPromptTemplate): RedirectResponse { $academyPromptTemplate->forceFill($this->persistPromptAttributes($request, $academyPromptTemplate))->save(); $this->cache->clearAll(); return redirect()->route('admin.academy.prompts.edit', ['academyPromptTemplate' => $academyPromptTemplate])->with('success', 'Academy prompt updated.'); } public function promptsDestroy(AcademyPromptTemplate $academyPromptTemplate): RedirectResponse { $academyPromptTemplate->delete(); $this->cache->clearAll(); return redirect()->route('admin.academy.prompts.index')->with('success', 'Academy prompt deleted.'); } public function packsIndex(): Response { return $this->renderIndex('packs'); } public function packsCreate(): Response { return $this->renderForm('packs', new AcademyPromptPack); } public function packsStore(UpsertAcademyPromptPackRequest $request): RedirectResponse { $pack = new AcademyPromptPack; $pack->fill(collect($request->validated())->except('prompt_ids')->all())->save(); $this->syncPackItems($pack, $request->validated('prompt_ids', [])); $this->cache->clearAll(); return redirect()->route('admin.academy.packs.edit', ['academyPromptPack' => $pack])->with('success', 'Academy prompt pack created.'); } public function packsEdit(AcademyPromptPack $academyPromptPack): Response { $academyPromptPack->load('prompts'); return $this->renderForm('packs', $academyPromptPack); } public function packsUpdate(UpsertAcademyPromptPackRequest $request, AcademyPromptPack $academyPromptPack): RedirectResponse { $academyPromptPack->fill(collect($request->validated())->except('prompt_ids')->all())->save(); $this->syncPackItems($academyPromptPack, $request->validated('prompt_ids', [])); $this->cache->clearAll(); return redirect()->route('admin.academy.packs.edit', ['academyPromptPack' => $academyPromptPack])->with('success', 'Academy prompt pack updated.'); } public function packsDestroy(AcademyPromptPack $academyPromptPack): RedirectResponse { $academyPromptPack->delete(); $this->cache->clearAll(); return redirect()->route('admin.academy.packs.index')->with('success', 'Academy prompt pack deleted.'); } public function challengesIndex(): Response { return $this->renderIndex('challenges'); } public function challengesCreate(): Response { return $this->renderForm('challenges', new AcademyChallenge); } public function challengesStore(UpsertAcademyChallengeRequest $request): RedirectResponse { $challenge = new AcademyChallenge; $challenge->fill($request->validated())->save(); $this->cache->clearAll(); return redirect()->route('admin.academy.challenges.edit', ['academyChallenge' => $challenge])->with('success', 'Academy challenge created.'); } public function challengesEdit(AcademyChallenge $academyChallenge): Response { return $this->renderForm('challenges', $academyChallenge); } public function challengesUpdate(UpsertAcademyChallengeRequest $request, AcademyChallenge $academyChallenge): RedirectResponse { $academyChallenge->fill($request->validated())->save(); $this->cache->clearAll(); return redirect()->route('admin.academy.challenges.edit', ['academyChallenge' => $academyChallenge])->with('success', 'Academy challenge updated.'); } public function challengesDestroy(AcademyChallenge $academyChallenge): RedirectResponse { $academyChallenge->delete(); $this->cache->clearAll(); return redirect()->route('admin.academy.challenges.index')->with('success', 'Academy challenge deleted.'); } public function badgesIndex(): Response { return $this->renderIndex('badges'); } public function badgesCreate(): Response { return $this->renderForm('badges', new AcademyBadge); } public function badgesStore(UpsertAcademyBadgeRequest $request): RedirectResponse { $badge = new AcademyBadge; $badge->fill($request->validated())->save(); return redirect()->route('admin.academy.badges.edit', ['academyBadge' => $badge])->with('success', 'Academy badge created.'); } public function badgesEdit(AcademyBadge $academyBadge): Response { return $this->renderForm('badges', $academyBadge); } public function badgesUpdate(UpsertAcademyBadgeRequest $request, AcademyBadge $academyBadge): RedirectResponse { $academyBadge->fill($request->validated())->save(); return redirect()->route('admin.academy.badges.edit', ['academyBadge' => $academyBadge])->with('success', 'Academy badge updated.'); } public function badgesDestroy(AcademyBadge $academyBadge): RedirectResponse { $academyBadge->delete(); return redirect()->route('admin.academy.badges.index')->with('success', 'Academy badge deleted.'); } public function submissionsIndex(Request $request): Response { $submissions = AcademyChallengeSubmission::query() ->with(['challenge', 'artwork', 'user']) ->latest('submitted_at') ->paginate(25) ->withQueryString(); $submissions->getCollection()->transform(fn (AcademyChallengeSubmission $submission): array => [ 'id' => (int) $submission->id, 'moderation_status' => (string) $submission->moderation_status, 'submitted_at' => $submission->submitted_at?->toISOString(), 'ai_tool_used' => (string) ($submission->ai_tool_used ?? ''), 'prompt_used' => (string) ($submission->prompt_used ?? ''), 'workflow_notes' => (string) ($submission->workflow_notes ?? ''), 'challenge' => $submission->challenge ? [ 'title' => (string) $submission->challenge->title, 'slug' => (string) $submission->challenge->slug, ] : null, 'user' => $submission->user ? [ 'name' => (string) $submission->user->name, 'username' => (string) ($submission->user->username ?? ''), ] : null, 'artwork' => $submission->artwork ? [ 'id' => (int) $submission->artwork->id, 'title' => (string) ($submission->artwork->title ?? 'Untitled artwork'), 'thumb_url' => $submission->artwork->thumbUrl('sm'), ] : null, 'approve_url' => route('admin.academy.submissions.approve', ['academyChallengeSubmission' => $submission]), 'reject_url' => route('admin.academy.submissions.reject', ['academyChallengeSubmission' => $submission]), ]); return Inertia::render('Admin/Academy/Submissions', [ 'submissions' => $submissions, ]); } public function approveSubmission(AcademyChallengeSubmission $academyChallengeSubmission): RedirectResponse { $academyChallengeSubmission->forceFill(['moderation_status' => 'approved'])->save(); return back()->with('success', 'Challenge submission approved.'); } public function rejectSubmission(AcademyChallengeSubmission $academyChallengeSubmission): RedirectResponse { $academyChallengeSubmission->forceFill(['moderation_status' => 'rejected'])->save(); return back()->with('success', 'Challenge submission rejected.'); } private function renderIndex(string $resource): Response { $meta = $this->resourceMeta($resource); $items = $meta['model']::query()->latest('updated_at')->paginate(25)->withQueryString(); $items->getCollection()->transform(fn (Model $model): array => $this->serializeIndexItem($resource, $model)); return Inertia::render('Admin/Academy/CrudIndex', [ 'resource' => $resource, 'title' => $meta['title'], 'subtitle' => $meta['subtitle'], 'items' => $items, 'columns' => $meta['columns'], 'createUrl' => route($meta['route_base'].'.create'), ]); } private function renderForm(string $resource, Model $record): Response { $meta = $this->resourceMeta($resource); return Inertia::render('Admin/Academy/CrudForm', [ 'resource' => $resource, 'title' => $record->exists ? 'Edit '.$meta['singular'] : 'Create '.$meta['singular'], 'subtitle' => $meta['subtitle'], 'fields' => $meta['fields'], 'record' => $this->serializeFormRecord($resource, $record), 'submitUrl' => $record->exists ? route($meta['route_base'].'.update', $this->routeParams($resource, $record)) : route($meta['route_base'].'.store'), 'indexUrl' => route($meta['route_base'].'.index'), 'destroyUrl' => $record->exists ? route($meta['route_base'].'.destroy', $this->routeParams($resource, $record)) : null, 'method' => $record->exists ? 'patch' : 'post', 'editorContext' => $this->formEditorContext($resource), ]); } private function formEditorContext(string $resource): array { if ($resource !== 'lessons') { return []; } return [ 'coverUploadUrl' => route('api.studio.academy.lessons.media.upload'), 'coverDeleteUrl' => route('api.studio.academy.lessons.media.destroy'), 'bodyMediaUploadUrl' => route('api.studio.academy.lessons.media.upload'), 'bodyMediaDeleteUrl' => route('api.studio.academy.lessons.media.destroy'), 'bodyMediaAssetsUrl' => route('api.studio.academy.lessons.media.assets'), 'coverCdnBaseUrl' => rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/'), 'categories' => AcademyCategory::query() ->where('type', 'lesson') ->orderBy('order_num') ->orderBy('name') ->get() ->map(fn (AcademyCategory $category): array => $this->serializeCategoryOption($category)) ->values() ->all(), 'categoryStoreUrl' => route('api.academy.categories.store'), 'categoryManageUrl' => route('admin.academy.categories.index'), ]; } private function resourceMeta(string $resource): array { return match ($resource) { 'categories' => [ 'model' => AcademyCategory::class, 'title' => 'Academy Categories', 'singular' => 'category', 'subtitle' => 'Manage lesson, prompt, pack, and challenge categories.', 'route_base' => 'admin.academy.categories', 'columns' => ['name', 'type', 'slug', 'active'], 'fields' => [ ['name' => 'type', 'label' => 'Type', 'type' => 'select', 'options' => [['value' => 'lesson', 'label' => 'Lesson'], ['value' => 'prompt', 'label' => 'Prompt'], ['value' => 'challenge', 'label' => 'Challenge'], ['value' => 'pack', 'label' => 'Pack']]], ['name' => 'name', 'label' => 'Name', 'type' => 'text'], ['name' => 'slug', 'label' => 'Slug', 'type' => 'text'], ['name' => 'description', 'label' => 'Description', 'type' => 'textarea'], ['name' => 'icon', 'label' => 'Icon', 'type' => 'text'], ['name' => 'order_num', 'label' => 'Order', 'type' => 'number'], ['name' => 'active', 'label' => 'Active', 'type' => 'checkbox'], ], ], 'lessons' => [ 'model' => AcademyLesson::class, 'title' => 'Academy Lessons', 'singular' => 'lesson', 'subtitle' => 'Create and publish Academy lessons.', 'route_base' => 'admin.academy.lessons', 'columns' => ['title', 'difficulty', 'access_level', 'featured', 'active'], 'fields' => [ ['name' => 'category_id', 'label' => 'Category', 'type' => 'select', 'options' => $this->categoryOptions('lesson')], ['name' => 'title', 'label' => 'Title', 'type' => 'text'], ['name' => 'slug', 'label' => 'Slug', 'type' => 'text'], ['name' => 'excerpt', 'label' => 'Excerpt', 'type' => 'textarea'], ['name' => 'content', 'label' => 'Content', 'type' => 'textarea'], ['name' => 'difficulty', 'label' => 'Difficulty', 'type' => 'select', 'options' => $this->difficultyOptions()], ['name' => 'access_level', 'label' => 'Access', 'type' => 'select', 'options' => $this->accessOptions()], ['name' => 'lesson_type', 'label' => 'Lesson Type', 'type' => 'text'], ['name' => 'cover_image', 'label' => 'Cover Image', 'type' => 'text'], ['name' => 'video_url', 'label' => 'Video URL', 'type' => 'text'], ['name' => 'reading_minutes', 'label' => 'Reading Minutes', 'type' => 'number'], ['name' => 'published_at', 'label' => 'Published At', 'type' => 'datetime-local'], ['name' => 'seo_title', 'label' => 'SEO Title', 'type' => 'text'], ['name' => 'seo_description', 'label' => 'SEO Description', 'type' => 'textarea'], ['name' => 'featured', 'label' => 'Featured', 'type' => 'checkbox'], ['name' => 'active', 'label' => 'Active', 'type' => 'checkbox'], ], ], 'prompts' => [ 'model' => AcademyPromptTemplate::class, 'title' => 'Academy Prompt Templates', 'singular' => 'prompt template', 'subtitle' => 'Manage prompt previews, premium prompts, and prompt of the week.', 'route_base' => 'admin.academy.prompts', 'columns' => ['title', 'difficulty', 'access_level', 'prompt_of_week', 'active'], 'fields' => [ ['name' => 'category_id', 'label' => 'Category', 'type' => 'select', 'options' => $this->categoryOptions('prompt')], ['name' => 'title', 'label' => 'Title', 'type' => 'text'], ['name' => 'slug', 'label' => 'Slug', 'type' => 'text'], ['name' => 'excerpt', 'label' => 'Excerpt', 'type' => 'textarea'], ['name' => 'prompt', 'label' => 'Prompt', 'type' => 'textarea'], ['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' => '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'], ['name' => 'tags', 'label' => 'Tags', 'type' => 'csv'], ['name' => 'preview_image', 'label' => 'Preview Image URL', 'type' => 'text'], ['name' => 'published_at', 'label' => 'Published At', 'type' => 'datetime-local'], ['name' => 'seo_title', 'label' => 'SEO Title', 'type' => 'text'], ['name' => 'seo_description', 'label' => 'SEO Description', 'type' => 'textarea'], ['name' => 'featured', 'label' => 'Featured', 'type' => 'checkbox'], ['name' => 'prompt_of_week', 'label' => 'Prompt Of Week', 'type' => 'checkbox'], ['name' => 'active', 'label' => 'Active', 'type' => 'checkbox'], ], ], 'packs' => [ 'model' => AcademyPromptPack::class, 'title' => 'Academy Prompt Packs', 'singular' => 'prompt pack', 'subtitle' => 'Bundle Academy prompts into reusable packs.', 'route_base' => 'admin.academy.packs', 'columns' => ['title', 'access_level', 'featured', 'active'], 'fields' => [ ['name' => 'title', 'label' => 'Title', 'type' => 'text'], ['name' => 'slug', 'label' => 'Slug', 'type' => 'text'], ['name' => 'excerpt', 'label' => 'Excerpt', 'type' => 'textarea'], ['name' => 'description', 'label' => 'Description', 'type' => 'textarea'], ['name' => 'access_level', 'label' => 'Access', 'type' => 'select', 'options' => $this->accessOptions()], ['name' => 'one_time_price_cents', 'label' => 'One-time Price (cents)', 'type' => 'number'], ['name' => 'currency', 'label' => 'Currency', 'type' => 'text'], ['name' => 'cover_image', 'label' => 'Cover Image', 'type' => 'text'], ['name' => 'tags', 'label' => 'Tags', 'type' => 'csv'], ['name' => 'prompt_ids', 'label' => 'Prompt Templates', 'type' => 'multiselect', 'options' => $this->promptOptions()], ['name' => 'published_at', 'label' => 'Published At', 'type' => 'datetime-local'], ['name' => 'featured', 'label' => 'Featured', 'type' => 'checkbox'], ['name' => 'active', 'label' => 'Active', 'type' => 'checkbox'], ], ], 'challenges' => [ 'model' => AcademyChallenge::class, 'title' => 'Academy Challenges', 'singular' => 'challenge', 'subtitle' => 'Create and moderate Academy challenge briefs.', 'route_base' => 'admin.academy.challenges', 'columns' => ['title', 'status', 'access_level', 'featured', 'active'], 'fields' => [ ['name' => 'title', 'label' => 'Title', 'type' => 'text'], ['name' => 'slug', 'label' => 'Slug', 'type' => 'text'], ['name' => 'excerpt', 'label' => 'Excerpt', 'type' => 'textarea'], ['name' => 'description', 'label' => 'Description', 'type' => 'textarea'], ['name' => 'brief', 'label' => 'Brief', 'type' => 'textarea'], ['name' => 'rules', 'label' => 'Rules', 'type' => 'textarea'], ['name' => 'access_level', 'label' => 'Access', 'type' => 'select', 'options' => $this->accessOptions()], ['name' => 'status', 'label' => 'Status', 'type' => 'select', 'options' => [['value' => 'draft', 'label' => 'Draft'], ['value' => 'scheduled', 'label' => 'Scheduled'], ['value' => 'active', 'label' => 'Active'], ['value' => 'voting', 'label' => 'Voting'], ['value' => 'completed', 'label' => 'Completed'], ['value' => 'archived', 'label' => 'Archived']]], ['name' => 'starts_at', 'label' => 'Starts At', 'type' => 'datetime-local'], ['name' => 'ends_at', 'label' => 'Ends At', 'type' => 'datetime-local'], ['name' => 'voting_starts_at', 'label' => 'Voting Starts At', 'type' => 'datetime-local'], ['name' => 'voting_ends_at', 'label' => 'Voting Ends At', 'type' => 'datetime-local'], ['name' => 'cover_image', 'label' => 'Cover Image', 'type' => 'text'], ['name' => 'prize_text', 'label' => 'Prize Text', 'type' => 'text'], ['name' => 'required_tags', 'label' => 'Required Tags', 'type' => 'csv'], ['name' => 'allowed_categories', 'label' => 'Allowed Categories', 'type' => 'csv'], ['name' => 'featured', 'label' => 'Featured', 'type' => 'checkbox'], ['name' => 'active', 'label' => 'Active', 'type' => 'checkbox'], ], ], 'badges' => [ 'model' => AcademyBadge::class, 'title' => 'Academy Badges', 'singular' => 'badge', 'subtitle' => 'Define Academy plan and achievement badges.', 'route_base' => 'admin.academy.badges', 'columns' => ['name', 'badge_type', 'slug', 'active'], 'fields' => [ ['name' => 'name', 'label' => 'Name', 'type' => 'text'], ['name' => 'slug', 'label' => 'Slug', 'type' => 'text'], ['name' => 'description', 'label' => 'Description', 'type' => 'textarea'], ['name' => 'icon', 'label' => 'Icon', 'type' => 'text'], ['name' => 'badge_type', 'label' => 'Badge Type', 'type' => 'text'], ['name' => 'rules', 'label' => 'Rules JSON', 'type' => 'json'], ['name' => 'active', 'label' => 'Active', 'type' => 'checkbox'], ], ], default => throw new \InvalidArgumentException('Unknown Academy resource ['.$resource.'].'), }; } private function serializeIndexItem(string $resource, Model $model): array { return match ($resource) { 'categories' => [ 'id' => (int) $model->id, 'name' => (string) $model->name, 'type' => (string) $model->type, 'slug' => (string) $model->slug, 'active' => (bool) $model->active, 'edit_url' => route('admin.academy.categories.edit', ['academyCategory' => $model]), 'destroy_url' => route('admin.academy.categories.destroy', ['academyCategory' => $model]), ], 'lessons' => [ 'id' => (int) $model->id, 'title' => (string) $model->title, 'difficulty' => (string) $model->difficulty, 'access_level' => (string) $model->access_level, 'featured' => (bool) $model->featured, 'active' => (bool) $model->active, 'edit_url' => route('admin.academy.lessons.edit', ['academyLesson' => $model]), 'destroy_url' => route('admin.academy.lessons.destroy', ['academyLesson' => $model]), ], 'prompts' => [ 'id' => (int) $model->id, 'title' => (string) $model->title, 'difficulty' => (string) $model->difficulty, 'access_level' => (string) $model->access_level, 'prompt_of_week' => (bool) $model->prompt_of_week, 'active' => (bool) $model->active, 'edit_url' => route('admin.academy.prompts.edit', ['academyPromptTemplate' => $model]), 'destroy_url' => route('admin.academy.prompts.destroy', ['academyPromptTemplate' => $model]), ], 'packs' => [ 'id' => (int) $model->id, 'title' => (string) $model->title, 'access_level' => (string) $model->access_level, 'featured' => (bool) $model->featured, 'active' => (bool) $model->active, 'edit_url' => route('admin.academy.packs.edit', ['academyPromptPack' => $model]), 'destroy_url' => route('admin.academy.packs.destroy', ['academyPromptPack' => $model]), ], 'challenges' => [ 'id' => (int) $model->id, 'title' => (string) $model->title, 'status' => (string) $model->status, 'access_level' => (string) $model->access_level, 'featured' => (bool) $model->featured, 'active' => (bool) $model->active, 'edit_url' => route('admin.academy.challenges.edit', ['academyChallenge' => $model]), 'destroy_url' => route('admin.academy.challenges.destroy', ['academyChallenge' => $model]), ], 'badges' => [ 'id' => (int) $model->id, 'name' => (string) $model->name, 'badge_type' => (string) $model->badge_type, 'slug' => (string) $model->slug, 'active' => (bool) $model->active, 'edit_url' => route('admin.academy.badges.edit', ['academyBadge' => $model]), 'destroy_url' => route('admin.academy.badges.destroy', ['academyBadge' => $model]), ], default => [], }; } private function serializeFormRecord(string $resource, Model $record): array { return match ($resource) { 'categories' => [ 'type' => (string) ($record->type ?? 'lesson'), 'name' => (string) ($record->name ?? ''), 'slug' => (string) ($record->slug ?? ''), 'description' => (string) ($record->description ?? ''), 'icon' => (string) ($record->icon ?? ''), 'order_num' => (int) ($record->order_num ?? 0), 'active' => (bool) ($record->active ?? true), ], 'lessons' => [ 'category_id' => $record->category_id, 'title' => (string) ($record->title ?? ''), 'slug' => (string) ($record->slug ?? ''), 'excerpt' => (string) ($record->excerpt ?? ''), 'content' => (string) ($record->content ?? ''), 'difficulty' => (string) ($record->difficulty ?? 'beginner'), 'access_level' => (string) ($record->access_level ?? 'free'), 'lesson_type' => (string) ($record->lesson_type ?? 'article'), 'cover_image' => (string) ($record->cover_image ?? ''), 'cover_image_url' => $this->resolveLessonCoverImageUrl((string) ($record->cover_image ?? '')), 'video_url' => (string) ($record->video_url ?? ''), 'reading_minutes' => (int) ($record->reading_minutes ?? 5), 'published_at' => optional($record->published_at)?->format('Y-m-d\TH:i'), 'seo_title' => (string) ($record->seo_title ?? ''), 'seo_description' => (string) ($record->seo_description ?? ''), 'featured' => (bool) ($record->featured ?? false), 'active' => (bool) ($record->active ?? true), 'blocks' => $record instanceof AcademyLesson ? $record->blocks->map(fn (AcademyLessonBlock $block): array => $this->serializeLessonBlock($block))->values()->all() : [], ], 'prompts' => [ 'category_id' => $record->category_id, 'title' => (string) ($record->title ?? ''), 'slug' => (string) ($record->slug ?? ''), 'excerpt' => (string) ($record->excerpt ?? ''), 'prompt' => (string) ($record->prompt ?? ''), 'negative_prompt' => (string) ($record->negative_prompt ?? ''), 'usage_notes' => (string) ($record->usage_notes ?? ''), 'workflow_notes' => (string) ($record->workflow_notes ?? ''), 'difficulty' => (string) ($record->difficulty ?? 'beginner'), 'access_level' => (string) ($record->access_level ?? 'free'), 'aspect_ratio' => (string) ($record->aspect_ratio ?? ''), 'tags' => implode(', ', (array) ($record->tags ?? [])), 'preview_image' => (string) ($record->preview_image ?? ''), 'preview_image_url' => $this->resolvePromptPreviewImageUrl((string) ($record->preview_image ?? '')), 'preview_image_file' => null, 'published_at' => optional($record->published_at)?->format('Y-m-d\TH:i'), 'seo_title' => (string) ($record->seo_title ?? ''), 'seo_description' => (string) ($record->seo_description ?? ''), 'featured' => (bool) ($record->featured ?? false), 'prompt_of_week' => (bool) ($record->prompt_of_week ?? false), 'active' => (bool) ($record->active ?? true), ], 'packs' => [ 'title' => (string) ($record->title ?? ''), 'slug' => (string) ($record->slug ?? ''), 'excerpt' => (string) ($record->excerpt ?? ''), 'description' => (string) ($record->description ?? ''), 'access_level' => (string) ($record->access_level ?? 'creator'), 'one_time_price_cents' => $record->one_time_price_cents, 'currency' => (string) ($record->currency ?? 'EUR'), 'cover_image' => (string) ($record->cover_image ?? ''), 'tags' => implode(', ', (array) ($record->tags ?? [])), 'prompt_ids' => $record instanceof AcademyPromptPack ? $record->prompts->pluck('id')->all() : [], 'published_at' => optional($record->published_at)?->format('Y-m-d\TH:i'), 'featured' => (bool) ($record->featured ?? false), 'active' => (bool) ($record->active ?? true), ], 'challenges' => [ 'title' => (string) ($record->title ?? ''), 'slug' => (string) ($record->slug ?? ''), 'excerpt' => (string) ($record->excerpt ?? ''), 'description' => (string) ($record->description ?? ''), 'brief' => (string) ($record->brief ?? ''), 'rules' => (string) ($record->rules ?? ''), 'access_level' => (string) ($record->access_level ?? 'free'), 'status' => (string) ($record->status ?? 'draft'), 'starts_at' => optional($record->starts_at)?->format('Y-m-d\TH:i'), 'ends_at' => optional($record->ends_at)?->format('Y-m-d\TH:i'), 'voting_starts_at' => optional($record->voting_starts_at)?->format('Y-m-d\TH:i'), 'voting_ends_at' => optional($record->voting_ends_at)?->format('Y-m-d\TH:i'), 'cover_image' => (string) ($record->cover_image ?? ''), 'prize_text' => (string) ($record->prize_text ?? ''), 'required_tags' => implode(', ', (array) ($record->required_tags ?? [])), 'allowed_categories' => implode(', ', (array) ($record->allowed_categories ?? [])), 'featured' => (bool) ($record->featured ?? false), 'active' => (bool) ($record->active ?? true), ], 'badges' => [ 'name' => (string) ($record->name ?? ''), 'slug' => (string) ($record->slug ?? ''), 'description' => (string) ($record->description ?? ''), 'icon' => (string) ($record->icon ?? ''), 'badge_type' => (string) ($record->badge_type ?? 'achievement'), 'rules' => json_encode((array) ($record->rules ?? []), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES), 'active' => (bool) ($record->active ?? true), ], default => [], }; } /** * @return array */ private function persistLessonAttributes(UpsertAcademyLessonRequest $request, ?AcademyLesson $lesson = null): array { $validated = $request->validated(); unset($validated['blocks']); $currentCoverImage = trim((string) ($lesson?->cover_image ?? '')); $nextCoverImage = filled($validated['cover_image'] ?? null) ? trim((string) $validated['cover_image']) : null; if ($currentCoverImage !== '' && $currentCoverImage !== (string) $nextCoverImage) { $this->deleteStoredLessonCoverIfLocal($currentCoverImage); } $validated['cover_image'] = $nextCoverImage; // Auto-publish: if marked active but no published_at set, default to now. if (! empty($validated['active']) && empty($validated['published_at'])) { $validated['published_at'] = now(); } return $validated; } /** * @param array> $blocks */ private function syncLessonBlocks(AcademyLesson $lesson, array $blocks): void { $lesson->loadMissing(['blocks.comparisonResults']); $existingBlocks = $lesson->blocks->keyBy(fn (AcademyLessonBlock $block): int => (int) $block->id); $retainedBlockIds = []; foreach ($blocks as $index => $blockData) { $blockId = isset($blockData['id']) ? (int) $blockData['id'] : null; $block = $blockId !== null ? $existingBlocks->get($blockId) : null; if (! $block instanceof AcademyLessonBlock) { $block = new AcademyLessonBlock; $block->lesson()->associate($lesson); } $payload = is_array($blockData['payload'] ?? null) ? $blockData['payload'] : []; $block->fill([ 'type' => (string) ($blockData['type'] ?? 'ai_comparison'), 'title' => $this->nullableTrimmedString($blockData['title'] ?? null) ?? $this->nullableTrimmedString($payload['title'] ?? null), 'payload' => $this->normalizeLessonBlockPayload($payload), 'sort_order' => (int) ($blockData['sort_order'] ?? $index), 'active' => (bool) ($blockData['active'] ?? true), ]); $block->save(); $retainedBlockIds[] = (int) $block->id; $this->syncLessonBlockComparisonResults($block, is_array($blockData['comparison_results'] ?? null) ? $blockData['comparison_results'] : []); } $lesson->blocks ->filter(fn (AcademyLessonBlock $block): bool => ! in_array((int) $block->id, $retainedBlockIds, true)) ->each(function (AcademyLessonBlock $block): void { foreach ($block->comparisonResults as $result) { $this->deleteStoredLessonMediaIfLocal($result->image_path); $this->deleteStoredLessonMediaIfLocal($result->thumb_path); } $block->delete(); }); } /** * @param array> $results */ private function syncLessonBlockComparisonResults(AcademyLessonBlock $block, array $results): void { $existingResults = $block->comparisonResults->keyBy(fn (AcademyAiComparisonResult $result): int => (int) $result->id); $retainedResultIds = []; foreach ($results as $index => $resultData) { $resultId = isset($resultData['id']) ? (int) $resultData['id'] : null; $result = $resultId !== null ? $existingResults->get($resultId) : null; if (! $result instanceof AcademyAiComparisonResult) { $result = new AcademyAiComparisonResult; $result->block()->associate($block); } $previousImagePath = (string) ($result->image_path ?? ''); $previousThumbPath = (string) ($result->thumb_path ?? ''); $nextImagePath = $this->nullableTrimmedString($resultData['image_path'] ?? null); $nextThumbPath = $this->nullableTrimmedString($resultData['thumb_path'] ?? null); $result->fill([ 'provider' => $this->nullableTrimmedString($resultData['provider'] ?? null), 'model_name' => $this->nullableTrimmedString($resultData['model_name'] ?? null), 'image_path' => $nextImagePath, 'thumb_path' => $nextThumbPath, 'settings' => $this->nullableTrimmedString($resultData['settings'] ?? null), 'strengths' => $this->nullableTrimmedString($resultData['strengths'] ?? null), 'weaknesses' => $this->nullableTrimmedString($resultData['weaknesses'] ?? null), 'best_for' => $this->nullableTrimmedString($resultData['best_for'] ?? null), 'score' => $resultData['score'] ?? null, 'sort_order' => (int) ($resultData['sort_order'] ?? $index), 'active' => (bool) ($resultData['active'] ?? true), ]); $result->save(); if ($previousImagePath !== '' && $previousImagePath !== (string) $nextImagePath) { $this->deleteStoredLessonMediaIfLocal($previousImagePath); } if ($previousThumbPath !== '' && $previousThumbPath !== (string) $nextThumbPath) { $this->deleteStoredLessonMediaIfLocal($previousThumbPath); } $retainedResultIds[] = (int) $result->id; } $block->comparisonResults ->filter(fn (AcademyAiComparisonResult $result): bool => ! in_array((int) $result->id, $retainedResultIds, true)) ->each(function (AcademyAiComparisonResult $result): void { $this->deleteStoredLessonMediaIfLocal($result->image_path); $this->deleteStoredLessonMediaIfLocal($result->thumb_path); $result->delete(); }); } /** * @param array $payload * @return array */ private function normalizeLessonBlockPayload(array $payload): array { $criteria = collect($payload['criteria'] ?? []) ->map(fn ($criterion): string => trim((string) $criterion)) ->filter(static fn (string $criterion): bool => $criterion !== '') ->values() ->all(); return [ 'title' => $this->nullableTrimmedString($payload['title'] ?? null), 'intro' => $this->nullableTrimmedString($payload['intro'] ?? null), 'prompt' => $this->nullableTrimmedString($payload['prompt'] ?? null), 'negative_prompt' => $this->nullableTrimmedString($payload['negative_prompt'] ?? null), 'aspect_ratio' => $this->nullableTrimmedString($payload['aspect_ratio'] ?? null), 'criteria' => $criteria, ]; } /** * @return array */ private function serializeLessonBlock(AcademyLessonBlock $block): array { $payload = is_array($block->payload) ? $block->payload : []; return [ 'id' => (int) $block->id, 'type' => (string) $block->type, 'title' => (string) ($block->title ?? ''), 'payload' => $this->normalizeLessonBlockPayload($payload), 'sort_order' => (int) $block->sort_order, 'active' => (bool) $block->active, 'comparison_results' => $block->comparisonResults->map(fn (AcademyAiComparisonResult $result): array => [ 'id' => (int) $result->id, 'provider' => (string) ($result->provider ?? ''), 'model_name' => (string) ($result->model_name ?? ''), 'image_path' => (string) $result->image_path, 'image_url' => $this->resolveLessonMediaUrl((string) $result->image_path), 'thumb_path' => (string) ($result->thumb_path ?? ''), 'thumb_url' => $this->resolveLessonMediaUrl((string) ($result->thumb_path ?? '')), 'settings' => (string) ($result->settings ?? ''), 'strengths' => (string) ($result->strengths ?? ''), 'weaknesses' => (string) ($result->weaknesses ?? ''), 'best_for' => (string) ($result->best_for ?? ''), 'score' => $result->score, 'sort_order' => (int) $result->sort_order, 'active' => (bool) $result->active, ])->values()->all(), ]; } /** * @return array */ private function persistPromptAttributes(UpsertAcademyPromptTemplateRequest $request, ?AcademyPromptTemplate $prompt = null): array { $validated = $request->validated(); unset($validated['preview_image_file']); $currentPreviewImage = (string) ($prompt?->preview_image ?? ''); $previewImageFile = $this->promptPreviewImageUpload($request); if ($previewImageFile instanceof UploadedFile) { $this->deleteStoredPromptPreviewIfLocal($currentPreviewImage); $validated['preview_image'] = $this->storePromptPreviewImage($previewImageFile); } else { $validated['preview_image'] = filled($validated['preview_image'] ?? null) ? trim((string) $validated['preview_image']) : null; } // Auto-publish: if marked active but no published_at set, default to now. if (! empty($validated['active']) && empty($validated['published_at'])) { $validated['published_at'] = now(); } return $validated; } private function promptPreviewImageUpload(UpsertAcademyPromptTemplateRequest $request): ?UploadedFile { $file = $request->file('preview_image_file'); if (! $file instanceof UploadedFile) { return null; } $pathName = trim((string) $file->getPathname()); if ($file->isValid() && $pathName !== '' && is_file($pathName) && is_readable($pathName)) { return $file; } throw ValidationException::withMessages([ 'preview_image_file' => $this->promptPreviewImageUploadErrorMessage($file), ]); } private function storePromptPreviewImage(UploadedFile $file): string { $pathName = trim((string) $file->getPathname()); if ($pathName === '' || ! is_file($pathName) || ! is_readable($pathName)) { throw ValidationException::withMessages([ 'preview_image_file' => $this->promptPreviewImageUploadErrorMessage($file), ]); } if (! function_exists('imagecreatefromstring') || ! function_exists('imagewebp')) { throw ValidationException::withMessages([ 'preview_image_file' => 'The server is missing WebP image support. Enable the GD WebP extension to upload prompt preview images.', ]); } $binary = @file_get_contents($pathName); if ($binary === false) { throw ValidationException::withMessages([ 'preview_image_file' => 'The uploaded preview image could not be opened for conversion. Please choose the file again and retry.', ]); } $image = @imagecreatefromstring($binary); if (! $image instanceof \GdImage) { throw ValidationException::withMessages([ 'preview_image_file' => 'The uploaded preview image format could not be converted. Please use JPG, PNG, or WEBP.', ]); } try { if (! imageistruecolor($image)) { imagepalettetotruecolor($image); } imagealphablending($image, true); imagesavealpha($image, true); ob_start(); $converted = imagewebp($image, 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.', ]); } $storedPath = self::PROMPT_PREVIEW_PREFIX.'/'.pathinfo(Str::replace('\\', '/', $file->hashName()), PATHINFO_FILENAME).'.webp'; Storage::disk($this->promptPreviewImageDisk())->put($storedPath, $webpBinary, ['visibility' => 'public']); } finally { imagedestroy($image); } return $storedPath; } private function deleteStoredPromptPreviewIfLocal(?string $path): void { $path = trim((string) $path); if ($path === '' || str_starts_with($path, 'http://') || str_starts_with($path, 'https://') || str_starts_with($path, '/')) { return; } if (! str_starts_with($path, self::PROMPT_PREVIEW_PREFIX.'/')) { return; } $disk = $this->promptPreviewImageDisk(); if (Storage::disk($disk)->exists($path)) { Storage::disk($disk)->delete($path); } } private function promptPreviewImageUploadErrorMessage(UploadedFile $file): string { return match ($file->getError()) { UPLOAD_ERR_INI_SIZE, UPLOAD_ERR_FORM_SIZE => 'The uploaded preview image exceeds the server upload limit.', UPLOAD_ERR_PARTIAL => 'The uploaded preview image was only partially received. Please retry the upload.', UPLOAD_ERR_NO_TMP_DIR => 'The server upload temp directory is unavailable. Check PHP upload temp configuration.', UPLOAD_ERR_CANT_WRITE => 'The server could not write the uploaded preview image to temporary storage.', UPLOAD_ERR_EXTENSION => 'A PHP extension blocked the preview image upload.', default => 'The uploaded preview image could not be read. Please choose the file again and retry.', }; } private function promptPreviewImageDisk(): string { return (string) config('uploads.object_storage.disk', 's3'); } private function resolvePromptPreviewImageUrl(?string $previewImage): ?string { $previewImage = trim((string) $previewImage); if ($previewImage === '') { return null; } if (str_starts_with($previewImage, 'http://') || str_starts_with($previewImage, 'https://') || str_starts_with($previewImage, '/')) { return $previewImage; } return Storage::disk($this->promptPreviewImageDisk())->url($previewImage); } private function resolveLessonMediaUrl(?string $path): ?string { $path = trim((string) $path); if ($path === '') { return null; } if (str_starts_with($path, 'http://') || str_starts_with($path, 'https://') || str_starts_with($path, '/')) { return $path; } return Storage::disk($this->promptPreviewImageDisk())->url($path); } private function deleteStoredLessonCoverIfLocal(?string $path): void { $path = trim((string) $path); if ($path === '' || str_starts_with($path, 'http://') || str_starts_with($path, 'https://') || str_starts_with($path, '/')) { return; } if (! str_starts_with($path, 'academy/lessons/covers/')) { return; } $disk = $this->promptPreviewImageDisk(); if (Storage::disk($disk)->exists($path)) { Storage::disk($disk)->delete($path); } } private function deleteStoredLessonMediaIfLocal(?string $path): void { $path = trim((string) $path); if ($path === '' || str_starts_with($path, 'http://') || str_starts_with($path, 'https://') || str_starts_with($path, '/')) { return; } if (! str_starts_with($path, 'academy/lessons/body/') && ! str_starts_with($path, 'academy/lessons/covers/')) { return; } $disk = $this->promptPreviewImageDisk(); if (Storage::disk($disk)->exists($path)) { Storage::disk($disk)->delete($path); } } private function nullableTrimmedString(mixed $value): ?string { $trimmed = trim((string) $value); return $trimmed === '' ? null : $trimmed; } private function resolveLessonCoverImageUrl(?string $coverImage): ?string { $coverImage = trim((string) $coverImage); if ($coverImage === '') { return null; } if (str_starts_with($coverImage, 'http://') || str_starts_with($coverImage, 'https://') || str_starts_with($coverImage, '/')) { return $coverImage; } return Storage::disk($this->promptPreviewImageDisk())->url($coverImage); } private function serializeCategoryOption(AcademyCategory $category): array { return [ 'id' => (int) $category->id, 'value' => (int) $category->id, 'label' => (string) $category->name, 'name' => (string) $category->name, 'slug' => (string) $category->slug, 'description' => (string) ($category->description ?? ''), 'order_num' => (int) ($category->order_num ?? 0), 'active' => (bool) ($category->active ?? true), 'edit_url' => route('admin.academy.categories.edit', ['academyCategory' => $category]), ]; } private function routeParams(string $resource, Model $record): array { return match ($resource) { 'categories' => ['academyCategory' => $record], 'lessons' => ['academyLesson' => $record], 'prompts' => ['academyPromptTemplate' => $record], 'packs' => ['academyPromptPack' => $record], 'challenges' => ['academyChallenge' => $record], 'badges' => ['academyBadge' => $record], default => [], }; } private function categoryOptions(string $type): array { return AcademyCategory::query() ->where('type', $type) ->orderBy('order_num') ->orderBy('name') ->get() ->map(fn (AcademyCategory $category): array => ['value' => $category->id, 'label' => $category->name]) ->prepend(['value' => '', 'label' => 'No category']) ->values() ->all(); } private function promptOptions(): array { return AcademyPromptTemplate::query() ->orderBy('title') ->get() ->map(fn (AcademyPromptTemplate $prompt): array => ['value' => $prompt->id, 'label' => $prompt->title]) ->values() ->all(); } private function difficultyOptions(): array { return collect((array) config('academy.difficulty_levels', [])) ->map(fn (string $value): array => ['value' => $value, 'label' => ucfirst($value)]) ->values() ->all(); } private function accessOptions(): array { return [ ['value' => 'free', 'label' => 'Free'], ['value' => 'creator', 'label' => 'Creator'], ['value' => 'pro', 'label' => 'Pro'], ]; } private function syncPackItems(AcademyPromptPack $pack, array $promptIds): void { AcademyPromptPackItem::query()->where('pack_id', $pack->id)->delete(); foreach (array_values($promptIds) as $index => $promptId) { AcademyPromptPackItem::query()->create([ 'pack_id' => $pack->id, 'prompt_template_id' => (int) $promptId, 'order_num' => $index, ]); } } }