Optimize academy

This commit is contained in:
2026-06-09 13:16:01 +02:00
parent f89ee937c0
commit 5af95f6533
109 changed files with 6862 additions and 719 deletions

View File

@@ -17,6 +17,7 @@ use App\Models\AcademyBadge;
use App\Models\AcademyCategory;
use App\Models\AcademyChallenge;
use App\Models\AcademyChallengeSubmission;
use App\Models\AcademyContentMetricDaily;
use App\Models\AcademyCourse;
use App\Models\AcademyCourseLesson;
use App\Models\AcademyCourseSection;
@@ -31,6 +32,7 @@ use App\Services\Academy\AcademyAdminBillingOverviewService;
use App\Services\Academy\AcademyCacheService;
use App\Services\Academy\AcademyCourseLessonOrderingService;
use App\Services\Academy\AcademyLessonMarkdownRenderer;
use App\Support\AcademyAnalytics\AcademyAnalyticsContentType;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
@@ -604,34 +606,48 @@ final class AcademyAdminController extends Controller
$meta = $this->resourceMeta($resource);
$search = trim((string) request()->query('search', ''));
$query = $meta['model']::query();
$filters = [
'search' => $search,
];
$summary = null;
if ($resource === 'courses') {
$query->withCount('courseLessons');
if ($search !== '') {
$query->where(function ($builder) use ($search): void {
$like = '%'.str_replace(['%', '_'], ['\\%', '\\_'], $search).'%';
$builder->where('title', 'like', $like)
->orWhere('slug', 'like', $like)
->orWhere('subtitle', 'like', $like)
->orWhere('excerpt', 'like', $like)
->orWhere('description', 'like', $like);
});
$this->applyCourseAdminSearch($query, $search);
}
$query->orderByDesc('is_featured')
->orderBy('order_num')
->orderByDesc('updated_at')
->orderByDesc('id');
} elseif ($resource === 'prompts') {
$query->with('category');
$query->withSum(['metrics as total_views' => function ($builder): void {
$builder->where('content_type', AcademyAnalyticsContentType::PROMPT);
}], 'views');
$promptFilters = [
'category' => (string) request()->query('category', 'all'),
'featured' => (string) request()->query('featured', 'all'),
'prompt_of_week' => (string) request()->query('prompt_of_week', 'all'),
'active' => (string) request()->query('active', 'all'),
'access_level' => (string) request()->query('access_level', 'all'),
'difficulty' => (string) request()->query('difficulty', 'all'),
'order' => (string) request()->query('order', 'updated_desc'),
];
$filters = array_merge($filters, $promptFilters);
$this->applyPromptAdminSearch($query, $search);
$this->applyPromptAdminFilters($query, $promptFilters);
$this->applyPromptAdminOrdering($query, $promptFilters['order']);
$summary = $this->promptAdminSummary($search, $promptFilters);
} else {
$query->latest('updated_at');
}
if ($resource === 'prompts') {
$query->with('category');
}
if ($resource === 'lessons') {
$query->with('courses:id,title');
}
@@ -646,48 +662,191 @@ final class AcademyAdminController extends Controller
'items' => $items,
'columns' => $meta['columns'],
'createUrl' => route($meta['route_base'].'.create'),
'filters' => [
'search' => $search,
],
'filters' => $filters,
'filterOptions' => $resource === 'prompts' ? [
'categories' => $this->promptAdminCategoryFilterOptions(),
'difficulty' => $this->filterOptionsWithAll($this->difficultyOptions(), 'All difficulties'),
'access' => $this->filterOptionsWithAll($this->accessOptions(), 'All access levels'),
'featured' => [
['value' => 'all', 'label' => 'Any featured state'],
['value' => 'yes', 'label' => 'Featured only'],
['value' => 'no', 'label' => 'Not featured'],
],
'promptOfWeek' => [
['value' => 'all', 'label' => 'Any weekly state'],
['value' => 'yes', 'label' => 'Prompt of the week'],
['value' => 'no', 'label' => 'Not prompt of the week'],
],
'active' => [
['value' => 'all', 'label' => 'Any visibility state'],
['value' => 'active', 'label' => 'Active only'],
['value' => 'inactive', 'label' => 'Inactive only'],
],
'order' => [
['value' => 'updated_desc', 'label' => 'Updated newest'],
['value' => 'updated_asc', 'label' => 'Updated oldest'],
['value' => 'views_desc', 'label' => 'Most viewed'],
['value' => 'title_asc', 'label' => 'Title A-Z'],
['value' => 'title_desc', 'label' => 'Title Z-A'],
['value' => 'access_asc', 'label' => 'Access'],
['value' => 'difficulty_asc', 'label' => 'Difficulty'],
['value' => 'featured_desc', 'label' => 'Featured first'],
],
] : null,
'summary' => $resource === 'courses' ? [
'total' => (int) $items->total(),
'published' => (int) (clone $meta['model']::query())->when($search !== '', function ($builder) use ($search): void {
$like = '%'.str_replace(['%', '_'], ['\\%', '\\_'], $search).'%';
$builder->where(function ($inner) use ($like): void {
$inner->where('title', 'like', $like)
->orWhere('slug', 'like', $like)
->orWhere('subtitle', 'like', $like)
->orWhere('excerpt', 'like', $like)
->orWhere('description', 'like', $like);
});
})->where('status', AcademyCourse::STATUS_PUBLISHED)->count(),
'featured' => (int) (clone $meta['model']::query())->when($search !== '', function ($builder) use ($search): void {
$like = '%'.str_replace(['%', '_'], ['\\%', '\\_'], $search).'%';
$builder->where(function ($inner) use ($like): void {
$inner->where('title', 'like', $like)
->orWhere('slug', 'like', $like)
->orWhere('subtitle', 'like', $like)
->orWhere('excerpt', 'like', $like)
->orWhere('description', 'like', $like);
});
})->where('is_featured', true)->count(),
'drafts' => (int) (clone $meta['model']::query())->when($search !== '', function ($builder) use ($search): void {
$like = '%'.str_replace(['%', '_'], ['\\%', '\\_'], $search).'%';
$builder->where(function ($inner) use ($like): void {
$inner->where('title', 'like', $like)
->orWhere('slug', 'like', $like)
->orWhere('subtitle', 'like', $like)
->orWhere('excerpt', 'like', $like)
->orWhere('description', 'like', $like);
});
})->where('status', AcademyCourse::STATUS_DRAFT)->count(),
] : null,
'published' => (int) (clone $meta['model']::query())->tap(fn ($builder) => $this->applyCourseAdminSearch($builder, $search))->where('status', AcademyCourse::STATUS_PUBLISHED)->count(),
'featured' => (int) (clone $meta['model']::query())->tap(fn ($builder) => $this->applyCourseAdminSearch($builder, $search))->where('is_featured', true)->count(),
'drafts' => (int) (clone $meta['model']::query())->tap(fn ($builder) => $this->applyCourseAdminSearch($builder, $search))->where('status', AcademyCourse::STATUS_DRAFT)->count(),
] : $summary,
]);
}
private function applyCourseAdminSearch($query, string $search): void
{
if ($search === '') {
return;
}
$like = '%'.str_replace(['%', '_'], ['\\%', '\\_'], $search).'%';
$query->where(function ($builder) use ($like): void {
$builder->where('title', 'like', $like)
->orWhere('slug', 'like', $like)
->orWhere('subtitle', 'like', $like)
->orWhere('excerpt', 'like', $like)
->orWhere('description', 'like', $like);
});
}
private function applyPromptAdminSearch($query, string $search): void
{
if ($search === '') {
return;
}
$like = '%'.str_replace(['%', '_'], ['\\%', '\\_'], $search).'%';
$query->where(function ($builder) use ($like): void {
$builder->where('title', 'like', $like)
->orWhere('slug', 'like', $like)
->orWhere('excerpt', 'like', $like)
->orWhere('prompt', 'like', $like)
->orWhere('negative_prompt', 'like', $like)
->orWhere('usage_notes', 'like', $like)
->orWhere('workflow_notes', 'like', $like)
->orWhereHas('category', function ($categoryQuery) use ($like): void {
$categoryQuery->where('name', 'like', $like);
});
});
}
private function applyPromptAdminFilters($query, array $filters, bool $includeAccessFilter = true): void
{
$category = (string) ($filters['category'] ?? 'all');
$featured = (string) ($filters['featured'] ?? 'all');
$promptOfWeek = (string) ($filters['prompt_of_week'] ?? 'all');
$active = (string) ($filters['active'] ?? 'all');
$accessLevel = (string) ($filters['access_level'] ?? 'all');
$difficulty = (string) ($filters['difficulty'] ?? 'all');
if ($category === 'uncategorized') {
$query->whereNull('category_id');
} elseif ($category !== '' && $category !== 'all' && ctype_digit($category)) {
$query->where('category_id', (int) $category);
}
if ($featured === 'yes') {
$query->where('featured', true);
} elseif ($featured === 'no') {
$query->where('featured', false);
}
if ($promptOfWeek === 'yes') {
$query->where('prompt_of_week', true);
} elseif ($promptOfWeek === 'no') {
$query->where('prompt_of_week', false);
}
if ($active === 'active') {
$query->where('active', true);
} elseif ($active === 'inactive') {
$query->where('active', false);
}
if ($includeAccessFilter && in_array($accessLevel, ['free', 'creator', 'pro'], true)) {
$query->where('access_level', $accessLevel);
}
if (in_array($difficulty, array_column($this->difficultyOptions(), 'value'), true)) {
$query->where('difficulty', $difficulty);
}
}
private function applyPromptAdminOrdering($query, string $order): void
{
match ($order) {
'updated_asc' => $query->orderBy('updated_at')->orderBy('id'),
'views_desc' => $query->orderByDesc('total_views')->orderByDesc('updated_at')->orderByDesc('id'),
'title_asc' => $query->orderBy('title')->orderByDesc('updated_at'),
'title_desc' => $query->orderByDesc('title')->orderByDesc('updated_at'),
'access_asc' => $query->orderByRaw("FIELD(access_level, 'free', 'creator', 'pro')")->orderBy('title'),
'difficulty_asc' => $query->orderBy('difficulty')->orderBy('title'),
'featured_desc' => $query->orderByDesc('featured')->orderByDesc('prompt_of_week')->orderBy('title'),
default => $query->orderByDesc('updated_at')->orderByDesc('id'),
};
}
private function promptAdminSummary(string $search, array $filters): array
{
$summaryQuery = AcademyPromptTemplate::query();
$accessSummaryQuery = AcademyPromptTemplate::query();
$this->applyPromptAdminSearch($summaryQuery, $search);
$this->applyPromptAdminFilters($summaryQuery, $filters);
$this->applyPromptAdminSearch($accessSummaryQuery, $search);
$this->applyPromptAdminFilters($accessSummaryQuery, $filters, false);
return [
'total' => (int) $summaryQuery->count(),
'active' => (int) (clone $summaryQuery)->where('active', true)->count(),
'featured' => (int) (clone $summaryQuery)->where('featured', true)->count(),
'promptOfWeek' => (int) (clone $summaryQuery)->where('prompt_of_week', true)->count(),
'access' => [
'free' => (int) (clone $accessSummaryQuery)->where('access_level', 'free')->count(),
'creator' => (int) (clone $accessSummaryQuery)->where('access_level', 'creator')->count(),
'pro' => (int) (clone $accessSummaryQuery)->where('access_level', 'pro')->count(),
],
];
}
private function promptAdminCategoryFilterOptions(): array
{
return AcademyCategory::query()
->where('type', 'prompt')
->orderBy('order_num')
->orderBy('name')
->get()
->map(fn (AcademyCategory $category): array => ['value' => (string) $category->id, 'label' => $category->name])
->prepend(['value' => 'uncategorized', 'label' => 'Uncategorized'])
->prepend(['value' => 'all', 'label' => 'All categories'])
->values()
->all();
}
private function filterOptionsWithAll(array $options, string $allLabel): array
{
return collect($options)
->map(fn (array $option): array => [
'value' => (string) ($option['value'] ?? ''),
'label' => (string) ($option['label'] ?? $option['value'] ?? ''),
])
->prepend(['value' => 'all', 'label' => $allLabel])
->values()
->all();
}
private function renderForm(string $resource, Model $record): Response
{
$meta = $this->resourceMeta($resource);
@@ -893,7 +1052,7 @@ final class AcademyAdminController extends Controller
'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'],
'columns' => ['title', 'category_name', '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'],
@@ -907,6 +1066,7 @@ final class AcademyAdminController extends Controller
['name' => 'placeholders', 'label' => 'Placeholders JSON', 'type' => 'json'],
['name' => 'helper_prompts', 'label' => 'Helper Prompts JSON', 'type' => 'json'],
['name' => 'prompt_variants', 'label' => 'Prompt Variants JSON', 'type' => 'json'],
['name' => 'filled_examples', 'label' => 'Filled Examples JSON', 'type' => 'json'],
['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'],
@@ -1048,6 +1208,7 @@ final class AcademyAdminController extends Controller
'active' => (bool) $model->active,
'preview_image_url' => $this->resolvePromptPreviewImageUrl((string) ($model->preview_image ?? '')),
'comparisons_count' => count($this->serializePromptToolNotes((array) ($model->tool_notes ?? []))),
'views_count' => (int) ($model->total_views ?? 0),
'tags' => array_values(array_filter(array_map(static fn ($tag): string => trim((string) $tag), (array) ($model->tags ?? [])))),
'updated_at' => optional($model->updated_at)->toIso8601String(),
'preview_url' => route('academy.prompts.show', ['slug' => $model->slug]),
@@ -1167,6 +1328,7 @@ final class AcademyAdminController extends Controller
'placeholders' => $this->encodePrettyJsonForForm($record->placeholders),
'helper_prompts' => $this->encodePrettyJsonForForm($record->helper_prompts),
'prompt_variants' => $this->encodePrettyJsonForForm($record->prompt_variants),
'filled_examples' => $this->encodePrettyJsonForForm($record->filled_examples),
'difficulty' => (string) ($record->difficulty ?? 'beginner'),
'access_level' => (string) ($record->access_level ?? 'free'),
'aspect_ratio' => (string) ($record->aspect_ratio ?? ''),
@@ -2174,6 +2336,7 @@ final class AcademyAdminController extends Controller
$validated['placeholders'] = $this->normalizePromptPlaceholders($validated['placeholders'] ?? null);
$validated['helper_prompts'] = $this->normalizePromptHelperPrompts($validated['helper_prompts'] ?? null);
$validated['prompt_variants'] = $this->normalizePromptVariants($validated['prompt_variants'] ?? null);
$validated['filled_examples'] = $this->normalizePromptFilledExamples($validated['filled_examples'] ?? null);
$validated['tool_notes'] = $this->normalizePromptToolNotes((array) ($validated['tool_notes'] ?? []));
$previousToolNotes = $this->normalizePromptToolNotes((array) ($prompt?->tool_notes ?? []));
@@ -2382,6 +2545,56 @@ final class AcademyAdminController extends Controller
->all();
}
/**
* @return array<int, array<string, mixed>>
*/
private function normalizePromptFilledExamples(mixed $filledExamples): array
{
if (! is_array($filledExamples)) {
return [];
}
return collect($filledExamples)
->filter(static fn ($example): bool => is_array($example))
->map(function (array $example): array {
return [
'title' => $this->nullableTrimmedString($example['title'] ?? null),
'description' => $this->nullableTrimmedString($example['description'] ?? null),
'placeholder_values' => collect(is_array($example['placeholder_values'] ?? null) ? $example['placeholder_values'] : [])
->mapWithKeys(function ($value, $key): array {
$normalizedKey = trim((string) $key);
if ($normalizedKey === '') {
return [];
}
$normalizedValue = $this->normalizePromptJsonValue($value);
if ($normalizedValue === null || $normalizedValue === '' || $normalizedValue === []) {
return [];
}
return [$normalizedKey => $normalizedValue];
})
->all(),
'prompt' => $this->nullableTrimmedString($example['prompt'] ?? null),
'negative_prompt' => $this->nullableTrimmedString($example['negative_prompt'] ?? null),
];
})
->filter(function (array $example): bool {
return collect([
$example['title'] ?? null,
$example['description'] ?? null,
$example['prompt'] ?? null,
$example['negative_prompt'] ?? null,
$example['placeholder_values'] ?? null,
])->contains(fn ($item): bool => $item !== null && $item !== '' && $item !== []);
})
->take(5)
->values()
->all();
}
/**
* @return array<int, string>
*/