1262 lines
58 KiB
PHP
1262 lines
58 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Http\Controllers\Settings;
|
|
|
|
use App\Http\Controllers\Controller;
|
|
use App\Http\Requests\Academy\UpsertAcademyBadgeRequest;
|
|
use App\Http\Requests\Academy\UpsertAcademyCategoryRequest;
|
|
use App\Http\Requests\Academy\UpsertAcademyChallengeRequest;
|
|
use App\Http\Requests\Academy\UpsertAcademyLessonRequest;
|
|
use App\Http\Requests\Academy\UpsertAcademyPromptPackRequest;
|
|
use App\Http\Requests\Academy\UpsertAcademyPromptTemplateRequest;
|
|
use App\Models\AcademyAiComparisonResult;
|
|
use App\Models\AcademyBadge;
|
|
use App\Models\AcademyCategory;
|
|
use App\Models\AcademyChallenge;
|
|
use App\Models\AcademyChallengeSubmission;
|
|
use App\Models\AcademyLesson;
|
|
use App\Models\AcademyLessonBlock;
|
|
use App\Models\AcademyPromptPack;
|
|
use App\Models\AcademyPromptPackItem;
|
|
use App\Models\AcademyPromptTemplate;
|
|
use App\Services\Academy\AcademyCacheService;
|
|
use Illuminate\Database\Eloquent\Model;
|
|
use Illuminate\Http\JsonResponse;
|
|
use Illuminate\Http\RedirectResponse;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Http\UploadedFile;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use Illuminate\Support\Str;
|
|
use Illuminate\Validation\ValidationException;
|
|
use Inertia\Inertia;
|
|
use Inertia\Response;
|
|
|
|
final class AcademyAdminController extends Controller
|
|
{
|
|
private const PROMPT_PREVIEW_WEBP_QUALITY = 84;
|
|
|
|
private const PROMPT_PREVIEW_PREFIX = 'academy-prompts/previews';
|
|
|
|
public function __construct(private readonly AcademyCacheService $cache) {}
|
|
|
|
public function dashboard(): Response
|
|
{
|
|
return Inertia::render('Admin/Academy/Dashboard', [
|
|
'stats' => [
|
|
'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<string, mixed>
|
|
*/
|
|
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<int, array<string, mixed>> $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<int, array<string, mixed>> $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<string, mixed> $payload
|
|
* @return array<string, mixed>
|
|
*/
|
|
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<string, mixed>
|
|
*/
|
|
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<string, mixed>
|
|
*/
|
|
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,
|
|
]);
|
|
}
|
|
}
|
|
}
|