[ 'courses' => AcademyCourse::query()->count(), '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' => [ 'courses' => route('admin.academy.courses.index'), '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 coursesIndex(): Response { return $this->renderIndex('courses'); } public function coursesCreate(): Response { return $this->renderForm('courses', new AcademyCourse); } public function coursesStore(UpsertAcademyCourseRequest $request): RedirectResponse { $course = new AcademyCourse; $course->fill($this->persistCourseAttributes($request))->save(); $this->cache->clearAll(); return redirect()->route('admin.academy.courses.edit', ['academyCourse' => $course])->with('success', 'Academy course created.'); } public function coursesEdit(AcademyCourse $academyCourse): Response { return $this->renderForm('courses', $academyCourse); } public function coursesUpdate(UpsertAcademyCourseRequest $request, AcademyCourse $academyCourse): RedirectResponse { $academyCourse->fill($this->persistCourseAttributes($request, $academyCourse))->save(); $this->cache->clearAll(); return redirect()->route('admin.academy.courses.edit', ['academyCourse' => $academyCourse])->with('success', 'Academy course updated.'); } public function coursesDestroy(AcademyCourse $academyCourse): RedirectResponse { $this->deleteStoredLessonCoverIfLocal((string) $academyCourse->cover_image); $this->deleteStoredLessonCoverIfLocal((string) $academyCourse->teaser_image); $academyCourse->delete(); $this->cache->clearAll(); return redirect()->route('admin.academy.courses.index')->with('success', 'Academy course deleted.'); } 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->syncLessonCourses($lesson, $request->validated('course_ids', [])); $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 { $this->createLessonRevision($academyLesson, $request->user(), 'Before lesson update'); $academyLesson->fill($this->persistLessonAttributes($request, $academyLesson))->save(); $this->syncLessonCourses($academyLesson, $request->validated('course_ids', [])); $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 lessonsRestoreRevision(Request $request, AcademyLesson $academyLesson, AcademyLessonRevision $academyLessonRevision): RedirectResponse { abort_unless((int) $academyLessonRevision->lesson_id === (int) $academyLesson->id, 404); $validated = $request->validate([ 'field' => ['nullable', 'string', 'in:category_id,title,slug,lesson_number,course_order,series_name,excerpt,content,difficulty,access_level,lesson_type,cover_image,article_cover_image,tags,video_url,reading_minutes,featured,active,published_at,seo_title,seo_description,course_ids,blocks'], ]); $field = filled($validated['field'] ?? null) ? (string) $validated['field'] : null; DB::transaction(function () use ($academyLesson, $academyLessonRevision, $field, $request): void { $note = $field ? sprintf('Before restoring %s from revision #%d', $field, $academyLessonRevision->id) : sprintf('Before restoring revision #%d', $academyLessonRevision->id); $this->createLessonRevision($academyLesson, $request->user(), $note); $this->applyLessonRevisionSnapshot($academyLesson, $academyLessonRevision, $field); }); $this->cache->clearAll(); return redirect()->route('admin.academy.lessons.edit', ['academyLesson' => $academyLesson])->with('success', $field ? sprintf('Lesson field "%s" restored from revision.', $field) : 'Lesson restored from revision.'); } 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); $this->deleteStoredLessonCoverIfLocal((string) $academyLesson->article_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); $query = $meta['model']::query()->latest('updated_at'); if ($resource === 'prompts') { $query->with('category'); } $items = $query->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, $record), ]); } private function formEditorContext(string $resource, Model $record): array { if ($resource === 'courses') { return [ 'links' => array_filter([ 'builder' => $record->exists ? route('admin.academy.courses.builder.edit', ['academyCourse' => $record]) : null, 'preview' => $record->exists ? route('academy.courses.show', ['course' => $record->slug]) : null, ]), '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'), '/'), 'outlineSummary' => $record instanceof AcademyCourse && $record->exists ? $this->serializeCourseOutlineSummary($record) : null, 'courseLessons' => $record instanceof AcademyCourse && $record->exists ? $this->serializeCourseEditorLessons($record) : [], 'availableLessons' => $record instanceof AcademyCourse && $record->exists ? $this->serializeCourseAvailableLessons($record) : [], 'attachLessonUrl' => $record instanceof AcademyCourse && $record->exists ? route('admin.academy.courses.lessons.attach', ['academyCourse' => $record]) : null, 'reorderUrl' => $record instanceof AcademyCourse && $record->exists ? route('admin.academy.courses.reorder', ['academyCourse' => $record]) : null, ]; } if ($resource === 'prompts') { return [ 'links' => array_filter([ 'preview' => $record->exists ? route('academy.prompts.show', ['slug' => $record->slug]) : null, ]), 'comparisonMediaUploadUrl' => route('api.studio.academy.lessons.media.upload'), 'comparisonMediaDeleteUrl' => route('api.studio.academy.lessons.media.destroy'), 'comparisonMediaAssetsUrl' => route('api.studio.academy.lessons.media.assets'), 'comparisonMediaBaseUrl' => rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/'), 'comparisonCodeLists' => [ 'providers' => array_values(array_filter(array_map('strval', (array) config('academy.prompt_comparison.providers', [])))), 'models' => array_values(array_filter(array_map('strval', (array) config('academy.prompt_comparison.models', [])))), ], ]; } if ($resource !== 'lessons') { return []; } return [ 'currentLessonId' => $record instanceof AcademyLesson && $record->exists ? (int) $record->id : null, 'currentLessonTitle' => $record instanceof AcademyLesson && $record->exists ? (string) $record->title : '', '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'), '/'), 'numbering' => $this->serializeLessonNumberingContext($record instanceof AcademyLesson ? $record : null), 'revisions' => $record instanceof AcademyLesson && $record->exists ? $this->serializeLessonRevisions($record) : [], 'categories' => AcademyCategory::query() ->where('type', 'lesson') ->orderBy('order_num') ->orderBy('name') ->get() ->map(fn (AcademyCategory $category): array => $this->serializeCategoryOption($category)) ->values() ->all(), 'courses' => $this->serializeLessonEditorCourses($record instanceof AcademyLesson ? $record : null), 'categoryStoreUrl' => route('api.academy.categories.store'), 'categoryManageUrl' => route('admin.academy.categories.index'), ]; } private function resourceMeta(string $resource): array { return match ($resource) { 'courses' => [ 'model' => AcademyCourse::class, 'title' => 'Academy Courses', 'singular' => 'course', 'subtitle' => 'Build structured learning paths from reusable Academy lessons.', 'route_base' => 'admin.academy.courses', 'columns' => ['title', 'difficulty', 'access_level', 'status', 'is_featured'], 'fields' => [ ['name' => 'title', 'label' => 'Title', 'type' => 'text'], ['name' => 'slug', 'label' => 'Slug', 'type' => 'text'], ['name' => 'subtitle', 'label' => 'Subtitle', 'type' => 'text'], ['name' => 'excerpt', 'label' => 'Excerpt', 'type' => 'textarea', 'rows' => 4], ['name' => 'description', 'label' => 'Description', 'type' => 'textarea', 'rows' => 8], ['name' => 'cover_image', 'label' => 'Cover Image', 'type' => 'text'], ['name' => 'teaser_image', 'label' => 'Teaser Image', 'type' => 'text'], ['name' => 'access_level', 'label' => 'Access', 'type' => 'select', 'options' => $this->courseAccessOptions()], ['name' => 'difficulty', 'label' => 'Difficulty', 'type' => 'select', 'options' => $this->courseDifficultyOptions()], ['name' => 'status', 'label' => 'Status', 'type' => 'select', 'options' => $this->courseStatusOptions()], ['name' => 'order_num', 'label' => 'Order', 'type' => 'number'], ['name' => 'estimated_minutes', 'label' => 'Estimated 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', 'rows' => 4], ['name' => 'meta_keywords', 'label' => 'Meta Keywords', 'type' => 'textarea', 'rows' => 3], ['name' => 'og_title', 'label' => 'OpenGraph Title', 'type' => 'text'], ['name' => 'og_description', 'label' => 'OpenGraph Description', 'type' => 'textarea', 'rows' => 4], ['name' => 'og_image', 'label' => 'OpenGraph Image', 'type' => 'text'], ['name' => 'is_featured', 'label' => 'Featured', 'type' => 'checkbox'], ], ], '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' => 'course_ids', 'label' => 'Courses', 'type' => 'multiselect', 'options' => $this->courseOptions()], ['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' => 'tags', 'label' => 'Tags', 'type' => 'csv'], ['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) { 'courses' => [ 'id' => (int) $model->id, 'title' => (string) $model->title, 'difficulty' => (string) $model->difficulty, 'access_level' => (string) $model->access_level, 'status' => (string) $model->status, 'is_featured' => (bool) $model->is_featured, '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]), ], '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, 'slug' => (string) $model->slug, 'excerpt' => (string) ($model->excerpt ?? ''), 'difficulty' => (string) $model->difficulty, 'access_level' => (string) $model->access_level, 'category_name' => (string) ($model->category?->name ?? ''), 'aspect_ratio' => (string) ($model->aspect_ratio ?? ''), 'featured' => (bool) $model->featured, 'prompt_of_week' => (bool) $model->prompt_of_week, 'active' => (bool) $model->active, 'preview_image_url' => $this->resolvePromptPreviewImageUrl((string) ($model->preview_image ?? '')), 'comparisons_count' => count($this->serializePromptToolNotes((array) ($model->tool_notes ?? []))), 'tags' => array_values(array_filter(array_map(static fn ($tag): string => trim((string) $tag), (array) ($model->tags ?? [])))), 'updated_at' => optional($model->updated_at)->toIso8601String(), 'preview_url' => route('academy.prompts.show', ['slug' => $model->slug]), '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) { 'courses' => [ 'title' => (string) ($record->title ?? ''), 'slug' => (string) ($record->slug ?? ''), 'subtitle' => (string) ($record->subtitle ?? ''), 'excerpt' => (string) ($record->excerpt ?? ''), 'description' => (string) ($record->description ?? ''), 'cover_image' => (string) ($record->cover_image ?? ''), 'cover_image_url' => $this->resolveLessonCoverImageUrl((string) ($record->cover_image ?? '')), 'teaser_image' => (string) ($record->teaser_image ?? ''), 'teaser_image_url' => $this->resolveLessonCoverImageUrl((string) ($record->teaser_image ?? '')), 'access_level' => (string) ($record->access_level ?? 'free'), 'difficulty' => (string) ($record->difficulty ?? 'beginner'), 'status' => (string) ($record->status ?? 'draft'), 'order_num' => (int) ($record->order_num ?? 0), 'estimated_minutes' => $record->estimated_minutes, 'published_at' => optional($record->published_at)?->format('Y-m-d\TH:i'), 'seo_title' => (string) ($record->seo_title ?? ''), 'seo_description' => (string) ($record->seo_description ?? ''), 'meta_keywords' => (string) ($record->meta_keywords ?? ''), 'og_title' => (string) ($record->og_title ?? ''), 'og_description' => (string) ($record->og_description ?? ''), 'og_image' => (string) ($record->og_image ?? ''), 'is_featured' => (bool) ($record->is_featured ?? false), ], '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, 'course_ids' => $record instanceof AcademyLesson ? $record->courses()->pluck('academy_courses.id')->map(static fn ($id): string => (string) $id)->values()->all() : [], 'title' => (string) ($record->title ?? ''), 'slug' => (string) ($record->slug ?? ''), 'lesson_number' => $record->lesson_number, 'course_order' => $record->course_order, 'series_name' => (string) ($record->series_name ?? ''), 'excerpt' => (string) ($record->excerpt ?? ''), 'content' => (string) ($record->content ?? ''), 'content_markdown' => (string) ($record->content_markdown ?? ''), '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 ?? '')), 'article_cover_image' => (string) ($record->article_cover_image ?? ''), 'article_cover_image_url' => $this->resolveLessonCoverImageUrl((string) ($record->article_cover_image ?? '')), 'tags' => implode(', ', (array) ($record->tags ?? [])), '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 ?? [])), 'tool_notes' => $this->serializePromptToolNotes((array) ($record->tool_notes ?? [])), '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['course_ids']); unset($validated['blocks']); $contentSource = in_array((string) ($validated['content_source'] ?? ''), ['html', 'markdown'], true) ? (string) $validated['content_source'] : ((string) ($lesson?->content_markdown ?? '') !== '' ? 'markdown' : 'html'); $contentMarkdown = $this->nullableTrimmedString($validated['content_markdown'] ?? null); $contentHtml = $this->nullableTrimmedString($validated['content'] ?? null); $currentCoverImage = trim((string) ($lesson?->cover_image ?? '')); $currentArticleCoverImage = trim((string) ($lesson?->article_cover_image ?? '')); $nextCoverImage = filled($validated['cover_image'] ?? null) ? trim((string) $validated['cover_image']) : null; $nextArticleCoverImage = filled($validated['article_cover_image'] ?? null) ? trim((string) $validated['article_cover_image']) : null; if ($currentCoverImage !== '' && $currentCoverImage !== (string) $nextCoverImage) { $this->deleteStoredLessonCoverIfLocal($currentCoverImage); } if ($currentArticleCoverImage !== '' && $currentArticleCoverImage !== (string) $nextArticleCoverImage) { $this->deleteStoredLessonCoverIfLocal($currentArticleCoverImage); } $validated['cover_image'] = $nextCoverImage; $validated['article_cover_image'] = $nextArticleCoverImage; $validated['content_markdown'] = $contentSource === 'markdown' ? $contentMarkdown : null; $validated['content'] = $contentSource === 'markdown' ? $contentHtml : $contentHtml; unset($validated['content_source']); if ($contentSource === 'markdown' && $contentMarkdown !== null) { $validated['content'] = $this->lessonMarkdownRenderer->render($contentMarkdown); } $validated['reading_minutes'] = $this->estimateLessonReadingMinutes( (string) ($validated['content'] ?? ''), (string) ($validated['content_markdown'] ?? ''), ); // 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 $courseIds */ private function syncLessonCourses(AcademyLesson $lesson, array $courseIds): void { $selectedCourseIds = collect($courseIds) ->map(static fn ($courseId): int => (int) $courseId) ->filter(static fn (int $courseId): bool => $courseId > 0) ->unique() ->values(); $existingCourseLessons = $lesson->courseLessons()->get()->keyBy(fn (AcademyCourseLesson $courseLesson): int => (int) $courseLesson->course_id); $affectedCourseIds = $existingCourseLessons->keys()->map(static fn ($courseId): int => (int) $courseId) ->merge($selectedCourseIds) ->unique() ->values(); foreach ($selectedCourseIds as $courseId) { if ($existingCourseLessons->has($courseId)) { continue; } AcademyCourseLesson::query()->create([ 'course_id' => $courseId, 'lesson_id' => $lesson->id, 'section_id' => null, 'order_num' => (int) ((AcademyCourseLesson::query()->where('course_id', $courseId)->max('order_num') ?? -1) + 1), 'is_required' => true, 'access_override' => null, 'unlock_after_lesson_id' => null, ]); } $existingCourseLessons ->reject(fn (AcademyCourseLesson $courseLesson, int $courseId): bool => $selectedCourseIds->contains($courseId)) ->each(fn (AcademyCourseLesson $courseLesson): bool|null => $courseLesson->delete()); if ($affectedCourseIds->isEmpty()) { return; } $affectedCourseIds->each(fn (int $courseId): bool => tap(true, fn () => $this->courseLessonOrdering->syncCourse($courseId))); $counts = AcademyCourseLesson::query() ->selectRaw('course_id, count(*) as aggregate') ->whereIn('course_id', $affectedCourseIds->all()) ->groupBy('course_id') ->pluck('aggregate', 'course_id'); AcademyCourse::query() ->whereIn('id', $affectedCourseIds->all()) ->get() ->each(function (AcademyCourse $course) use ($counts): void { $course->forceFill([ 'lessons_count_cache' => (int) ($counts[$course->id] ?? 0), ])->save(); }); } /** * @return array> */ private function courseOptions(): array { return AcademyCourse::query() ->orderByDesc('is_featured') ->orderBy('order_num') ->orderBy('title') ->get() ->map(fn (AcademyCourse $course): array => [ 'value' => (string) $course->id, 'label' => (string) $course->title, ]) ->values() ->all(); } /** * @return array>> */ private function serializeLessonNumberingContext(?AcademyLesson $currentLesson): array { $otherLessons = AcademyLesson::query() ->when($currentLesson?->exists, fn ($query) => $query->whereKeyNot($currentLesson->id)) ->get(['lesson_number', 'course_order']); return [ 'lesson_number' => $this->buildSequentialNumberSummary($otherLessons->pluck('lesson_number')->all()), 'course_order' => $this->buildSequentialNumberSummary($otherLessons->pluck('course_order')->all()), ]; } /** * @return array> */ private function serializeLessonEditorCourses(?AcademyLesson $currentLesson): array { return AcademyCourse::query() ->with([ 'courseLessons' => fn ($query) => $query ->with(['lesson:id,title,slug,lesson_number,series_name', 'section:id,title']) ->orderBy('order_num') ->orderBy('id'), ]) ->orderByDesc('is_featured') ->orderBy('order_num') ->orderBy('title') ->get() ->map(function (AcademyCourse $course) use ($currentLesson): array { $courseLessons = $course->courseLessons ->sortBy([['order_num', 'asc'], ['id', 'asc']]) ->values(); return [ 'value' => (string) $course->id, 'label' => (string) $course->title, 'description' => sprintf('%s ยท %s', (string) $course->difficulty, (string) $course->status), 'status' => (string) $course->status, 'difficulty' => (string) $course->difficulty, 'access_level' => (string) $course->access_level, 'lesson_count' => $courseLessons->count(), 'next_order_num' => (int) (($courseLessons->max('order_num') ?? -1) + 1), 'attach_url' => route('admin.academy.courses.lessons.attach', ['academyCourse' => $course]), 'reorder_url' => route('admin.academy.courses.reorder', ['academyCourse' => $course]), 'builder_url' => route('admin.academy.courses.builder.edit', ['academyCourse' => $course]), 'edit_url' => route('admin.academy.courses.edit', ['academyCourse' => $course]), 'preview_url' => route('academy.courses.show', ['course' => $course->slug]), 'lessons' => $courseLessons->map(function (AcademyCourseLesson $courseLesson) use ($currentLesson, $course): array { $lesson = $courseLesson->lesson; return [ 'id' => (int) $courseLesson->id, 'lesson_id' => (int) $courseLesson->lesson_id, 'title' => (string) ($lesson?->title ?? 'Untitled lesson'), 'slug' => (string) ($lesson?->slug ?? ''), 'section_id' => $courseLesson->section_id ? (int) $courseLesson->section_id : null, 'order_num' => (int) ($courseLesson->order_num ?? 0), 'display_order' => (int) ($courseLesson->order_num ?? 0) + 1, 'formatted_lesson_number' => $lesson?->formatted_lesson_number, 'series_name' => (string) ($lesson?->series_name ?? ''), 'section_title' => (string) ($courseLesson->section?->title ?? ''), 'is_required' => (bool) $courseLesson->is_required, 'is_current' => $currentLesson?->exists ? (int) $courseLesson->lesson_id === (int) $currentLesson->id : false, 'destroy_url' => route('admin.academy.courses.lessons.destroy', ['academyCourse' => $course, 'academyCourseLesson' => $courseLesson]), 'edit_url' => $lesson instanceof AcademyLesson ? route('admin.academy.lessons.edit', ['academyLesson' => $lesson]) : null, ]; })->all(), ]; }) ->values() ->all(); } /** * @return array> */ private function serializeLessonRevisions(AcademyLesson $lesson): array { return AcademyLessonRevision::query() ->with('user') ->where('lesson_id', $lesson->id) ->latest('id') ->limit(12) ->get() ->map(function (AcademyLessonRevision $revision) use ($lesson): array { $snapshot = is_array($revision->snapshot_json) ? $revision->snapshot_json : []; $fields = is_array($snapshot['fields'] ?? null) ? $snapshot['fields'] : []; return [ 'id' => (int) $revision->id, 'created_at' => $revision->created_at?->toISOString(), 'created_label' => $revision->created_at?->diffForHumans(), 'actor_name' => (string) ($revision->user?->name ?? 'Staff'), 'change_note' => (string) ($revision->change_note ?? ''), 'restore_url' => route('admin.academy.lessons.revisions.restore', [ 'academyLesson' => $lesson, 'academyLessonRevision' => $revision, ]), 'snapshot' => [ 'title' => (string) ($fields['title'] ?? ''), 'excerpt' => (string) ($fields['excerpt'] ?? ''), 'content_preview' => Str::limit(strip_tags((string) ($fields['content'] ?? '')), 180), 'course_count' => count((array) ($snapshot['course_ids'] ?? [])), 'block_count' => count((array) ($snapshot['blocks'] ?? [])), ], ]; }) ->values() ->all(); } /** * @param array $values * @return array> */ private function buildSequentialNumberSummary(array $values): array { $numbers = collect($values) ->map(static fn ($value): int => (int) $value) ->filter(static fn (int $value): bool => $value > 0) ->unique() ->sort() ->values(); $missing = []; $expected = 1; foreach ($numbers as $number) { while ($expected < $number) { $missing[] = $expected; $expected += 1; } $expected = $number + 1; } return [ 'suggested' => (int) ($missing[0] ?? (($numbers->last() ?? 0) + 1)), 'highest' => (int) ($numbers->last() ?? 0), 'used_count' => $numbers->count(), 'missing' => array_slice($missing, 0, 8), ]; } private function createLessonRevision(AcademyLesson $lesson, ?User $user, ?string $changeNote = null): AcademyLessonRevision { $lesson->loadMissing(['blocks.comparisonResults', 'courses']); return AcademyLessonRevision::query()->create([ 'lesson_id' => $lesson->id, 'user_id' => $user?->id, 'change_note' => $this->nullableTrimmedString($changeNote), 'snapshot_json' => $this->serializeLessonRevisionSnapshot($lesson), ]); } /** * @return array */ private function serializeLessonRevisionSnapshot(AcademyLesson $lesson): array { return [ 'content_source' => (string) ($lesson->content_markdown ? 'markdown' : 'html'), 'fields' => [ 'category_id' => $lesson->category_id, 'title' => (string) $lesson->title, 'slug' => (string) $lesson->slug, 'lesson_number' => $lesson->lesson_number, 'course_order' => $lesson->course_order, 'series_name' => (string) ($lesson->series_name ?? ''), 'excerpt' => (string) ($lesson->excerpt ?? ''), 'content' => (string) ($lesson->content ?? ''), 'content_markdown' => (string) ($lesson->content_markdown ?? ''), 'difficulty' => (string) $lesson->difficulty, 'access_level' => (string) $lesson->access_level, 'lesson_type' => (string) $lesson->lesson_type, 'cover_image' => (string) ($lesson->cover_image ?? ''), 'article_cover_image' => (string) ($lesson->article_cover_image ?? ''), 'tags' => array_values((array) ($lesson->tags ?? [])), 'video_url' => (string) ($lesson->video_url ?? ''), 'reading_minutes' => (int) ($lesson->reading_minutes ?? 5), 'featured' => (bool) ($lesson->featured ?? false), 'active' => (bool) ($lesson->active ?? true), 'published_at' => $lesson->published_at?->toISOString(), 'seo_title' => (string) ($lesson->seo_title ?? ''), 'seo_description' => (string) ($lesson->seo_description ?? ''), ], 'course_ids' => $lesson->courses->pluck('id')->map(static fn ($id): int => (int) $id)->values()->all(), 'blocks' => $lesson->blocks->map(fn (AcademyLessonBlock $block): array => $this->serializeLessonBlock($block))->values()->all(), ]; } private function applyLessonRevisionSnapshot(AcademyLesson $lesson, AcademyLessonRevision $revision, ?string $field = null): void { $snapshot = is_array($revision->snapshot_json) ? $revision->snapshot_json : []; $fields = is_array($snapshot['fields'] ?? null) ? $snapshot['fields'] : []; if ($field === 'course_ids') { $this->syncLessonCourses($lesson, array_values((array) ($snapshot['course_ids'] ?? []))); return; } if ($field === 'blocks') { $this->syncLessonBlocks($lesson, array_values((array) ($snapshot['blocks'] ?? []))); return; } if ($field === 'content') { $contentSource = (string) ($snapshot['content_source'] ?? 'html'); $lesson->forceFill([ 'content' => (string) ($fields['content'] ?? ''), 'content_markdown' => $contentSource === 'markdown' ? $this->nullableTrimmedString($fields['content_markdown'] ?? null) : null, ])->save(); return; } if (is_string($field) && $field !== '') { $lesson->forceFill([$field => $this->normalizeLessonRevisionFieldValue($field, $fields[$field] ?? null)])->save(); return; } $lesson->forceFill([ 'category_id' => $fields['category_id'] ?? null, 'title' => (string) ($fields['title'] ?? ''), 'slug' => (string) ($fields['slug'] ?? ''), 'lesson_number' => $fields['lesson_number'] ?? null, 'course_order' => $fields['course_order'] ?? null, 'series_name' => (string) ($fields['series_name'] ?? ''), 'excerpt' => (string) ($fields['excerpt'] ?? ''), 'content' => (string) ($fields['content'] ?? ''), 'content_markdown' => (string) ($snapshot['content_source'] ?? 'html') === 'markdown' ? $this->nullableTrimmedString($fields['content_markdown'] ?? null) : null, 'difficulty' => (string) ($fields['difficulty'] ?? 'beginner'), 'access_level' => (string) ($fields['access_level'] ?? 'free'), 'lesson_type' => (string) ($fields['lesson_type'] ?? 'article'), 'cover_image' => $this->nullableTrimmedString($fields['cover_image'] ?? null), 'article_cover_image' => $this->nullableTrimmedString($fields['article_cover_image'] ?? null), 'tags' => array_values((array) ($fields['tags'] ?? [])), 'video_url' => $this->nullableTrimmedString($fields['video_url'] ?? null), 'reading_minutes' => (int) ($fields['reading_minutes'] ?? 5), 'featured' => (bool) ($fields['featured'] ?? false), 'active' => (bool) ($fields['active'] ?? true), 'published_at' => filled($fields['published_at'] ?? null) ? Carbon::parse((string) $fields['published_at']) : null, 'seo_title' => $this->nullableTrimmedString($fields['seo_title'] ?? null), 'seo_description' => $this->nullableTrimmedString($fields['seo_description'] ?? null), ])->save(); $this->syncLessonCourses($lesson, array_values((array) ($snapshot['course_ids'] ?? []))); $this->syncLessonBlocks($lesson, array_values((array) ($snapshot['blocks'] ?? []))); } private function normalizeLessonRevisionFieldValue(string $field, mixed $value): mixed { return match ($field) { 'category_id', 'lesson_number', 'course_order' => filled($value) ? (int) $value : null, 'reading_minutes' => max(1, (int) ($value ?? 5)), 'featured', 'active' => (bool) $value, 'published_at' => filled($value) ? Carbon::parse((string) $value) : null, 'cover_image', 'article_cover_image', 'video_url', 'seo_title', 'seo_description' => $this->nullableTrimmedString($value), 'tags' => array_values((array) ($value ?? [])), default => is_string($value) ? $value : $value, }; } private function estimateLessonReadingMinutes(?string $contentHtml, ?string $contentMarkdown): int { $source = trim((string) ($contentMarkdown ?: '')); if ($source === '') { $source = html_entity_decode(strip_tags((string) ($contentHtml ?? '')), ENT_QUOTES | ENT_HTML5, 'UTF-8'); } $normalized = preg_replace('/\s+/u', ' ', trim((string) $source)); if (! is_string($normalized) || $normalized === '') { return 1; } $words = preg_split('/\s+/u', $normalized, -1, PREG_SPLIT_NO_EMPTY); $wordCount = is_array($words) ? count($words) : 0; return max(1, (int) ceil($wordCount / 180)); } private function persistCourseAttributes(UpsertAcademyCourseRequest $request, ?AcademyCourse $course = null): array { $validated = $request->validated(); $currentCoverImage = trim((string) ($course?->cover_image ?? '')); $currentTeaserImage = trim((string) ($course?->teaser_image ?? '')); $nextCoverImage = $this->nullableTrimmedString($validated['cover_image'] ?? null); $nextTeaserImage = $this->nullableTrimmedString($validated['teaser_image'] ?? null); if ($currentCoverImage !== '' && $currentCoverImage !== (string) $nextCoverImage) { $this->deleteStoredLessonCoverIfLocal($currentCoverImage); } if ($currentTeaserImage !== '' && $currentTeaserImage !== (string) $nextTeaserImage) { $this->deleteStoredLessonCoverIfLocal($currentTeaserImage); } $validated['cover_image'] = $nextCoverImage; $validated['teaser_image'] = $nextTeaserImage; $validated['seo_title'] = $this->nullableTrimmedString($validated['seo_title'] ?? null); $validated['seo_description'] = $this->nullableTrimmedString($validated['seo_description'] ?? null); $validated['meta_keywords'] = $this->nullableTrimmedString($validated['meta_keywords'] ?? null); $validated['og_title'] = $this->nullableTrimmedString($validated['og_title'] ?? null); $validated['og_description'] = $this->nullableTrimmedString($validated['og_description'] ?? null); $validated['og_image'] = $this->nullableTrimmedString($validated['og_image'] ?? null); if ((string) ($validated['status'] ?? '') === 'published' && empty($validated['published_at'])) { $validated['published_at'] = now(); } return $validated; } /** * @return array */ /** * @return array> */ private function serializeCourseEditorLessons(AcademyCourse $course): array { $course->loadMissing(['courseLessons.lesson', 'courseLessons.section']); return $course->courseLessons ->sortBy([['order_num', 'asc'], ['id', 'asc']]) ->values() ->map(function (AcademyCourseLesson $courseLesson) use ($course): array { $lesson = $courseLesson->lesson; return [ 'id' => (int) $courseLesson->id, 'lesson_id' => (int) $courseLesson->lesson_id, 'title' => (string) ($lesson?->title ?? 'Untitled lesson'), 'slug' => (string) ($lesson?->slug ?? ''), 'section_id' => $courseLesson->section_id ? (int) $courseLesson->section_id : null, 'section_title' => (string) ($courseLesson->section?->title ?? ''), 'order_num' => (int) ($courseLesson->order_num ?? 0), 'display_order' => (int) ($courseLesson->order_num ?? 0) + 1, 'formatted_lesson_number' => $lesson instanceof AcademyLesson ? $lesson->formatted_lesson_number : null, 'is_required' => (bool) $courseLesson->is_required, 'difficulty' => (string) ($lesson?->difficulty ?? ''), 'access_level' => (string) ($lesson?->access_level ?? ''), 'destroy_url' => route('admin.academy.courses.lessons.destroy', [ 'academyCourse' => $course, 'academyCourseLesson' => $courseLesson, ]), 'edit_url' => $lesson instanceof AcademyLesson ? route('admin.academy.lessons.edit', ['academyLesson' => $lesson]) : null, ]; }) ->all(); } /** * @return array> */ 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() ->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]), ]) ->values() ->all(); } private function serializeCourseOutlineSummary(AcademyCourse $course): array { $course->loadMissing(['sections', 'courseLessons']); $sections = $course->sections ->sortBy([['order_num', 'asc'], ['id', 'asc']]) ->values(); $courseLessons = $course->courseLessons; return [ 'section_count' => $sections->count(), 'visible_section_count' => $sections->where('is_visible', true)->count(), 'lesson_count' => $courseLessons->count(), 'required_lesson_count' => $courseLessons->where('is_required', true)->count(), 'unsectioned_lesson_count' => $courseLessons->whereNull('section_id')->count(), 'sections' => $sections->map(function ($section) use ($courseLessons): array { return [ 'id' => (int) $section->id, 'title' => (string) $section->title, 'is_visible' => (bool) $section->is_visible, 'lesson_count' => $courseLessons->where('section_id', $section->id)->count(), ]; })->all(), ]; } /** * @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']); $newCategoryName = trim((string) ($validated['new_category_name'] ?? '')); unset($validated['new_category_name']); if ($newCategoryName !== '') { $validated['category_id'] = $this->resolveOrCreatePromptCategoryId($newCategoryName); } $validated['tool_notes'] = $this->normalizePromptToolNotes((array) ($validated['tool_notes'] ?? [])); $previousToolNotes = $this->normalizePromptToolNotes((array) ($prompt?->tool_notes ?? [])); $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(); } $this->deleteRemovedPromptComparisonMedia($previousToolNotes, (array) ($validated['tool_notes'] ?? [])); return $validated; } private function resolveOrCreatePromptCategoryId(string $name): int { $normalizedName = trim($name); $existing = AcademyCategory::query() ->where('type', 'prompt') ->whereRaw('LOWER(name) = ?', [mb_strtolower($normalizedName)]) ->first(); if ($existing instanceof AcademyCategory) { return (int) $existing->id; } $maxOrder = (int) (AcademyCategory::query()->where('type', 'prompt')->max('order_num') ?? 0); $category = new AcademyCategory(); $category->forceFill([ 'type' => 'prompt', 'name' => $normalizedName, 'slug' => Str::slug($normalizedName), 'description' => null, 'order_num' => $maxOrder + 1, 'active' => true, ])->save(); return (int) $category->id; } /** * @param array $notes * @return array> */ private function serializePromptToolNotes(array $notes): array { return collect($this->normalizePromptToolNotes($notes)) ->map(fn (array $note): array => [ ...$note, 'image_url' => $this->resolveLessonMediaUrl($note['image_path'] ?? null), 'thumb_url' => $this->resolveLessonMediaUrl($note['thumb_path'] ?? null), ]) ->values() ->all(); } /** * @param array $notes * @return array> */ private function normalizePromptToolNotes(array $notes): array { return collect($notes) ->map(function ($note): ?array { if (is_string($note)) { $normalized = [ 'provider' => '', 'model_name' => '', 'notes' => trim($note), 'strengths' => '', 'weaknesses' => '', 'best_for' => '', 'image_path' => '', 'thumb_path' => '', 'settings' => '', 'score' => null, 'active' => true, ]; return $normalized['notes'] !== '' ? $normalized : null; } if (! is_array($note)) { return null; } $normalized = [ 'provider' => trim((string) ($note['provider'] ?? '')), 'model_name' => trim((string) ($note['model_name'] ?? '')), 'notes' => trim((string) ($note['notes'] ?? '')), 'strengths' => trim((string) ($note['strengths'] ?? '')), 'weaknesses' => trim((string) ($note['weaknesses'] ?? '')), 'best_for' => trim((string) ($note['best_for'] ?? '')), 'image_path' => trim((string) ($note['image_path'] ?? '')), 'thumb_path' => trim((string) ($note['thumb_path'] ?? '')), 'settings' => trim((string) ($note['settings'] ?? '')), 'score' => filled($note['score'] ?? null) ? max(1, min(10, (int) $note['score'])) : null, 'active' => filter_var($note['active'] ?? true, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE) ?? true, ]; $hasContent = collect([ $normalized['provider'], $normalized['model_name'], $normalized['notes'], $normalized['strengths'], $normalized['weaknesses'], $normalized['best_for'], $normalized['image_path'], $normalized['thumb_path'], $normalized['settings'], ])->contains(fn (string $value): bool => $value !== ''); return $hasContent || $normalized['score'] !== null ? $normalized : null; }) ->filter() ->values() ->all(); } /** * @param array> $previousNotes * @param array> $nextNotes */ private function deleteRemovedPromptComparisonMedia(array $previousNotes, array $nextNotes): void { $previousPaths = collect($previousNotes) ->flatMap(fn (array $note): array => [ trim((string) ($note['image_path'] ?? '')), trim((string) ($note['thumb_path'] ?? '')), ]) ->filter() ->unique(); $nextPaths = collect($nextNotes) ->flatMap(fn (array $note): array => [ trim((string) ($note['image_path'] ?? '')), trim((string) ($note['thumb_path'] ?? '')), ]) ->filter() ->unique() ->all(); $previousPaths ->reject(fn (string $path): bool => in_array($path, $nextPaths, true)) ->each(fn (string $path): bool => $this->deleteStoredLessonMediaIfLocal($path)); } 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) { 'courses' => ['academyCourse' => $record], '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 courseDifficultyOptions(): array { return collect(['beginner', 'intermediate', 'advanced']) ->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 courseAccessOptions(): array { return [ ['value' => 'free', 'label' => 'Free'], ['value' => 'premium', 'label' => 'Premium'], ['value' => 'mixed', 'label' => 'Mixed'], ]; } private function courseStatusOptions(): array { return [ ['value' => 'draft', 'label' => 'Draft'], ['value' => 'review', 'label' => 'Review'], ['value' => 'published', 'label' => 'Published'], ['value' => 'archived', 'label' => 'Archived'], ]; } 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, ]); } } }