Add tests for featured thumbnail generation; apply Pint formatting and related edits
This commit is contained in:
@@ -11,26 +11,36 @@ 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
|
||||
{
|
||||
public function __construct(private readonly AcademyCacheService $cache)
|
||||
{
|
||||
}
|
||||
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
|
||||
{
|
||||
@@ -65,18 +75,30 @@ final class AcademyAdminController extends Controller
|
||||
|
||||
public function categoriesCreate(): Response
|
||||
{
|
||||
return $this->renderForm('categories', new AcademyCategory());
|
||||
return $this->renderForm('categories', new AcademyCategory);
|
||||
}
|
||||
|
||||
public function categoriesStore(UpsertAcademyCategoryRequest $request): RedirectResponse
|
||||
{
|
||||
$category = new AcademyCategory();
|
||||
$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);
|
||||
@@ -105,13 +127,19 @@ final class AcademyAdminController extends Controller
|
||||
|
||||
public function lessonsCreate(): Response
|
||||
{
|
||||
return $this->renderForm('lessons', new AcademyLesson());
|
||||
return $this->renderForm('lessons', new AcademyLesson);
|
||||
}
|
||||
|
||||
public function lessonsStore(UpsertAcademyLessonRequest $request): RedirectResponse
|
||||
{
|
||||
$lesson = new AcademyLesson();
|
||||
$lesson->fill($request->validated())->save();
|
||||
$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.');
|
||||
@@ -119,12 +147,18 @@ final class AcademyAdminController extends Controller
|
||||
|
||||
public function lessonsEdit(AcademyLesson $academyLesson): Response
|
||||
{
|
||||
$academyLesson->load(['blocks.comparisonResults']);
|
||||
|
||||
return $this->renderForm('lessons', $academyLesson);
|
||||
}
|
||||
|
||||
public function lessonsUpdate(UpsertAcademyLessonRequest $request, AcademyLesson $academyLesson): RedirectResponse
|
||||
{
|
||||
$academyLesson->fill($request->validated())->save();
|
||||
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.');
|
||||
@@ -132,6 +166,16 @@ final class AcademyAdminController extends Controller
|
||||
|
||||
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();
|
||||
|
||||
@@ -145,13 +189,13 @@ final class AcademyAdminController extends Controller
|
||||
|
||||
public function promptsCreate(): Response
|
||||
{
|
||||
return $this->renderForm('prompts', new AcademyPromptTemplate());
|
||||
return $this->renderForm('prompts', new AcademyPromptTemplate);
|
||||
}
|
||||
|
||||
public function promptsStore(UpsertAcademyPromptTemplateRequest $request): RedirectResponse
|
||||
{
|
||||
$prompt = new AcademyPromptTemplate();
|
||||
$prompt->fill($request->validated())->save();
|
||||
$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.');
|
||||
@@ -164,7 +208,7 @@ final class AcademyAdminController extends Controller
|
||||
|
||||
public function promptsUpdate(UpsertAcademyPromptTemplateRequest $request, AcademyPromptTemplate $academyPromptTemplate): RedirectResponse
|
||||
{
|
||||
$academyPromptTemplate->fill($request->validated())->save();
|
||||
$academyPromptTemplate->forceFill($this->persistPromptAttributes($request, $academyPromptTemplate))->save();
|
||||
$this->cache->clearAll();
|
||||
|
||||
return redirect()->route('admin.academy.prompts.edit', ['academyPromptTemplate' => $academyPromptTemplate])->with('success', 'Academy prompt updated.');
|
||||
@@ -185,12 +229,12 @@ final class AcademyAdminController extends Controller
|
||||
|
||||
public function packsCreate(): Response
|
||||
{
|
||||
return $this->renderForm('packs', new AcademyPromptPack());
|
||||
return $this->renderForm('packs', new AcademyPromptPack);
|
||||
}
|
||||
|
||||
public function packsStore(UpsertAcademyPromptPackRequest $request): RedirectResponse
|
||||
{
|
||||
$pack = new AcademyPromptPack();
|
||||
$pack = new AcademyPromptPack;
|
||||
$pack->fill(collect($request->validated())->except('prompt_ids')->all())->save();
|
||||
$this->syncPackItems($pack, $request->validated('prompt_ids', []));
|
||||
$this->cache->clearAll();
|
||||
@@ -229,12 +273,12 @@ final class AcademyAdminController extends Controller
|
||||
|
||||
public function challengesCreate(): Response
|
||||
{
|
||||
return $this->renderForm('challenges', new AcademyChallenge());
|
||||
return $this->renderForm('challenges', new AcademyChallenge);
|
||||
}
|
||||
|
||||
public function challengesStore(UpsertAcademyChallengeRequest $request): RedirectResponse
|
||||
{
|
||||
$challenge = new AcademyChallenge();
|
||||
$challenge = new AcademyChallenge;
|
||||
$challenge->fill($request->validated())->save();
|
||||
$this->cache->clearAll();
|
||||
|
||||
@@ -269,12 +313,12 @@ final class AcademyAdminController extends Controller
|
||||
|
||||
public function badgesCreate(): Response
|
||||
{
|
||||
return $this->renderForm('badges', new AcademyBadge());
|
||||
return $this->renderForm('badges', new AcademyBadge);
|
||||
}
|
||||
|
||||
public function badgesStore(UpsertAcademyBadgeRequest $request): RedirectResponse
|
||||
{
|
||||
$badge = new AcademyBadge();
|
||||
$badge = new AcademyBadge;
|
||||
$badge->fill($request->validated())->save();
|
||||
|
||||
return redirect()->route('admin.academy.badges.edit', ['academyBadge' => $badge])->with('success', 'Academy badge created.');
|
||||
@@ -362,7 +406,7 @@ final class AcademyAdminController extends Controller
|
||||
'subtitle' => $meta['subtitle'],
|
||||
'items' => $items,
|
||||
'columns' => $meta['columns'],
|
||||
'createUrl' => route($meta['route_base'] . '.create'),
|
||||
'createUrl' => route($meta['route_base'].'.create'),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -372,17 +416,44 @@ final class AcademyAdminController extends Controller
|
||||
|
||||
return Inertia::render('Admin/Academy/CrudForm', [
|
||||
'resource' => $resource,
|
||||
'title' => $record->exists ? 'Edit ' . $meta['singular'] : 'Create ' . $meta['singular'],
|
||||
'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,
|
||||
'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) {
|
||||
@@ -449,7 +520,7 @@ final class AcademyAdminController extends Controller
|
||||
['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', 'type' => 'text'],
|
||||
['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'],
|
||||
@@ -526,7 +597,7 @@ final class AcademyAdminController extends Controller
|
||||
['name' => 'active', 'label' => 'Active', 'type' => 'checkbox'],
|
||||
],
|
||||
],
|
||||
default => throw new \InvalidArgumentException('Unknown Academy resource [' . $resource . '].'),
|
||||
default => throw new \InvalidArgumentException('Unknown Academy resource ['.$resource.'].'),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -616,6 +687,7 @@ final class AcademyAdminController extends Controller
|
||||
'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'),
|
||||
@@ -623,6 +695,9 @@ final class AcademyAdminController extends Controller
|
||||
'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,
|
||||
@@ -638,6 +713,8 @@ final class AcademyAdminController extends Controller
|
||||
'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 ?? ''),
|
||||
@@ -693,6 +770,429 @@ final class AcademyAdminController extends Controller
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @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) {
|
||||
@@ -758,4 +1258,4 @@ final class AcademyAdminController extends Controller
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user