Add tests for featured thumbnail generation; apply Pint formatting and related edits
This commit is contained in:
@@ -19,8 +19,7 @@ final class AcademyLessonController extends Controller
|
||||
public function __construct(
|
||||
private readonly AcademyAccessService $access,
|
||||
private readonly AcademyCacheService $cache,
|
||||
) {
|
||||
}
|
||||
) {}
|
||||
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
@@ -40,8 +39,8 @@ final class AcademyLessonController extends Controller
|
||||
|
||||
if (filled($filters['q'] ?? null)) {
|
||||
$query->where(function ($builder) use ($filters): void {
|
||||
$builder->where('title', 'like', '%' . $filters['q'] . '%')
|
||||
->orWhere('excerpt', 'like', '%' . $filters['q'] . '%');
|
||||
$builder->where('title', 'like', '%'.$filters['q'].'%')
|
||||
->orWhere('excerpt', 'like', '%'.$filters['q'].'%');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -81,17 +80,31 @@ final class AcademyLessonController extends Controller
|
||||
abort_unless((bool) config('academy.enabled', true), 404);
|
||||
|
||||
$lesson = AcademyLesson::query()
|
||||
->with('category')
|
||||
->with(['category', 'activeBlocks.activeComparisonResults'])
|
||||
->active()
|
||||
->published()
|
||||
->where('slug', $slug)
|
||||
->firstOrFail();
|
||||
|
||||
$payload = $this->access->lessonPayload($lesson, $request->user(), true);
|
||||
$relatedLessons = $lesson->category_id !== null
|
||||
? AcademyLesson::query()
|
||||
->with('category')
|
||||
->active()
|
||||
->published()
|
||||
->where('category_id', $lesson->category_id)
|
||||
->where('id', '!=', $lesson->id)
|
||||
->orderByDesc('published_at')
|
||||
->limit(6)
|
||||
->get()
|
||||
->map(fn (AcademyLesson $relatedLesson): array => $this->access->lessonPayload($relatedLesson, $request->user()))
|
||||
->values()
|
||||
->all()
|
||||
: [];
|
||||
$canonical = route('academy.lessons.show', ['slug' => $lesson->slug]);
|
||||
$description = Str::limit(trim((string) ($lesson->seo_description ?? $lesson->excerpt ?? 'Skinbase Academy lesson.')), 160, '...');
|
||||
$seo = app(SeoFactory::class)->collectionPage(
|
||||
(string) ($lesson->seo_title ?? ($lesson->title . ' — Skinbase Academy')),
|
||||
(string) ($lesson->seo_title ?? ($lesson->title.' — Skinbase Academy')),
|
||||
$description,
|
||||
$canonical,
|
||||
$lesson->cover_image,
|
||||
@@ -100,10 +113,11 @@ final class AcademyLessonController extends Controller
|
||||
return Inertia::render('Academy/Show', [
|
||||
'pageType' => 'lesson',
|
||||
'item' => $payload,
|
||||
'relatedLessons' => $relatedLessons,
|
||||
'seo' => $seo,
|
||||
'pricingUrl' => route('academy.pricing'),
|
||||
'completeUrl' => $request->user() ? route('academy.lessons.complete', ['lesson' => $lesson->id]) : null,
|
||||
'completed' => $request->user()?->academyLessonProgress()->where('lesson_id', $lesson->id)->whereNotNull('completed_at')->exists() ?? false,
|
||||
])->rootView('collections');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,7 +100,7 @@ final class AcademyPromptController extends Controller
|
||||
(string) ($prompt->seo_title ?? ($prompt->title . ' — Skinbase Academy')),
|
||||
$description,
|
||||
$canonical,
|
||||
$prompt->preview_image,
|
||||
$payload['preview_image'] ?? null,
|
||||
)->toArray();
|
||||
|
||||
return Inertia::render('Academy/Show', [
|
||||
|
||||
@@ -30,7 +30,19 @@ class LatestCommentsApiController extends Controller
|
||||
return response()->json(['error' => 'Unauthenticated'], 401);
|
||||
}
|
||||
|
||||
$query = ArtworkComment::with(['user', 'user.profile', 'artwork'])
|
||||
$query = ArtworkComment::query()
|
||||
->select([
|
||||
'artwork_comments.id',
|
||||
'artwork_comments.artwork_id',
|
||||
'artwork_comments.user_id',
|
||||
'artwork_comments.content',
|
||||
'artwork_comments.created_at',
|
||||
])
|
||||
->with([
|
||||
'user:id,username,name',
|
||||
'user.profile:user_id,avatar_hash',
|
||||
'artwork:id,title,slug,hash,file_path,file_name',
|
||||
])
|
||||
->whereHas('artwork', function ($q) {
|
||||
$q->public()->published()->whereNull('deleted_at');
|
||||
})
|
||||
|
||||
@@ -17,6 +17,7 @@ use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\View\View;
|
||||
|
||||
@@ -37,10 +38,16 @@ class RegisteredUserController extends Controller
|
||||
*/
|
||||
public function create(Request $request): View
|
||||
{
|
||||
$cspNonce = $this->resolveCspNonce($request);
|
||||
|
||||
return view('auth.register', [
|
||||
'prefillEmail' => (string) $request->query('email', ''),
|
||||
'requiresCaptcha' => $this->shouldRequireCaptcha($request->ip()),
|
||||
'captcha' => $this->captchaVerifier->frontendConfig(),
|
||||
'turnstile' => [
|
||||
'enabled' => $this->turnstileVerifier->isEnabled(),
|
||||
'siteKey' => $this->turnstileVerifier->siteKey(),
|
||||
'scriptUrl' => $this->turnstileVerifier->scriptUrl(),
|
||||
'cspNonce' => $cspNonce,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -62,25 +69,35 @@ class RegisteredUserController extends Controller
|
||||
*/
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$turnstileResponse = (string) ($request->input('turnstile_token') ?: $request->input('cf-turnstile-response', ''));
|
||||
|
||||
$rules = [
|
||||
'email' => ['required', 'string', 'lowercase', 'email', 'max:255'],
|
||||
'website' => ['nullable', 'max:0'],
|
||||
'turnstile_token' => ['nullable', 'string'],
|
||||
'cf-turnstile-response' => [$this->turnstileVerifier->isEnabled() ? 'required_without:turnstile_token' : 'nullable', 'string'],
|
||||
];
|
||||
$rules[$this->captchaVerifier->inputName()] = ['nullable', 'string'];
|
||||
|
||||
$validator = Validator::make($request->all(), $rules);
|
||||
|
||||
if ($validator->fails()) {
|
||||
$errors = $validator->errors()->toArray();
|
||||
|
||||
if (array_key_exists('cf-turnstile-response', $errors) && ! array_key_exists('turnstile_token', $errors)) {
|
||||
$errors['turnstile_token'] = $errors['cf-turnstile-response'];
|
||||
unset($errors['cf-turnstile-response']);
|
||||
}
|
||||
|
||||
$this->authAuditLogger->log(
|
||||
eventType: 'register',
|
||||
request: $request,
|
||||
status: 'failed',
|
||||
reason: 'validation_failed',
|
||||
identifier: (string) $request->input('email'),
|
||||
metadata: ['fields' => array_keys($validator->errors()->toArray())],
|
||||
metadata: ['fields' => array_keys($errors)],
|
||||
);
|
||||
|
||||
$validator->validate();
|
||||
throw ValidationException::withMessages($errors);
|
||||
}
|
||||
|
||||
$validated = $validator->validated();
|
||||
@@ -90,32 +107,18 @@ class RegisteredUserController extends Controller
|
||||
|
||||
$this->trackRegisterAttempt($ip);
|
||||
|
||||
if ($this->shouldRequireCaptcha($ip)) {
|
||||
$verified = $this->captchaVerifier->verify(
|
||||
(string) $request->input($this->captchaVerifier->inputName(), ''),
|
||||
$ip
|
||||
if ($this->turnstileVerifier->isEnabled() && ! $this->turnstileVerifier->verify($turnstileResponse, $ip)) {
|
||||
$this->authAuditLogger->log(
|
||||
eventType: 'register',
|
||||
request: $request,
|
||||
status: 'failed',
|
||||
reason: 'captcha_failed',
|
||||
identifier: $email,
|
||||
);
|
||||
|
||||
if ($this->turnstileVerifier->isEnabled()) {
|
||||
$verified = $this->turnstileVerifier->verify(
|
||||
(string) $request->input($this->captchaVerifier->inputName(), ''),
|
||||
$ip
|
||||
);
|
||||
}
|
||||
|
||||
if (! $verified) {
|
||||
$this->authAuditLogger->log(
|
||||
eventType: 'register',
|
||||
request: $request,
|
||||
status: 'failed',
|
||||
reason: 'captcha_failed',
|
||||
identifier: $email,
|
||||
);
|
||||
|
||||
return back()
|
||||
->withInput($request->except('website'))
|
||||
->withErrors(['captcha' => 'Captcha verification failed. Please try again.']);
|
||||
}
|
||||
return back()
|
||||
->withInput($request->except('website', 'turnstile_token', 'cf-turnstile-response'))
|
||||
->withErrors(['turnstile_token' => 'Security verification failed. Please try again.']);
|
||||
}
|
||||
|
||||
if ($this->disposableEmailService->isDisposableEmail($email)) {
|
||||
@@ -264,23 +267,6 @@ class RegisteredUserController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
private function shouldRequireCaptcha(?string $ip): bool
|
||||
{
|
||||
if (! $this->captchaVerifier->isEnabled()) {
|
||||
if (! $this->turnstileVerifier->isEnabled()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! (bool) config('registration.enable_turnstile', true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->turnstileVerifier->isEnabled() && $this->shouldRequireCaptchaForIp($ip);
|
||||
}
|
||||
|
||||
return $this->shouldRequireCaptchaForIp($ip);
|
||||
}
|
||||
|
||||
private function shouldRequireCaptchaForIp(?string $ip): bool
|
||||
{
|
||||
if (! $this->captchaVerifier->isEnabled() && ! $this->turnstileVerifier->isEnabled()) {
|
||||
@@ -387,4 +373,28 @@ class RegisteredUserController extends Controller
|
||||
|
||||
return $remaining >= 0 ? 0 : abs((int) $remaining);
|
||||
}
|
||||
|
||||
private function resolveCspNonce(Request $request): ?string
|
||||
{
|
||||
$candidates = [
|
||||
$request->attributes->get('csp_nonce'),
|
||||
$request->attributes->get('cspNonce'),
|
||||
$request->headers->get('X-CSP-Nonce'),
|
||||
$request->server('HTTP_X_CSP_NONCE'),
|
||||
];
|
||||
|
||||
foreach ($candidates as $candidate) {
|
||||
if (! is_string($candidate)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$nonce = trim($candidate);
|
||||
|
||||
if ($nonce !== '') {
|
||||
return $nonce;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,19 @@ class LatestCommentsController extends Controller
|
||||
|
||||
// Build initial (first-page, type=all) data for React SSR props
|
||||
$initialData = Cache::remember('comments.latest.all.page1', 120, function () {
|
||||
return ArtworkComment::with(['user', 'user.profile', 'artwork'])
|
||||
return ArtworkComment::query()
|
||||
->select([
|
||||
'artwork_comments.id',
|
||||
'artwork_comments.artwork_id',
|
||||
'artwork_comments.user_id',
|
||||
'artwork_comments.content',
|
||||
'artwork_comments.created_at',
|
||||
])
|
||||
->with([
|
||||
'user:id,username,name',
|
||||
'user.profile:user_id,avatar_hash',
|
||||
'artwork:id,title,slug,hash,file_path,file_name',
|
||||
])
|
||||
->whereHas('artwork', function ($q) {
|
||||
$q->public()->published()->whereNull('deleted_at');
|
||||
})
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
namespace App\Http\Controllers\News;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\NewsArticleComment;
|
||||
use App\Models\User;
|
||||
use App\Services\News\NewsService;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -147,40 +146,14 @@ class NewsController extends Controller
|
||||
// Track view (once per session / IP)
|
||||
$this->trackView($request, $article);
|
||||
|
||||
// Related articles (same category, excluding current)
|
||||
$related = NewsArticle::with('author', 'category')
|
||||
->published()
|
||||
->when($article->category_id, fn ($q) => $q->where('category_id', $article->category_id))
|
||||
->where('id', '!=', $article->id)
|
||||
->editorialOrder()
|
||||
->limit(config('news.related_limit', 4))
|
||||
->get();
|
||||
|
||||
$comments = collect();
|
||||
$commentsCount = 0;
|
||||
|
||||
if ($article->commentsAreEnabled()) {
|
||||
$comments = NewsArticleComment::query()
|
||||
->where('article_id', $article->id)
|
||||
->whereNull('parent_id')
|
||||
->where('status', 'visible')
|
||||
->with(['user.profile'])
|
||||
->orderBy('created_at')
|
||||
->orderBy('id')
|
||||
->get();
|
||||
|
||||
$commentsCount = (int) NewsArticleComment::query()
|
||||
->where('article_id', $article->id)
|
||||
->where('status', 'visible')
|
||||
->count();
|
||||
}
|
||||
$articleData = $this->news->publicArticleShowData($article, $request->user());
|
||||
|
||||
return view('news.show', [
|
||||
'article' => $article,
|
||||
'related' => $related,
|
||||
'relatedEntities' => $this->news->resolveRelatedEntities($article, $request->user()),
|
||||
'comments' => $comments,
|
||||
'commentsCount' => $commentsCount,
|
||||
'related' => $articleData['related'],
|
||||
'relatedEntities' => $articleData['relatedEntities'],
|
||||
'comments' => $articleData['comments'],
|
||||
'commentsCount' => $articleData['commentsCount'],
|
||||
] + $this->sidebarData());
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,42 +5,15 @@ declare(strict_types=1);
|
||||
namespace App\Http\Controllers\Studio;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\News\NewsCoverImageService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Intervention\Image\Drivers\Gd\Driver as GdDriver;
|
||||
use Intervention\Image\Drivers\Imagick\Driver as ImagickDriver;
|
||||
use Intervention\Image\Encoders\WebpEncoder;
|
||||
use Intervention\Image\ImageManager;
|
||||
use RuntimeException;
|
||||
|
||||
final class StudioNewsMediaApiController extends Controller
|
||||
{
|
||||
private const ALLOWED_MIME_TYPES = ['image/jpeg', 'image/png', 'image/webp'];
|
||||
|
||||
private const MAX_FILE_SIZE_KB = 6144;
|
||||
|
||||
private const MAX_WIDTH = 2200;
|
||||
|
||||
private const MAX_HEIGHT = 1400;
|
||||
|
||||
private const MIN_WIDTH = 1200;
|
||||
|
||||
private const MIN_HEIGHT = 630;
|
||||
|
||||
private ?ImageManager $manager = null;
|
||||
|
||||
public function __construct()
|
||||
public function __construct(private readonly NewsCoverImageService $covers)
|
||||
{
|
||||
try {
|
||||
$this->manager = extension_loaded('gd')
|
||||
? new ImageManager(new GdDriver())
|
||||
: new ImageManager(new ImagickDriver());
|
||||
} catch (\Throwable) {
|
||||
$this->manager = null;
|
||||
}
|
||||
}
|
||||
|
||||
public function store(Request $request): JsonResponse
|
||||
@@ -52,26 +25,28 @@ final class StudioNewsMediaApiController extends Controller
|
||||
'required',
|
||||
'file',
|
||||
'image',
|
||||
'max:' . self::MAX_FILE_SIZE_KB,
|
||||
'max:' . $this->covers->maxFileSizeKb(),
|
||||
'mimes:jpg,jpeg,png,webp',
|
||||
'mimetypes:image/jpeg,image/png,image/webp',
|
||||
],
|
||||
]);
|
||||
|
||||
/** @var UploadedFile $file */
|
||||
$file = $validated['image'];
|
||||
|
||||
try {
|
||||
$stored = $this->storeMediaFile($file);
|
||||
$stored = $this->covers->storeUploadedFile($file);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'path' => $stored['path'],
|
||||
'url' => $this->publicUrlForPath($stored['path']),
|
||||
'url' => $stored['url'],
|
||||
'width' => $stored['width'],
|
||||
'height' => $stored['height'],
|
||||
'mime_type' => 'image/webp',
|
||||
'size_bytes' => $stored['size_bytes'],
|
||||
'mobile_url' => $stored['mobile_url'],
|
||||
'desktop_url' => $stored['desktop_url'],
|
||||
'srcset' => $stored['srcset'],
|
||||
]);
|
||||
} catch (RuntimeException $e) {
|
||||
return response()->json([
|
||||
@@ -99,7 +74,7 @@ final class StudioNewsMediaApiController extends Controller
|
||||
'path' => ['required', 'string', 'max:2048'],
|
||||
]);
|
||||
|
||||
$this->deleteMediaFile((string) $validated['path']);
|
||||
$this->covers->deleteManagedFiles((string) $validated['path']);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
@@ -176,56 +151,4 @@ final class StudioNewsMediaApiController extends Controller
|
||||
{
|
||||
abort_unless($request->user() && ($request->user()->isAdmin() || $request->user()->isModerator()), 403);
|
||||
}
|
||||
|
||||
private function mediaDiskName(): string
|
||||
{
|
||||
return (string) config('uploads.object_storage.disk', 's3');
|
||||
}
|
||||
|
||||
private function mediaPath(string $hash): string
|
||||
{
|
||||
return sprintf(
|
||||
'news/covers/%s/%s/%s.webp',
|
||||
substr($hash, 0, 2),
|
||||
substr($hash, 2, 2),
|
||||
$hash,
|
||||
);
|
||||
}
|
||||
|
||||
private function publicUrlForPath(string $path): string
|
||||
{
|
||||
return rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/') . '/' . ltrim($path, '/');
|
||||
}
|
||||
|
||||
private function deleteMediaFile(string $path): void
|
||||
{
|
||||
$trimmed = ltrim(trim($path), '/');
|
||||
|
||||
if ($trimmed === '' || ! Str::startsWith($trimmed, 'news/covers/')) {
|
||||
return;
|
||||
}
|
||||
|
||||
Storage::disk($this->mediaDiskName())->delete($trimmed);
|
||||
}
|
||||
|
||||
private function assertImageManager(): void
|
||||
{
|
||||
if ($this->manager !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw new RuntimeException('Image processing is not available on this environment.');
|
||||
}
|
||||
|
||||
private function assertStorageIsAllowed(): void
|
||||
{
|
||||
if (! app()->environment('production')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$diskName = $this->mediaDiskName();
|
||||
if (in_array($diskName, ['local', 'public'], true)) {
|
||||
throw new RuntimeException('Production news media storage must use object storage, not local/public disks.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Http\Requests\Academy;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class UpsertAcademyLessonRequest extends FormRequest
|
||||
@@ -16,10 +17,62 @@ class UpsertAcademyLessonRequest extends FormRequest
|
||||
|
||||
protected function prepareForValidation(): void
|
||||
{
|
||||
$blocks = collect($this->input('blocks', []))
|
||||
->filter(static fn ($block): bool => is_array($block))
|
||||
->map(function (array $block): array {
|
||||
$payload = Arr::wrap($block['payload'] ?? []);
|
||||
$criteria = collect($payload['criteria'] ?? [])
|
||||
->map(static fn ($criterion) => trim((string) $criterion))
|
||||
->filter(static fn (string $criterion): bool => $criterion !== '')
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$results = collect($block['comparison_results'] ?? [])
|
||||
->filter(static fn ($result): bool => is_array($result))
|
||||
->map(function (array $result): array {
|
||||
return [
|
||||
'id' => filled($result['id'] ?? null) ? (int) $result['id'] : null,
|
||||
'provider' => $result['provider'] ?? null,
|
||||
'model_name' => $result['model_name'] ?? null,
|
||||
'image_path' => $result['image_path'] ?? null,
|
||||
'thumb_path' => $result['thumb_path'] ?? null,
|
||||
'settings' => $result['settings'] ?? null,
|
||||
'strengths' => $result['strengths'] ?? null,
|
||||
'weaknesses' => $result['weaknesses'] ?? null,
|
||||
'best_for' => $result['best_for'] ?? null,
|
||||
'score' => filled($result['score'] ?? null) ? (int) $result['score'] : null,
|
||||
'sort_order' => filled($result['sort_order'] ?? null) ? (int) $result['sort_order'] : 0,
|
||||
'active' => filter_var($result['active'] ?? true, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE) ?? true,
|
||||
];
|
||||
})
|
||||
->values()
|
||||
->all();
|
||||
|
||||
return [
|
||||
'id' => filled($block['id'] ?? null) ? (int) $block['id'] : null,
|
||||
'type' => (string) ($block['type'] ?? 'ai_comparison'),
|
||||
'title' => $block['title'] ?? null,
|
||||
'payload' => [
|
||||
'title' => $payload['title'] ?? null,
|
||||
'intro' => $payload['intro'] ?? null,
|
||||
'prompt' => $payload['prompt'] ?? null,
|
||||
'negative_prompt' => $payload['negative_prompt'] ?? null,
|
||||
'aspect_ratio' => $payload['aspect_ratio'] ?? null,
|
||||
'criteria' => $criteria,
|
||||
],
|
||||
'sort_order' => filled($block['sort_order'] ?? null) ? (int) $block['sort_order'] : 0,
|
||||
'active' => filter_var($block['active'] ?? true, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE) ?? true,
|
||||
'comparison_results' => $results,
|
||||
];
|
||||
})
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$this->merge([
|
||||
'reading_minutes' => $this->filled('reading_minutes') ? (int) $this->input('reading_minutes') : 5,
|
||||
'featured' => $this->boolean('featured'),
|
||||
'active' => $this->boolean('active', true),
|
||||
'blocks' => $blocks,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -44,6 +97,33 @@ class UpsertAcademyLessonRequest extends FormRequest
|
||||
'published_at' => ['nullable', 'date'],
|
||||
'seo_title' => ['nullable', 'string', 'max:180'],
|
||||
'seo_description' => ['nullable', 'string', 'max:255'],
|
||||
'blocks' => ['nullable', 'array'],
|
||||
'blocks.*.id' => ['nullable', 'integer', 'exists:academy_lesson_blocks,id'],
|
||||
'blocks.*.type' => ['required', 'string', Rule::in(['ai_comparison'])],
|
||||
'blocks.*.title' => ['nullable', 'string', 'max:255'],
|
||||
'blocks.*.payload' => ['nullable', 'array'],
|
||||
'blocks.*.payload.title' => ['nullable', 'string', 'max:255'],
|
||||
'blocks.*.payload.intro' => ['nullable', 'string'],
|
||||
'blocks.*.payload.prompt' => ['nullable', 'string'],
|
||||
'blocks.*.payload.negative_prompt' => ['nullable', 'string'],
|
||||
'blocks.*.payload.aspect_ratio' => ['nullable', 'string', 'max:20'],
|
||||
'blocks.*.payload.criteria' => ['nullable', 'array'],
|
||||
'blocks.*.payload.criteria.*' => ['nullable', 'string', 'max:100'],
|
||||
'blocks.*.sort_order' => ['required', 'integer', 'min:0'],
|
||||
'blocks.*.active' => ['required', 'boolean'],
|
||||
'blocks.*.comparison_results' => ['nullable', 'array'],
|
||||
'blocks.*.comparison_results.*.id' => ['nullable', 'integer', 'exists:academy_ai_comparison_results,id'],
|
||||
'blocks.*.comparison_results.*.provider' => ['nullable', 'string', 'max:100'],
|
||||
'blocks.*.comparison_results.*.model_name' => ['nullable', 'string', 'max:150'],
|
||||
'blocks.*.comparison_results.*.image_path' => ['required', 'string', 'max:500'],
|
||||
'blocks.*.comparison_results.*.thumb_path' => ['nullable', 'string', 'max:500'],
|
||||
'blocks.*.comparison_results.*.settings' => ['nullable', 'string'],
|
||||
'blocks.*.comparison_results.*.strengths' => ['nullable', 'string'],
|
||||
'blocks.*.comparison_results.*.weaknesses' => ['nullable', 'string'],
|
||||
'blocks.*.comparison_results.*.best_for' => ['nullable', 'string'],
|
||||
'blocks.*.comparison_results.*.score' => ['nullable', 'integer', 'min:1', 'max:10'],
|
||||
'blocks.*.comparison_results.*.sort_order' => ['required', 'integer', 'min:0'],
|
||||
'blocks.*.comparison_results.*.active' => ['required', 'boolean'],
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@ class UpsertAcademyPromptTemplateRequest extends FormRequest
|
||||
'tags.*' => ['string', 'max:60'],
|
||||
'tool_notes' => ['nullable', 'array'],
|
||||
'preview_image' => ['nullable', 'string', 'max:2048'],
|
||||
'preview_image_file' => ['nullable', 'file', 'image', 'mimes:jpg,jpeg,png,webp', 'max:5120'],
|
||||
'featured' => ['required', 'boolean'],
|
||||
'prompt_of_week' => ['required', 'boolean'],
|
||||
'active' => ['required', 'boolean'],
|
||||
|
||||
Reference in New Issue
Block a user