chore: commit remaining workspace changes
This commit is contained in:
122
app/Console/Commands/AcademyCoursesSyncFoundationsCommand.php
Normal file
122
app/Console/Commands/AcademyCoursesSyncFoundationsCommand.php
Normal file
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\AcademyCourse;
|
||||
use App\Models\AcademyCourseLesson;
|
||||
use App\Models\AcademyCourseSection;
|
||||
use App\Models\AcademyLesson;
|
||||
use App\Services\Academy\AcademyCacheService;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
final class AcademyCoursesSyncFoundationsCommand extends Command
|
||||
{
|
||||
protected $signature = 'academy:courses:sync-foundations';
|
||||
|
||||
protected $description = 'Create or update the default AI-Assisted Digital Art Foundations Academy course.';
|
||||
|
||||
public function __construct(private readonly AcademyCacheService $cache)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$course = AcademyCourse::query()->updateOrCreate(
|
||||
['slug' => 'ai-assisted-digital-art-foundations'],
|
||||
[
|
||||
'title' => 'AI-Assisted Digital Art Foundations',
|
||||
'subtitle' => 'A guided path through prompting, publishing, and better Skinbase-ready workflows.',
|
||||
'excerpt' => 'Learn the foundations of AI-assisted digital art, from better prompts and ethical rules to preparing, tagging, and publishing artwork on Skinbase.',
|
||||
'description' => 'A starter course for Skinbase creators who want a structured path from core AI-art concepts to cleaner publishing-ready results.',
|
||||
'access_level' => 'free',
|
||||
'difficulty' => 'beginner',
|
||||
'status' => 'published',
|
||||
'is_featured' => true,
|
||||
'order_num' => 1,
|
||||
'published_at' => now(),
|
||||
],
|
||||
);
|
||||
|
||||
$sectionOrder = [
|
||||
'Introduction',
|
||||
'Prompting Basics',
|
||||
'Publishing on Skinbase',
|
||||
'Workflow and Quality',
|
||||
];
|
||||
|
||||
$sections = collect($sectionOrder)->mapWithKeys(function (string $title, int $index) use ($course): array {
|
||||
$section = AcademyCourseSection::query()->updateOrCreate(
|
||||
['course_id' => $course->id, 'slug' => Str::slug($title)],
|
||||
[
|
||||
'title' => $title,
|
||||
'order_num' => $index,
|
||||
'is_visible' => true,
|
||||
],
|
||||
);
|
||||
|
||||
return [$title => $section];
|
||||
});
|
||||
|
||||
$lessonMap = [
|
||||
'Introduction' => [
|
||||
'what-is-ai-assisted-digital-art',
|
||||
'ai-ethics-and-skinbase-upload-rules',
|
||||
'ai-generated-vs-ai-assisted-artwork',
|
||||
],
|
||||
'Prompting Basics' => [
|
||||
'prompting-basics-for-skinbase-creators',
|
||||
'how-to-write-better-wallpaper-prompts',
|
||||
'understanding-style-mood-lighting-and-composition',
|
||||
],
|
||||
'Publishing on Skinbase' => [
|
||||
'how-to-prepare-ai-artwork-for-upload',
|
||||
'how-to-choose-better-tags-and-categories',
|
||||
],
|
||||
'Workflow and Quality' => [
|
||||
'how-to-avoid-common-ai-image-problems',
|
||||
'from-idea-to-artwork-a-simple-skinbase-workflow',
|
||||
],
|
||||
];
|
||||
|
||||
$orderNum = 0;
|
||||
foreach ($lessonMap as $sectionTitle => $slugs) {
|
||||
$section = $sections->get($sectionTitle);
|
||||
|
||||
foreach ($slugs as $slug) {
|
||||
$lesson = AcademyLesson::query()->where('slug', $slug)->first();
|
||||
|
||||
if (! $lesson instanceof AcademyLesson) {
|
||||
$this->warn(sprintf('Skipped missing lesson [%s].', $slug));
|
||||
continue;
|
||||
}
|
||||
|
||||
AcademyCourseLesson::query()->updateOrCreate(
|
||||
[
|
||||
'course_id' => $course->id,
|
||||
'lesson_id' => $lesson->id,
|
||||
],
|
||||
[
|
||||
'section_id' => $section?->id,
|
||||
'order_num' => $orderNum,
|
||||
'is_required' => true,
|
||||
],
|
||||
);
|
||||
|
||||
$orderNum++;
|
||||
}
|
||||
}
|
||||
|
||||
$course->forceFill([
|
||||
'lessons_count_cache' => AcademyCourseLesson::query()->where('course_id', $course->id)->count(),
|
||||
])->save();
|
||||
|
||||
$this->cache->clearAll();
|
||||
$this->info('AI-Assisted Digital Art Foundations course synced.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ use App\Console\Commands\BackfillArtworkVectorIndexCommand;
|
||||
use App\Console\Commands\IndexArtworkVectorsCommand;
|
||||
use App\Console\Commands\SearchArtworkVectorsCommand;
|
||||
use App\Console\Commands\AggregateSimilarArtworkAnalyticsCommand;
|
||||
use App\Console\Commands\AcademyCoursesSyncFoundationsCommand;
|
||||
use App\Console\Commands\AggregateFeedAnalyticsCommand;
|
||||
use App\Console\Commands\AggregateTagInteractionAnalyticsCommand;
|
||||
use App\Console\Commands\SeedTagInteractionDemoCommand;
|
||||
@@ -71,6 +72,7 @@ class Kernel extends ConsoleKernel
|
||||
ZipUnsupportedArtworkOriginalsCommand::class,
|
||||
SendTestMail::class,
|
||||
DispatchCollectionMaintenanceCommand::class,
|
||||
AcademyCoursesSyncFoundationsCommand::class,
|
||||
BackfillArtworkEmbeddingsCommand::class,
|
||||
BackfillArtworkVectorIndexCommand::class,
|
||||
IndexArtworkVectorsCommand::class,
|
||||
|
||||
184
app/Http/Controllers/Academy/AcademyCourseController.php
Normal file
184
app/Http/Controllers/Academy/AcademyCourseController.php
Normal file
@@ -0,0 +1,184 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Academy;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\AcademyCourse;
|
||||
use App\Models\AcademyCourseLesson;
|
||||
use App\Services\Academy\AcademyAccessService;
|
||||
use App\Services\Academy\AcademyCacheService;
|
||||
use App\Services\Academy\AcademyCourseNavigationService;
|
||||
use App\Services\Academy\AcademyCourseProgressService;
|
||||
use App\Support\Seo\SeoFactory;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
final class AcademyCourseController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AcademyAccessService $access,
|
||||
private readonly AcademyCacheService $cache,
|
||||
private readonly AcademyCourseNavigationService $navigation,
|
||||
private readonly AcademyCourseProgressService $progress,
|
||||
) {
|
||||
}
|
||||
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
abort_unless((bool) config('academy.enabled', true), 404);
|
||||
|
||||
$filters = $request->validate([
|
||||
'difficulty' => ['nullable', 'string', 'max:40'],
|
||||
'access' => ['nullable', 'string', 'max:40'],
|
||||
]);
|
||||
|
||||
$query = AcademyCourse::query()->published()->ordered();
|
||||
|
||||
if (filled($filters['difficulty'] ?? null)) {
|
||||
$query->where('difficulty', $filters['difficulty']);
|
||||
}
|
||||
|
||||
if (filled($filters['access'] ?? null)) {
|
||||
$query->where('access_level', $filters['access']);
|
||||
}
|
||||
|
||||
$courses = $query->paginate(12)->withQueryString();
|
||||
$courses->getCollection()->transform(function (AcademyCourse $course) use ($request): array {
|
||||
return $this->access->coursePayload($course, $request->user(), [
|
||||
'progress' => $this->progress->getProgress($request->user(), $course),
|
||||
]);
|
||||
});
|
||||
|
||||
$featuredCourses = collect($this->cache->featuredCourses())->map(fn (AcademyCourse $course): array => $this->access->coursePayload($course, $request->user(), [
|
||||
'progress' => $this->progress->getProgress($request->user(), $course),
|
||||
]))->values();
|
||||
|
||||
$seoCourses = $featuredCourses
|
||||
->concat(collect($courses->items()))
|
||||
->unique(fn (array $course): string => (string) ($course['slug'] ?? ''))
|
||||
->values();
|
||||
|
||||
$seo = app(SeoFactory::class)
|
||||
->academyCourseListingPage(
|
||||
'Academy Courses — Skinbase',
|
||||
'Follow guided Skinbase AI Academy courses built from reusable lessons, chapters, and creator workflows.',
|
||||
route('academy.courses.index', $request->query()),
|
||||
$seoCourses,
|
||||
[
|
||||
['name' => 'Academy', 'url' => route('academy.index')],
|
||||
['name' => 'Courses', 'url' => route('academy.courses.index')],
|
||||
],
|
||||
)
|
||||
->toArray();
|
||||
|
||||
return Inertia::render('Academy/CoursesIndex', [
|
||||
'seo' => $seo,
|
||||
'title' => 'Academy courses',
|
||||
'description' => 'Guided learning paths built from reusable Academy lessons and creator workflows.',
|
||||
'items' => $courses,
|
||||
'featuredCourses' => $featuredCourses->all(),
|
||||
'filters' => $filters,
|
||||
'pricingUrl' => route('academy.pricing'),
|
||||
])->rootView('collections');
|
||||
}
|
||||
|
||||
public function show(Request $request, AcademyCourse $course): Response
|
||||
{
|
||||
abort_unless((bool) config('academy.enabled', true), 404);
|
||||
abort_unless($course->isPublished(), 404);
|
||||
|
||||
$course->load(['sections', 'courseLessons.section', 'courseLessons.lesson.category']);
|
||||
|
||||
$progress = $this->progress->getProgress($request->user(), $course);
|
||||
$completedLessonIds = $request->user() ? $this->progress->getCompletedLessonIds($request->user(), $course) : [];
|
||||
$orderedLessons = $this->navigation->orderedCourseLessons($course);
|
||||
$stepMeta = $orderedLessons
|
||||
->values()
|
||||
->mapWithKeys(fn (AcademyCourseLesson $courseLesson, int $index): array => [
|
||||
$courseLesson->id => [
|
||||
'course_step_number' => $index + 1,
|
||||
'course_step_label' => sprintf('Step %02d', $index + 1),
|
||||
],
|
||||
]);
|
||||
$sections = $course->sections
|
||||
->sortBy([['order_num', 'asc'], ['id', 'asc']])
|
||||
->values()
|
||||
->map(function ($section) use ($completedLessonIds, $orderedLessons, $request, $stepMeta): array {
|
||||
$sectionLessons = $orderedLessons
|
||||
->where('section_id', $section->id)
|
||||
->values()
|
||||
->map(fn (AcademyCourseLesson $courseLesson): array => $this->access->courseLessonPayload($courseLesson, $request->user(), false, [
|
||||
'completed_lesson_ids' => $completedLessonIds,
|
||||
...((array) $stepMeta->get($courseLesson->id, [])),
|
||||
]))
|
||||
->all();
|
||||
|
||||
return [
|
||||
'id' => (int) $section->id,
|
||||
'title' => (string) $section->title,
|
||||
'slug' => (string) ($section->slug ?? ''),
|
||||
'description' => (string) ($section->description ?? ''),
|
||||
'order_num' => (int) ($section->order_num ?? 0),
|
||||
'is_visible' => (bool) ($section->is_visible ?? true),
|
||||
'lessons' => $sectionLessons,
|
||||
];
|
||||
})
|
||||
->all();
|
||||
|
||||
$unsectionedLessons = $orderedLessons
|
||||
->whereNull('section_id')
|
||||
->values()
|
||||
->map(fn (AcademyCourseLesson $courseLesson): array => $this->access->courseLessonPayload($courseLesson, $request->user(), false, [
|
||||
'completed_lesson_ids' => $completedLessonIds,
|
||||
...((array) $stepMeta->get($courseLesson->id, [])),
|
||||
]))
|
||||
->all();
|
||||
|
||||
$coursePayload = $this->access->coursePayload($course, $request->user(), ['progress' => $progress]);
|
||||
$courseKeywords = collect(explode(',', (string) ($course->meta_keywords ?? '')))
|
||||
->map(fn (string $keyword): string => trim($keyword))
|
||||
->filter()
|
||||
->values()
|
||||
->all();
|
||||
$courseImage = (string) ($coursePayload['cover_image_url'] ?? $coursePayload['teaser_image_url'] ?? $course->og_image ?? $course->cover_image ?? $course->teaser_image ?? '');
|
||||
|
||||
$seo = app(SeoFactory::class)
|
||||
->academyCoursePage(
|
||||
(string) ($course->seo_title ?: ($course->title . ' — Skinbase Academy')),
|
||||
(string) ($course->seo_description ?: $course->excerpt ?: 'Skinbase Academy course'),
|
||||
route('academy.courses.show', ['course' => $course->slug]),
|
||||
$courseImage,
|
||||
[
|
||||
['name' => 'Academy', 'url' => route('academy.index')],
|
||||
['name' => 'Courses', 'url' => route('academy.courses.index')],
|
||||
['name' => (string) $course->title, 'url' => route('academy.courses.show', ['course' => $course->slug])],
|
||||
],
|
||||
$courseKeywords,
|
||||
$course->published_at?->toAtomString(),
|
||||
$course->updated_at?->toAtomString(),
|
||||
(string) ($course->access_level ?? ''),
|
||||
(string) ($course->difficulty ?? ''),
|
||||
(int) ($course->estimated_minutes ?? 0),
|
||||
$orderedLessons
|
||||
->values()
|
||||
->map(fn (AcademyCourseLesson $courseLesson): array => $this->access->courseLessonPayload($courseLesson, $request->user(), false, [
|
||||
'completed_lesson_ids' => $completedLessonIds,
|
||||
...((array) $stepMeta->get($courseLesson->id, [])),
|
||||
]))
|
||||
->all(),
|
||||
)
|
||||
->toArray();
|
||||
|
||||
return Inertia::render('Academy/CoursesShow', [
|
||||
'seo' => $seo,
|
||||
'course' => $coursePayload,
|
||||
'sections' => $sections,
|
||||
'unsectionedLessons' => $unsectionedLessons,
|
||||
'pricingUrl' => route('academy.pricing'),
|
||||
'startUrl' => $request->user() ? route('academy.courses.start', ['course' => $course->slug]) : null,
|
||||
])->rootView('collections');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Academy;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\AcademyCourse;
|
||||
use App\Services\Academy\AcademyCourseProgressService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
final class AcademyCourseEnrollmentController extends Controller
|
||||
{
|
||||
public function __construct(private readonly AcademyCourseProgressService $progress)
|
||||
{
|
||||
}
|
||||
|
||||
public function start(Request $request, AcademyCourse $course): RedirectResponse
|
||||
{
|
||||
abort_unless((bool) config('academy.enabled', true), 404);
|
||||
abort_unless($course->isPublished(), 404);
|
||||
|
||||
$this->progress->markEnrollmentStarted($request->user(), $course);
|
||||
$continueLesson = $this->progress->getContinueLesson($request->user(), $course);
|
||||
|
||||
if ($continueLesson?->lesson) {
|
||||
return redirect()->route('academy.courses.lessons.show', ['course' => $course->slug, 'lesson' => $continueLesson->lesson->slug]);
|
||||
}
|
||||
|
||||
return redirect()->route('academy.courses.show', ['course' => $course->slug]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Academy;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\AcademyCourse;
|
||||
use App\Models\AcademyLesson;
|
||||
use App\Services\Academy\AcademyAccessService;
|
||||
use App\Services\Academy\AcademyCourseNavigationService;
|
||||
use App\Services\Academy\AcademyCourseProgressService;
|
||||
use App\Support\Seo\SeoFactory;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
final class AcademyCourseLessonController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AcademyAccessService $access,
|
||||
private readonly AcademyCourseNavigationService $navigation,
|
||||
private readonly AcademyCourseProgressService $progress,
|
||||
) {
|
||||
}
|
||||
|
||||
public function show(Request $request, AcademyCourse $course, AcademyLesson $lesson): Response
|
||||
{
|
||||
abort_unless((bool) config('academy.enabled', true), 404);
|
||||
abort_unless($course->isPublished(), 404);
|
||||
|
||||
$course->load(['sections', 'courseLessons.section', 'courseLessons.lesson.category']);
|
||||
$courseLesson = $this->navigation->findCourseLesson($course, $lesson);
|
||||
|
||||
abort_unless($courseLesson instanceof \App\Models\AcademyCourseLesson, 404);
|
||||
|
||||
if ($request->user()) {
|
||||
$this->progress->updateLastLesson($request->user(), $course, $lesson);
|
||||
$this->progress->markCourseCompletedIfFinished($request->user(), $course);
|
||||
}
|
||||
|
||||
$progress = $this->progress->getProgress($request->user(), $course);
|
||||
$previousLesson = $this->navigation->previousLesson($course, $lesson);
|
||||
$nextLesson = $this->navigation->nextLesson($course, $lesson);
|
||||
$courseOutline = $this->navigation->orderedCourseLessons($course)
|
||||
->map(fn (\App\Models\AcademyCourseLesson $entry): array => $this->access->courseLessonPayload($entry, $request->user()))
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$payload = $this->access->courseLessonPayload($courseLesson, $request->user(), true);
|
||||
$canonical = route('academy.courses.lessons.show', ['course' => $course->slug, 'lesson' => $lesson->slug]);
|
||||
$description = Str::limit(trim((string) ($lesson->seo_description ?? $lesson->excerpt ?? 'Skinbase Academy course lesson.')), 160, '...');
|
||||
$seo = app(SeoFactory::class)->academyLessonPage(
|
||||
(string) ($lesson->seo_title ?? ($lesson->title . ' — ' . $course->title)),
|
||||
$description,
|
||||
$canonical,
|
||||
(string) ($payload['article_cover_image_url'] ?? $payload['cover_image_url'] ?? $lesson->cover_image ?? ''),
|
||||
[
|
||||
['name' => 'Academy', 'url' => route('academy.index')],
|
||||
['name' => 'Courses', 'url' => route('academy.courses.index')],
|
||||
['name' => (string) $course->title, 'url' => route('academy.courses.show', ['course' => $course->slug])],
|
||||
['name' => (string) $lesson->title, 'url' => $canonical],
|
||||
],
|
||||
array_values((array) ($payload['tags'] ?? [])),
|
||||
$lesson->published_at?->toAtomString(),
|
||||
$lesson->updated_at?->toAtomString(),
|
||||
(string) $course->title,
|
||||
)->toArray();
|
||||
|
||||
return Inertia::render('Academy/Show', [
|
||||
'pageType' => 'lesson',
|
||||
'item' => $payload,
|
||||
'relatedLessons' => [],
|
||||
'relatedCourses' => [],
|
||||
'previousLesson' => $previousLesson ? $this->access->courseLessonPayload($previousLesson, $request->user()) : null,
|
||||
'nextLesson' => $nextLesson ? $this->access->courseLessonPayload($nextLesson, $request->user()) : null,
|
||||
'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,
|
||||
'courseContext' => [
|
||||
'id' => (int) $course->id,
|
||||
'title' => (string) $course->title,
|
||||
'slug' => (string) $course->slug,
|
||||
'subtitle' => (string) ($course->subtitle ?? ''),
|
||||
'showUrl' => route('academy.courses.show', ['course' => $course->slug]),
|
||||
'completePayload' => ['course_id' => $course->id],
|
||||
'progress' => [
|
||||
'percent' => (int) ($progress['progress_percent'] ?? 0),
|
||||
'completedRequired' => (int) ($progress['completed_required'] ?? 0),
|
||||
'totalRequired' => (int) ($progress['total_required'] ?? 0),
|
||||
'completed' => (bool) ($progress['completed'] ?? false),
|
||||
],
|
||||
'outline' => $courseOutline,
|
||||
],
|
||||
])->rootView('collections');
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ namespace App\Http\Controllers\Academy;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\AcademyChallenge;
|
||||
use App\Models\AcademyCourse;
|
||||
use App\Models\AcademyLesson;
|
||||
use App\Models\AcademyPromptTemplate;
|
||||
use App\Services\Academy\AcademyAccessService;
|
||||
@@ -41,11 +42,13 @@ final class AcademyHomeController extends Controller
|
||||
$home = $this->cache->homePayload(function (): array {
|
||||
return [
|
||||
'featuredLessons' => $this->cache->featuredLessons(),
|
||||
'featuredCourses' => $this->cache->featuredCourses(),
|
||||
'featuredPrompts' => $this->cache->featuredPrompts(),
|
||||
'featuredChallenges' => (bool) config('academy.challenges_enabled', true)
|
||||
? $this->cache->featuredChallenges()
|
||||
: [],
|
||||
'lessonCount' => AcademyLesson::query()->active()->published()->count(),
|
||||
'courseCount' => AcademyCourse::query()->published()->count(),
|
||||
'promptCount' => AcademyPromptTemplate::query()->active()->published()->count(),
|
||||
'challengeCount' => (bool) config('academy.challenges_enabled', true)
|
||||
? AcademyChallenge::query()->publiclyVisible()->count()
|
||||
@@ -58,6 +61,7 @@ final class AcademyHomeController extends Controller
|
||||
'pricingUrl' => route('academy.pricing'),
|
||||
'links' => [
|
||||
'lessons' => route('academy.lessons.index'),
|
||||
'courses' => route('academy.courses.index'),
|
||||
'prompts' => route('academy.prompts.index'),
|
||||
'packs' => route('academy.packs.index'),
|
||||
'challenges' => route('academy.challenges.index'),
|
||||
@@ -69,9 +73,11 @@ final class AcademyHomeController extends Controller
|
||||
],
|
||||
'stats' => [
|
||||
'lessonCount' => (int) $home['lessonCount'],
|
||||
'courseCount' => (int) $home['courseCount'],
|
||||
'promptCount' => (int) $home['promptCount'],
|
||||
'challengeCount' => (int) $home['challengeCount'],
|
||||
],
|
||||
'featuredCourses' => collect($home['featuredCourses'])->map(fn (AcademyCourse $course): array => $this->access->coursePayload($course, $request->user()))->values()->all(),
|
||||
'featuredLessons' => collect($home['featuredLessons'])->map(fn (AcademyLesson $lesson): array => $this->access->lessonPayload($lesson, $request->user()))->values()->all(),
|
||||
'featuredPrompts' => collect($home['featuredPrompts'])->map(fn (AcademyPromptTemplate $prompt): array => $this->access->promptPayload($prompt, $request->user()))->values()->all(),
|
||||
'featuredChallenges' => collect($home['featuredChallenges'])->map(fn (AcademyChallenge $challenge): array => $this->access->challengePayload($challenge, $request->user(), true))->values()->all(),
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Http\Controllers\Academy;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\AcademyCourse;
|
||||
use App\Models\AcademyLesson;
|
||||
use App\Services\Academy\AcademyAccessService;
|
||||
use App\Services\Academy\AcademyCacheService;
|
||||
@@ -35,7 +36,7 @@ final class AcademyLessonController extends Controller
|
||||
->with('category')
|
||||
->active()
|
||||
->published()
|
||||
->latest('published_at');
|
||||
->orderedForCourse();
|
||||
|
||||
if (filled($filters['q'] ?? null)) {
|
||||
$query->where(function ($builder) use ($filters): void {
|
||||
@@ -87,33 +88,73 @@ final class AcademyLessonController extends Controller
|
||||
->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()
|
||||
: [];
|
||||
$courseQuery = AcademyLesson::query()
|
||||
->with('category')
|
||||
->active()
|
||||
->published();
|
||||
|
||||
if (filled($lesson->series_name)) {
|
||||
$courseQuery->where('series_name', $lesson->series_name);
|
||||
} elseif ($lesson->category_id !== null) {
|
||||
$courseQuery->where('category_id', $lesson->category_id);
|
||||
} else {
|
||||
$courseQuery->whereKey($lesson->id);
|
||||
}
|
||||
|
||||
$courseLessons = $courseQuery
|
||||
->orderedForCourse()
|
||||
->get()
|
||||
->filter(fn (AcademyLesson $courseLesson): bool => $this->access->canAccessLesson($request->user(), $courseLesson))
|
||||
->values();
|
||||
|
||||
$currentIndex = $courseLessons->search(fn (AcademyLesson $courseLesson): bool => $courseLesson->is($lesson));
|
||||
$previousLesson = is_int($currentIndex) && $currentIndex > 0
|
||||
? $courseLessons->get($currentIndex - 1)
|
||||
: null;
|
||||
$nextLesson = is_int($currentIndex) && $currentIndex < ($courseLessons->count() - 1)
|
||||
? $courseLessons->get($currentIndex + 1)
|
||||
: null;
|
||||
|
||||
$relatedLessons = $courseLessons
|
||||
->reject(fn (AcademyLesson $courseLesson): bool => $courseLesson->is($lesson))
|
||||
->take(6)
|
||||
->map(fn (AcademyLesson $relatedLesson): array => $this->access->lessonPayload($relatedLesson, $request->user()))
|
||||
->values()
|
||||
->all();
|
||||
$relatedCourses = AcademyCourse::query()
|
||||
->published()
|
||||
->ordered()
|
||||
->whereHas('courseLessons', fn ($builder) => $builder->where('lesson_id', $lesson->id))
|
||||
->limit(3)
|
||||
->get()
|
||||
->map(fn (AcademyCourse $course): array => $this->access->coursePayload($course, $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(
|
||||
$seo = app(SeoFactory::class)->academyLessonPage(
|
||||
(string) ($lesson->seo_title ?? ($lesson->title.' — Skinbase Academy')),
|
||||
$description,
|
||||
$canonical,
|
||||
$lesson->cover_image,
|
||||
(string) ($payload['article_cover_image_url'] ?? $payload['cover_image_url'] ?? $lesson->cover_image ?? ''),
|
||||
[
|
||||
['name' => 'Academy', 'url' => route('academy.index')],
|
||||
['name' => 'Lessons', 'url' => route('academy.lessons.index')],
|
||||
['name' => (string) $lesson->title, 'url' => $canonical],
|
||||
],
|
||||
array_values((array) ($payload['tags'] ?? [])),
|
||||
$lesson->published_at?->toAtomString(),
|
||||
$lesson->updated_at?->toAtomString(),
|
||||
(string) ($lesson->series_name ?: $lesson->category?->name ?: 'Academy'),
|
||||
)->toArray();
|
||||
|
||||
return Inertia::render('Academy/Show', [
|
||||
'pageType' => 'lesson',
|
||||
'item' => $payload,
|
||||
'relatedLessons' => $relatedLessons,
|
||||
'relatedCourses' => $relatedCourses,
|
||||
'previousLesson' => $previousLesson ? $this->access->lessonPayload($previousLesson, $request->user()) : null,
|
||||
'nextLesson' => $nextLesson ? $this->access->lessonPayload($nextLesson, $request->user()) : null,
|
||||
'seo' => $seo,
|
||||
'pricingUrl' => route('academy.pricing'),
|
||||
'completeUrl' => $request->user() ? route('academy.lessons.complete', ['lesson' => $lesson->id]) : null,
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Http\Controllers\Academy;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\AcademyCourse;
|
||||
use App\Models\AcademyLesson;
|
||||
use App\Services\Academy\AcademyAccessService;
|
||||
use App\Services\Academy\AcademyProgressService;
|
||||
@@ -24,7 +25,13 @@ final class AcademyProgressController extends Controller
|
||||
abort_unless((bool) config('academy.enabled', true), 404);
|
||||
abort_unless($this->access->canAccessLesson($request->user(), $lesson), 403);
|
||||
|
||||
$record = $this->progress->markLessonComplete($request->user(), $lesson);
|
||||
$course = null;
|
||||
|
||||
if ($request->filled('course_id')) {
|
||||
$course = AcademyCourse::query()->published()->find($request->integer('course_id'));
|
||||
}
|
||||
|
||||
$record = $this->progress->markLessonComplete($request->user(), $lesson, $course);
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
|
||||
@@ -98,7 +98,7 @@ class ForumController extends Controller
|
||||
|
||||
$thread->loadMissing([
|
||||
'category:id,name,slug',
|
||||
'user:id,name',
|
||||
'user:id,name,username',
|
||||
'user.profile:user_id,avatar_hash',
|
||||
]);
|
||||
|
||||
@@ -116,7 +116,7 @@ class ForumController extends Controller
|
||||
$opPost = ForumPost::query()
|
||||
->where('thread_id', $thread->id)
|
||||
->with([
|
||||
'user:id,name',
|
||||
'user:id,name,username',
|
||||
'user.profile:user_id,avatar_hash',
|
||||
'attachments:id,post_id,file_path,file_size,mime_type,width,height',
|
||||
])
|
||||
@@ -128,7 +128,7 @@ class ForumController extends Controller
|
||||
->where('thread_id', $thread->id)
|
||||
->when($opPost, fn ($query) => $query->where('id', '!=', $opPost->id))
|
||||
->with([
|
||||
'user:id,name',
|
||||
'user:id,name,username',
|
||||
'user.profile:user_id,avatar_hash',
|
||||
'attachments:id,post_id,file_path,file_size,mime_type,width,height',
|
||||
])
|
||||
@@ -148,7 +148,7 @@ class ForumController extends Controller
|
||||
if ($quotePostId > 0) {
|
||||
$quotedPost = ForumPost::query()
|
||||
->where('thread_id', $thread->id)
|
||||
->with('user:id,name')
|
||||
->with('user:id,name,username')
|
||||
->find($quotePostId);
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
308
app/Http/Controllers/Settings/AcademyCourseBuilderController.php
Normal file
308
app/Http/Controllers/Settings/AcademyCourseBuilderController.php
Normal file
@@ -0,0 +1,308 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Settings;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\AcademyCourse;
|
||||
use App\Models\AcademyCourseLesson;
|
||||
use App\Models\AcademyCourseSection;
|
||||
use App\Models\AcademyLesson;
|
||||
use App\Services\Academy\AcademyCacheService;
|
||||
use App\Services\Academy\AcademyCourseLessonOrderingService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
final class AcademyCourseBuilderController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AcademyCacheService $cache,
|
||||
private readonly AcademyCourseLessonOrderingService $courseLessonOrdering,
|
||||
)
|
||||
{
|
||||
}
|
||||
|
||||
public function edit(AcademyCourse $academyCourse): Response
|
||||
{
|
||||
$academyCourse->load(['sections', 'courseLessons.section', 'courseLessons.lesson.category']);
|
||||
|
||||
return Inertia::render('Admin/Academy/CourseBuilder', [
|
||||
'course' => $this->serializeCourse($academyCourse),
|
||||
'sections' => $academyCourse->sections
|
||||
->sortBy([['order_num', 'asc'], ['id', 'asc']])
|
||||
->values()
|
||||
->map(fn (AcademyCourseSection $section): array => $this->serializeSection($section))
|
||||
->all(),
|
||||
'courseLessons' => $academyCourse->courseLessons
|
||||
->sortBy([['order_num', 'asc'], ['id', 'asc']])
|
||||
->values()
|
||||
->map(fn (AcademyCourseLesson $courseLesson): array => $this->serializeCourseLesson($courseLesson))
|
||||
->all(),
|
||||
'availableLessons' => AcademyLesson::query()
|
||||
->with('category')
|
||||
->orderBy('title')
|
||||
->get()
|
||||
->map(fn (AcademyLesson $lesson): array => [
|
||||
'id' => (int) $lesson->id,
|
||||
'title' => (string) $lesson->title,
|
||||
'slug' => (string) $lesson->slug,
|
||||
'excerpt' => (string) ($lesson->excerpt ?? ''),
|
||||
'difficulty' => (string) $lesson->difficulty,
|
||||
'access_level' => (string) $lesson->access_level,
|
||||
'active' => (bool) $lesson->active,
|
||||
'published_at' => $lesson->published_at?->toISOString(),
|
||||
'category' => $lesson->category ? (string) $lesson->category->name : '',
|
||||
'attached' => $academyCourse->courseLessons->contains(fn (AcademyCourseLesson $courseLesson): bool => (int) $courseLesson->lesson_id === (int) $lesson->id),
|
||||
])
|
||||
->values()
|
||||
->all(),
|
||||
'routes' => [
|
||||
'index' => route('admin.academy.courses.index'),
|
||||
'edit' => route('admin.academy.courses.edit', ['academyCourse' => $academyCourse]),
|
||||
'preview' => route('academy.courses.show', ['course' => $academyCourse->slug]),
|
||||
'sectionStore' => route('admin.academy.courses.sections.store', ['academyCourse' => $academyCourse]),
|
||||
'attachLesson' => route('admin.academy.courses.lessons.attach', ['academyCourse' => $academyCourse]),
|
||||
'reorder' => route('admin.academy.courses.reorder', ['academyCourse' => $academyCourse]),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function storeSection(Request $request, AcademyCourse $academyCourse): RedirectResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'title' => ['required', 'string', 'max:180'],
|
||||
'slug' => ['nullable', 'string', 'max:180'],
|
||||
'description' => ['nullable', 'string'],
|
||||
'order_num' => ['nullable', 'integer', 'min:0'],
|
||||
'is_visible' => ['nullable', 'boolean'],
|
||||
]);
|
||||
|
||||
$academyCourse->sections()->create([
|
||||
'title' => $data['title'],
|
||||
'slug' => filled($data['slug'] ?? null) ? $data['slug'] : Str::slug($data['title']),
|
||||
'description' => $data['description'] ?? null,
|
||||
'order_num' => (int) ($data['order_num'] ?? ($academyCourse->sections()->max('order_num') + 1)),
|
||||
'is_visible' => (bool) ($data['is_visible'] ?? true),
|
||||
]);
|
||||
|
||||
$this->cache->clearAll();
|
||||
|
||||
return back()->with('success', 'Course section created.');
|
||||
}
|
||||
|
||||
public function updateSection(Request $request, AcademyCourse $academyCourse, AcademyCourseSection $academyCourseSection): RedirectResponse
|
||||
{
|
||||
abort_unless((int) $academyCourseSection->course_id === (int) $academyCourse->id, 404);
|
||||
|
||||
$data = $request->validate([
|
||||
'title' => ['required', 'string', 'max:180'],
|
||||
'slug' => ['nullable', 'string', 'max:180'],
|
||||
'description' => ['nullable', 'string'],
|
||||
'order_num' => ['nullable', 'integer', 'min:0'],
|
||||
'is_visible' => ['nullable', 'boolean'],
|
||||
]);
|
||||
|
||||
$academyCourseSection->forceFill([
|
||||
'title' => $data['title'],
|
||||
'slug' => filled($data['slug'] ?? null) ? $data['slug'] : Str::slug($data['title']),
|
||||
'description' => $data['description'] ?? null,
|
||||
'order_num' => (int) ($data['order_num'] ?? 0),
|
||||
'is_visible' => (bool) ($data['is_visible'] ?? true),
|
||||
])->save();
|
||||
|
||||
$this->cache->clearAll();
|
||||
|
||||
return back()->with('success', 'Course section updated.');
|
||||
}
|
||||
|
||||
public function destroySection(AcademyCourse $academyCourse, AcademyCourseSection $academyCourseSection): RedirectResponse
|
||||
{
|
||||
abort_unless((int) $academyCourseSection->course_id === (int) $academyCourse->id, 404);
|
||||
|
||||
$academyCourseSection->delete();
|
||||
$this->cache->clearAll();
|
||||
|
||||
return back()->with('success', 'Course section deleted.');
|
||||
}
|
||||
|
||||
public function attachLesson(Request $request, AcademyCourse $academyCourse): RedirectResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'lesson_id' => ['required', 'integer', 'exists:academy_lessons,id'],
|
||||
'section_id' => ['nullable', 'integer', 'exists:academy_course_sections,id'],
|
||||
'order_num' => ['nullable', 'integer', 'min:0'],
|
||||
'is_required' => ['nullable', 'boolean'],
|
||||
'access_override' => ['nullable', 'string', 'in:free,premium,creator,pro'],
|
||||
'unlock_after_lesson_id' => ['nullable', 'integer', 'exists:academy_lessons,id'],
|
||||
]);
|
||||
|
||||
if (AcademyCourseLesson::query()->where('course_id', $academyCourse->id)->where('lesson_id', $data['lesson_id'])->exists()) {
|
||||
return back()->with('error', 'That lesson is already attached to this course.');
|
||||
}
|
||||
|
||||
$sectionId = $data['section_id'] ?? null;
|
||||
if ($sectionId !== null) {
|
||||
abort_unless(AcademyCourseSection::query()->where('course_id', $academyCourse->id)->whereKey($sectionId)->exists(), 404);
|
||||
}
|
||||
|
||||
AcademyCourseLesson::query()->create([
|
||||
'course_id' => $academyCourse->id,
|
||||
'section_id' => $sectionId,
|
||||
'lesson_id' => (int) $data['lesson_id'],
|
||||
'order_num' => (int) ($data['order_num'] ?? ($academyCourse->courseLessons()->max('order_num') + 1)),
|
||||
'is_required' => (bool) ($data['is_required'] ?? true),
|
||||
'access_override' => $data['access_override'] ?? null,
|
||||
'unlock_after_lesson_id' => $data['unlock_after_lesson_id'] ?? null,
|
||||
]);
|
||||
|
||||
$this->courseLessonOrdering->syncCourse($academyCourse);
|
||||
$this->syncCourseCounts($academyCourse);
|
||||
|
||||
return back()->with('success', 'Lesson attached to course.');
|
||||
}
|
||||
|
||||
public function updateCourseLesson(Request $request, AcademyCourse $academyCourse, AcademyCourseLesson $academyCourseLesson): RedirectResponse
|
||||
{
|
||||
abort_unless((int) $academyCourseLesson->course_id === (int) $academyCourse->id, 404);
|
||||
|
||||
$data = $request->validate([
|
||||
'section_id' => ['nullable', 'integer', 'exists:academy_course_sections,id'],
|
||||
'order_num' => ['nullable', 'integer', 'min:0'],
|
||||
'is_required' => ['nullable', 'boolean'],
|
||||
'access_override' => ['nullable', 'string', 'in:free,premium,creator,pro'],
|
||||
'unlock_after_lesson_id' => ['nullable', 'integer', 'exists:academy_lessons,id'],
|
||||
]);
|
||||
|
||||
$sectionId = $data['section_id'] ?? null;
|
||||
if ($sectionId !== null) {
|
||||
abort_unless(AcademyCourseSection::query()->where('course_id', $academyCourse->id)->whereKey($sectionId)->exists(), 404);
|
||||
}
|
||||
|
||||
$academyCourseLesson->forceFill([
|
||||
'section_id' => $sectionId,
|
||||
'order_num' => (int) ($data['order_num'] ?? 0),
|
||||
'is_required' => (bool) ($data['is_required'] ?? true),
|
||||
'access_override' => $data['access_override'] ?? null,
|
||||
'unlock_after_lesson_id' => $data['unlock_after_lesson_id'] ?? null,
|
||||
])->save();
|
||||
|
||||
$this->courseLessonOrdering->syncCourse($academyCourse);
|
||||
$this->syncCourseCounts($academyCourse);
|
||||
|
||||
return back()->with('success', 'Course lesson updated.');
|
||||
}
|
||||
|
||||
public function detachLesson(AcademyCourse $academyCourse, AcademyCourseLesson $academyCourseLesson): RedirectResponse
|
||||
{
|
||||
abort_unless((int) $academyCourseLesson->course_id === (int) $academyCourse->id, 404);
|
||||
|
||||
$academyCourseLesson->delete();
|
||||
$this->courseLessonOrdering->syncCourse($academyCourse);
|
||||
$this->syncCourseCounts($academyCourse);
|
||||
|
||||
return back()->with('success', 'Lesson removed from course.');
|
||||
}
|
||||
|
||||
public function reorder(Request $request, AcademyCourse $academyCourse): RedirectResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'sections' => ['nullable', 'array'],
|
||||
'sections.*.id' => ['required', 'integer', 'exists:academy_course_sections,id'],
|
||||
'sections.*.order_num' => ['required', 'integer', 'min:0'],
|
||||
'lessons' => ['nullable', 'array'],
|
||||
'lessons.*.id' => ['required', 'integer', 'exists:academy_course_lessons,id'],
|
||||
'lessons.*.order_num' => ['required', 'integer', 'min:0'],
|
||||
'lessons.*.section_id' => ['nullable', 'integer', 'exists:academy_course_sections,id'],
|
||||
]);
|
||||
|
||||
foreach ((array) ($data['sections'] ?? []) as $sectionData) {
|
||||
AcademyCourseSection::query()
|
||||
->where('course_id', $academyCourse->id)
|
||||
->whereKey((int) $sectionData['id'])
|
||||
->update(['order_num' => (int) $sectionData['order_num']]);
|
||||
}
|
||||
|
||||
foreach ((array) ($data['lessons'] ?? []) as $lessonData) {
|
||||
AcademyCourseLesson::query()
|
||||
->where('course_id', $academyCourse->id)
|
||||
->whereKey((int) $lessonData['id'])
|
||||
->update([
|
||||
'order_num' => (int) $lessonData['order_num'],
|
||||
'section_id' => $lessonData['section_id'] ?? null,
|
||||
]);
|
||||
}
|
||||
|
||||
$this->courseLessonOrdering->syncCourse($academyCourse);
|
||||
$this->syncCourseCounts($academyCourse);
|
||||
|
||||
return back()->with('success', 'Course order updated.');
|
||||
}
|
||||
|
||||
private function syncCourseCounts(AcademyCourse $academyCourse): void
|
||||
{
|
||||
$academyCourse->forceFill([
|
||||
'lessons_count_cache' => $academyCourse->courseLessons()->count(),
|
||||
])->save();
|
||||
|
||||
$this->cache->clearAll();
|
||||
}
|
||||
|
||||
private function serializeCourse(AcademyCourse $course): array
|
||||
{
|
||||
return [
|
||||
'id' => (int) $course->id,
|
||||
'title' => (string) $course->title,
|
||||
'slug' => (string) $course->slug,
|
||||
'subtitle' => (string) ($course->subtitle ?? ''),
|
||||
'excerpt' => (string) ($course->excerpt ?? ''),
|
||||
'description' => (string) ($course->description ?? ''),
|
||||
'access_level' => (string) $course->access_level,
|
||||
'difficulty' => (string) $course->difficulty,
|
||||
'status' => (string) $course->status,
|
||||
'lessons_count_cache' => (int) ($course->lessons_count_cache ?? 0),
|
||||
'cover_image' => (string) ($course->cover_image ?? ''),
|
||||
'published_at' => $course->published_at?->toISOString(),
|
||||
];
|
||||
}
|
||||
|
||||
private function serializeSection(AcademyCourseSection $section): array
|
||||
{
|
||||
return [
|
||||
'id' => (int) $section->id,
|
||||
'title' => (string) $section->title,
|
||||
'slug' => (string) ($section->slug ?? ''),
|
||||
'description' => (string) ($section->description ?? ''),
|
||||
'order_num' => (int) ($section->order_num ?? 0),
|
||||
'is_visible' => (bool) ($section->is_visible ?? true),
|
||||
'updateUrl' => route('admin.academy.courses.sections.update', ['academyCourse' => $section->course_id, 'academyCourseSection' => $section]),
|
||||
'destroyUrl' => route('admin.academy.courses.sections.destroy', ['academyCourse' => $section->course_id, 'academyCourseSection' => $section]),
|
||||
];
|
||||
}
|
||||
|
||||
private function serializeCourseLesson(AcademyCourseLesson $courseLesson): array
|
||||
{
|
||||
$lesson = $courseLesson->lesson;
|
||||
|
||||
return [
|
||||
'id' => (int) $courseLesson->id,
|
||||
'section_id' => $courseLesson->section_id ? (int) $courseLesson->section_id : null,
|
||||
'lesson_id' => (int) $courseLesson->lesson_id,
|
||||
'title' => (string) ($lesson?->title ?? ''),
|
||||
'slug' => (string) ($lesson?->slug ?? ''),
|
||||
'excerpt' => (string) ($lesson?->excerpt ?? ''),
|
||||
'difficulty' => (string) ($lesson?->difficulty ?? ''),
|
||||
'access_level' => (string) ($lesson?->access_level ?? ''),
|
||||
'category' => (string) ($lesson?->category?->name ?? ''),
|
||||
'order_num' => (int) ($courseLesson->order_num ?? 0),
|
||||
'is_required' => (bool) $courseLesson->is_required,
|
||||
'access_override' => $courseLesson->access_override,
|
||||
'unlock_after_lesson_id' => $courseLesson->unlock_after_lesson_id ? (int) $courseLesson->unlock_after_lesson_id : null,
|
||||
'updateUrl' => route('admin.academy.courses.lessons.update', ['academyCourse' => $courseLesson->course_id, 'academyCourseLesson' => $courseLesson]),
|
||||
'destroyUrl' => route('admin.academy.courses.lessons.destroy', ['academyCourse' => $courseLesson->course_id, 'academyCourseLesson' => $courseLesson]),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -111,7 +111,7 @@ class CollectionInsightsController extends Controller
|
||||
'title' => 'Collections Dashboard — Skinbase',
|
||||
'description' => 'Overview of collection lifecycle, quality, activity, and upcoming collection campaigns.',
|
||||
'canonical' => route('settings.collections.dashboard'),
|
||||
'robots' => 'noindex,follow',
|
||||
'robots' => 'index,follow',
|
||||
],
|
||||
])->rootView('collections');
|
||||
}
|
||||
@@ -130,7 +130,7 @@ class CollectionInsightsController extends Controller
|
||||
'title' => sprintf('%s Analytics — Skinbase', $collection->title),
|
||||
'description' => sprintf('Analytics and performance history for the %s collection.', $collection->title),
|
||||
'canonical' => route('settings.collections.analytics', ['collection' => $collection->id]),
|
||||
'robots' => 'noindex,follow',
|
||||
'robots' => 'index,follow',
|
||||
],
|
||||
])->rootView('collections');
|
||||
}
|
||||
@@ -153,7 +153,7 @@ class CollectionInsightsController extends Controller
|
||||
'title' => sprintf('%s History — Skinbase', $collection->title),
|
||||
'description' => sprintf('Audit history and lifecycle changes for the %s collection.', $collection->title),
|
||||
'canonical' => route('settings.collections.history', ['collection' => $collection->id]),
|
||||
'robots' => 'noindex,follow',
|
||||
'robots' => 'index,follow',
|
||||
],
|
||||
])->rootView('collections');
|
||||
}
|
||||
|
||||
@@ -95,7 +95,7 @@ class CollectionProgrammingController extends Controller
|
||||
'title' => 'Collection Programming — Skinbase',
|
||||
'description' => 'Staff programming tools for assignments, previews, eligibility diagnostics, and recommendation refreshes.',
|
||||
'canonical' => route('staff.collections.programming'),
|
||||
'robots' => 'noindex,follow',
|
||||
'robots' => 'index,follow',
|
||||
],
|
||||
])->rootView('collections');
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@ class CollectionSurfaceController extends Controller
|
||||
'title' => 'Collection Surfaces - Skinbase',
|
||||
'description' => 'Staff tools for homepage, discovery, and campaign collection surfaces.',
|
||||
'canonical' => route('settings.collections.surfaces.index'),
|
||||
'robots' => 'noindex,follow',
|
||||
'robots' => 'index,follow',
|
||||
],
|
||||
])->rootView('collections');
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ class FeaturedArtworkAdminController extends Controller
|
||||
'title' => 'Featured Artworks — Skinbase',
|
||||
'description' => 'Editorial controls for homepage featured artworks and the current hero winner.',
|
||||
'canonical' => route($routePrefix . 'main'),
|
||||
'robots' => 'noindex,follow',
|
||||
'robots' => 'index,follow',
|
||||
],
|
||||
],
|
||||
))->rootView($isAdminSurface ? 'admin' : 'collections');
|
||||
|
||||
@@ -194,7 +194,7 @@ class StoryController extends Controller
|
||||
'storyTypes' => $this->storyCategories(),
|
||||
'page_title' => 'Create Story - Skinbase',
|
||||
'page_meta_description' => 'Write and publish a creator story on Skinbase.',
|
||||
'page_robots' => 'noindex,nofollow',
|
||||
'page_robots' => 'index,nofollow',
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -149,7 +149,7 @@ final class ArtworkPageController extends Controller
|
||||
'md' => $thumbMd,
|
||||
'lg' => $thumbLg,
|
||||
'xl' => $thumbXl,
|
||||
], $canonical)->toArray();
|
||||
], $canonical, $this->artworkBreadcrumbs($artwork, $canonical))->toArray();
|
||||
|
||||
$categoryIds = $artwork->categories->pluck('id')->filter()->values();
|
||||
$tagIds = $artwork->tags->pluck('id')->filter()->values();
|
||||
@@ -364,6 +364,70 @@ final class ArtworkPageController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{name: string, url: string}>
|
||||
*/
|
||||
private function artworkBreadcrumbs(Artwork $artwork, string $canonical): array
|
||||
{
|
||||
$primaryCategory = $artwork->categories
|
||||
->sortBy(fn ($category) => [
|
||||
(int) ($category->sort_order ?? 0),
|
||||
(string) ($category->name ?? ''),
|
||||
])
|
||||
->first();
|
||||
|
||||
if ($primaryCategory === null) {
|
||||
return [
|
||||
['name' => 'Explore', 'url' => url('/explore')],
|
||||
['name' => html_entity_decode((string) $artwork->title, ENT_QUOTES | ENT_HTML5, 'UTF-8'), 'url' => $canonical],
|
||||
];
|
||||
}
|
||||
|
||||
$contentType = $primaryCategory->contentType;
|
||||
$chain = collect();
|
||||
$current = $primaryCategory;
|
||||
|
||||
while ($current !== null) {
|
||||
$chain->prepend($current);
|
||||
$current = $current->relationLoaded('parent') ? $current->parent : null;
|
||||
}
|
||||
|
||||
$breadcrumbs = [];
|
||||
$contentTypeSlug = trim((string) ($contentType?->slug ?? ''));
|
||||
$contentTypeName = trim((string) ($contentType?->name ?? ''));
|
||||
|
||||
if ($contentTypeSlug !== '' && $contentTypeName !== '') {
|
||||
$breadcrumbs[] = [
|
||||
'name' => $contentTypeName,
|
||||
'url' => url('/' . $contentTypeSlug),
|
||||
];
|
||||
}
|
||||
|
||||
$pathSegments = [];
|
||||
|
||||
foreach ($chain as $category) {
|
||||
$slug = trim((string) ($category->slug ?? ''));
|
||||
$name = trim((string) ($category->name ?? ''));
|
||||
|
||||
if ($slug === '' || $name === '' || $contentTypeSlug === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$pathSegments[] = $slug;
|
||||
$breadcrumbs[] = [
|
||||
'name' => $name,
|
||||
'url' => url('/' . $contentTypeSlug . '/' . implode('/', $pathSegments)),
|
||||
];
|
||||
}
|
||||
|
||||
$breadcrumbs[] = [
|
||||
'name' => html_entity_decode((string) $artwork->title, ENT_QUOTES | ENT_HTML5, 'UTF-8'),
|
||||
'url' => $canonical,
|
||||
];
|
||||
|
||||
return $breadcrumbs;
|
||||
}
|
||||
|
||||
/** Silently catch suggestion query failures so error page never crashes. */
|
||||
private function safeSuggestions(callable $fn): mixed
|
||||
{
|
||||
|
||||
@@ -181,7 +181,7 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
|
||||
'hero_title' => $contentType->name,
|
||||
'hero_description' => $contentType->description ?? ($contentType->name . ' artworks on Skinbase.'),
|
||||
'breadcrumbs' => collect([
|
||||
(object) ['name' => 'Explore', 'url' => '/browse'],
|
||||
(object) ['name' => 'Explore', 'url' => route('explore.index')],
|
||||
(object) ['name' => $contentType->name, 'url' => '/' . $contentSlug],
|
||||
]),
|
||||
'page_title' => $contentType->name . ' – Skinbase',
|
||||
@@ -237,7 +237,7 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
|
||||
$breadcrumbs = collect(array_merge([
|
||||
(object) [
|
||||
'name' => 'Explore',
|
||||
'url' => '/browse',
|
||||
'url' => route('explore.index'),
|
||||
],
|
||||
(object) [
|
||||
'name' => $contentType->name,
|
||||
@@ -335,6 +335,8 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
|
||||
return (object) $this->maturity->decoratePayload([
|
||||
'id' => $artwork->id,
|
||||
'name' => $artwork->title,
|
||||
'slug' => $artwork->slug,
|
||||
'url' => route('art.show', ['id' => $artwork->id, 'slug' => $artwork->slug]),
|
||||
'content_type_name' => $primaryCategory?->contentType?->name ?? '',
|
||||
'content_type_slug' => $primaryCategory?->contentType?->slug ?? '',
|
||||
'category_name' => $primaryCategory->name ?? '',
|
||||
|
||||
@@ -102,7 +102,7 @@ final class SimilarArtworksPageController extends Controller
|
||||
'page_title' => 'Similar to "' . $sourceTitle . '" — Skinbase',
|
||||
'page_meta_description' => 'Discover artworks similar to "' . $sourceTitle . '" on Skinbase.',
|
||||
'page_canonical' => $baseUrl,
|
||||
'page_robots' => 'noindex,follow',
|
||||
'page_robots' => 'index,follow',
|
||||
'breadcrumbs' => collect([
|
||||
(object) ['name' => 'Explore', 'url' => '/explore'],
|
||||
(object) ['name' => $sourceTitle, 'url' => $sourceUrl],
|
||||
|
||||
53
app/Http/Requests/Academy/UpsertAcademyCourseRequest.php
Normal file
53
app/Http/Requests/Academy/UpsertAcademyCourseRequest.php
Normal file
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\Academy;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class UpsertAcademyCourseRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return (bool) $this->user()?->hasStaffAccess();
|
||||
}
|
||||
|
||||
protected function prepareForValidation(): void
|
||||
{
|
||||
$this->merge([
|
||||
'is_featured' => $this->boolean('is_featured'),
|
||||
'order_num' => $this->filled('order_num') ? (int) $this->input('order_num') : 0,
|
||||
'estimated_minutes' => $this->filled('estimated_minutes') ? (int) $this->input('estimated_minutes') : null,
|
||||
]);
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
$courseId = $this->route('academyCourse')?->id;
|
||||
|
||||
return [
|
||||
'title' => ['required', 'string', 'max:180'],
|
||||
'slug' => ['required', 'string', 'max:180', Rule::unique('academy_courses', 'slug')->ignore($courseId)],
|
||||
'subtitle' => ['nullable', 'string', 'max:255'],
|
||||
'excerpt' => ['nullable', 'string'],
|
||||
'description' => ['nullable', 'string'],
|
||||
'cover_image' => ['nullable', 'string', 'max:2048'],
|
||||
'teaser_image' => ['nullable', 'string', 'max:2048'],
|
||||
'access_level' => ['required', 'string', Rule::in(['free', 'premium', 'mixed'])],
|
||||
'difficulty' => ['required', 'string', Rule::in(['beginner', 'intermediate', 'advanced'])],
|
||||
'status' => ['required', 'string', Rule::in(['draft', 'review', 'published', 'archived'])],
|
||||
'is_featured' => ['required', 'boolean'],
|
||||
'order_num' => ['required', 'integer', 'min:0'],
|
||||
'estimated_minutes' => ['nullable', 'integer', 'min:1', 'max:10000'],
|
||||
'published_at' => ['nullable', 'date'],
|
||||
'seo_title' => ['nullable', 'string', 'max:180'],
|
||||
'seo_description' => ['nullable', 'string', 'max:255'],
|
||||
'meta_keywords' => ['nullable', 'string'],
|
||||
'og_title' => ['nullable', 'string', 'max:180'],
|
||||
'og_description' => ['nullable', 'string', 'max:255'],
|
||||
'og_image' => ['nullable', 'string', 'max:2048'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -69,6 +69,18 @@ class UpsertAcademyLessonRequest extends FormRequest
|
||||
->all();
|
||||
|
||||
$this->merge([
|
||||
'lesson_number' => $this->filled('lesson_number') ? (int) $this->input('lesson_number') : null,
|
||||
'course_order' => $this->filled('course_order') ? (int) $this->input('course_order') : null,
|
||||
'content_source' => in_array((string) $this->input('content_source'), ['html', 'markdown'], true)
|
||||
? (string) $this->input('content_source')
|
||||
: ($this->filled('content_markdown') ? 'markdown' : 'html'),
|
||||
'course_ids' => collect($this->input('course_ids', []))
|
||||
->filter(static fn ($courseId): bool => filled($courseId))
|
||||
->map(static fn ($courseId): int => (int) $courseId)
|
||||
->unique()
|
||||
->values()
|
||||
->all(),
|
||||
'tags' => array_values(array_filter((array) $this->input('tags', []))),
|
||||
'reading_minutes' => $this->filled('reading_minutes') ? (int) $this->input('reading_minutes') : 5,
|
||||
'featured' => $this->boolean('featured'),
|
||||
'active' => $this->boolean('active', true),
|
||||
@@ -84,12 +96,22 @@ class UpsertAcademyLessonRequest extends FormRequest
|
||||
'category_id' => ['nullable', 'integer', 'exists:academy_categories,id'],
|
||||
'title' => ['required', 'string', 'max:180'],
|
||||
'slug' => ['required', 'string', 'max:180', Rule::unique('academy_lessons', 'slug')->ignore($lessonId)],
|
||||
'lesson_number' => ['nullable', 'integer', 'min:1'],
|
||||
'course_order' => ['nullable', 'integer', 'min:1'],
|
||||
'course_ids' => ['nullable', 'array'],
|
||||
'course_ids.*' => ['integer', 'exists:academy_courses,id'],
|
||||
'series_name' => ['nullable', 'string', 'max:120'],
|
||||
'excerpt' => ['nullable', 'string'],
|
||||
'content' => ['nullable', 'string'],
|
||||
'content_markdown' => ['nullable', 'string'],
|
||||
'content_source' => ['required', 'string', Rule::in(['html', 'markdown'])],
|
||||
'difficulty' => ['required', 'string', Rule::in((array) config('academy.difficulty_levels', []))],
|
||||
'access_level' => ['required', 'string', Rule::in(['free', 'creator', 'pro'])],
|
||||
'lesson_type' => ['required', 'string', 'max:80'],
|
||||
'cover_image' => ['nullable', 'string', 'max:2048'],
|
||||
'article_cover_image' => ['nullable', 'string', 'max:2048'],
|
||||
'tags' => ['nullable', 'array'],
|
||||
'tags.*' => ['string', 'max:60'],
|
||||
'video_url' => ['nullable', 'string', 'max:2048'],
|
||||
'reading_minutes' => ['required', 'integer', 'min:1', 'max:999'],
|
||||
'featured' => ['required', 'boolean'],
|
||||
|
||||
@@ -20,8 +20,31 @@ class UpsertAcademyPromptTemplateRequest extends FormRequest
|
||||
'featured' => $this->boolean('featured'),
|
||||
'prompt_of_week' => $this->boolean('prompt_of_week'),
|
||||
'active' => $this->boolean('active', true),
|
||||
'new_category_name' => trim((string) $this->input('new_category_name', '')),
|
||||
'tags' => array_values(array_filter((array) $this->input('tags', []))),
|
||||
'tool_notes' => (array) $this->input('tool_notes', []),
|
||||
'tool_notes' => collect($this->input('tool_notes', []))
|
||||
->filter(static fn ($note): bool => is_array($note) || is_string($note))
|
||||
->map(function ($note): array|string {
|
||||
if (is_string($note)) {
|
||||
return $note;
|
||||
}
|
||||
|
||||
return [
|
||||
'provider' => $note['provider'] ?? null,
|
||||
'model_name' => $note['model_name'] ?? null,
|
||||
'notes' => $note['notes'] ?? null,
|
||||
'strengths' => $note['strengths'] ?? null,
|
||||
'weaknesses' => $note['weaknesses'] ?? null,
|
||||
'best_for' => $note['best_for'] ?? null,
|
||||
'image_path' => $note['image_path'] ?? null,
|
||||
'thumb_path' => $note['thumb_path'] ?? null,
|
||||
'settings' => $note['settings'] ?? null,
|
||||
'score' => filled($note['score'] ?? null) ? (int) $note['score'] : null,
|
||||
'active' => filter_var($note['active'] ?? true, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE) ?? true,
|
||||
];
|
||||
})
|
||||
->values()
|
||||
->all(),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -31,6 +54,7 @@ class UpsertAcademyPromptTemplateRequest extends FormRequest
|
||||
|
||||
return [
|
||||
'category_id' => ['nullable', 'integer', 'exists:academy_categories,id'],
|
||||
'new_category_name' => ['nullable', 'string', 'max:120'],
|
||||
'title' => ['required', 'string', 'max:180'],
|
||||
'slug' => ['required', 'string', 'max:180', Rule::unique('academy_prompt_templates', 'slug')->ignore($promptId)],
|
||||
'excerpt' => ['nullable', 'string'],
|
||||
@@ -44,6 +68,17 @@ class UpsertAcademyPromptTemplateRequest extends FormRequest
|
||||
'tags' => ['nullable', 'array'],
|
||||
'tags.*' => ['string', 'max:60'],
|
||||
'tool_notes' => ['nullable', 'array'],
|
||||
'tool_notes.*.provider' => ['nullable', 'string', 'max:100'],
|
||||
'tool_notes.*.model_name' => ['nullable', 'string', 'max:150'],
|
||||
'tool_notes.*.notes' => ['nullable', 'string'],
|
||||
'tool_notes.*.strengths' => ['nullable', 'string'],
|
||||
'tool_notes.*.weaknesses' => ['nullable', 'string'],
|
||||
'tool_notes.*.best_for' => ['nullable', 'string'],
|
||||
'tool_notes.*.image_path' => ['nullable', 'string', 'max:500'],
|
||||
'tool_notes.*.thumb_path' => ['nullable', 'string', 'max:500'],
|
||||
'tool_notes.*.settings' => ['nullable', 'string'],
|
||||
'tool_notes.*.score' => ['nullable', 'integer', 'min:1', 'max:10'],
|
||||
'tool_notes.*.active' => ['nullable', 'boolean'],
|
||||
'preview_image' => ['nullable', 'string', 'max:2048'],
|
||||
'preview_image_file' => ['nullable', 'file', 'image', 'mimes:jpg,jpeg,png,webp', 'max:5120'],
|
||||
'featured' => ['required', 'boolean'],
|
||||
|
||||
170
app/Models/AcademyCourse.php
Normal file
170
app/Models/AcademyCourse.php
Normal file
@@ -0,0 +1,170 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class AcademyCourse extends Model
|
||||
{
|
||||
use SoftDeletes;
|
||||
|
||||
public const STATUS_DRAFT = 'draft';
|
||||
public const STATUS_REVIEW = 'review';
|
||||
public const STATUS_PUBLISHED = 'published';
|
||||
public const STATUS_ARCHIVED = 'archived';
|
||||
|
||||
protected $fillable = [
|
||||
'title',
|
||||
'slug',
|
||||
'subtitle',
|
||||
'excerpt',
|
||||
'description',
|
||||
'cover_image',
|
||||
'teaser_image',
|
||||
'access_level',
|
||||
'difficulty',
|
||||
'status',
|
||||
'is_featured',
|
||||
'order_num',
|
||||
'estimated_minutes',
|
||||
'lessons_count_cache',
|
||||
'published_at',
|
||||
'seo_title',
|
||||
'seo_description',
|
||||
'meta_keywords',
|
||||
'og_title',
|
||||
'og_description',
|
||||
'og_image',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_featured' => 'boolean',
|
||||
'order_num' => 'integer',
|
||||
'estimated_minutes' => 'integer',
|
||||
'lessons_count_cache' => 'integer',
|
||||
'published_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function scopePublished(Builder $query): Builder
|
||||
{
|
||||
return $query
|
||||
->where('status', self::STATUS_PUBLISHED)
|
||||
->where(function (Builder $builder): void {
|
||||
$builder->whereNull('published_at')->orWhere('published_at', '<=', now());
|
||||
});
|
||||
}
|
||||
|
||||
public function scopeFeatured(Builder $query): Builder
|
||||
{
|
||||
return $query->where('is_featured', true);
|
||||
}
|
||||
|
||||
public function scopeOrdered(Builder $query): Builder
|
||||
{
|
||||
return $query
|
||||
->orderByDesc('is_featured')
|
||||
->orderBy('order_num')
|
||||
->orderByDesc('published_at')
|
||||
->orderBy('id');
|
||||
}
|
||||
|
||||
public function scopeFree(Builder $query): Builder
|
||||
{
|
||||
return $query->where('access_level', 'free');
|
||||
}
|
||||
|
||||
public function scopePremium(Builder $query): Builder
|
||||
{
|
||||
return $query->where('access_level', 'premium');
|
||||
}
|
||||
|
||||
public function scopeMixed(Builder $query): Builder
|
||||
{
|
||||
return $query->where('access_level', 'mixed');
|
||||
}
|
||||
|
||||
public function sections(): HasMany
|
||||
{
|
||||
return $this->hasMany(AcademyCourseSection::class, 'course_id')
|
||||
->orderBy('order_num')
|
||||
->orderBy('id');
|
||||
}
|
||||
|
||||
public function courseLessons(): HasMany
|
||||
{
|
||||
return $this->hasMany(AcademyCourseLesson::class, 'course_id')
|
||||
->orderBy('order_num')
|
||||
->orderBy('id');
|
||||
}
|
||||
|
||||
public function lessons(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(AcademyLesson::class, 'academy_course_lessons', 'course_id', 'lesson_id')
|
||||
->using(AcademyCourseLesson::class)
|
||||
->withPivot(['section_id', 'order_num', 'is_required', 'access_override', 'unlock_after_lesson_id'])
|
||||
->withTimestamps()
|
||||
->orderBy('academy_course_lessons.order_num')
|
||||
->orderBy('academy_course_lessons.id');
|
||||
}
|
||||
|
||||
public function enrollments(): HasMany
|
||||
{
|
||||
return $this->hasMany(AcademyCourseEnrollment::class, 'course_id');
|
||||
}
|
||||
|
||||
public function isPublished(): bool
|
||||
{
|
||||
return (string) $this->status === self::STATUS_PUBLISHED
|
||||
&& ($this->published_at === null || $this->published_at->lte(now()));
|
||||
}
|
||||
|
||||
public function isFree(): bool
|
||||
{
|
||||
return (string) $this->access_level === 'free';
|
||||
}
|
||||
|
||||
public function isPremium(): bool
|
||||
{
|
||||
return (string) $this->access_level === 'premium';
|
||||
}
|
||||
|
||||
public function isMixed(): bool
|
||||
{
|
||||
return (string) $this->access_level === 'mixed';
|
||||
}
|
||||
|
||||
public function getPublicUrl(): string
|
||||
{
|
||||
return route('academy.courses.show', ['course' => $this->slug]);
|
||||
}
|
||||
|
||||
public function getContinueUrl(?User $user): string
|
||||
{
|
||||
$lastLesson = $user?->academyCourseEnrollments()
|
||||
->where('course_id', $this->id)
|
||||
->with('lastLesson')
|
||||
->first()?->lastLesson;
|
||||
|
||||
if ($lastLesson instanceof AcademyLesson) {
|
||||
return route('academy.courses.lessons.show', ['course' => $this->slug, 'lesson' => $lastLesson->slug]);
|
||||
}
|
||||
|
||||
$firstLesson = $this->courseLessons()
|
||||
->with('lesson')
|
||||
->get()
|
||||
->map(fn (AcademyCourseLesson $courseLesson): ?AcademyLesson => $courseLesson->lesson)
|
||||
->first(fn (?AcademyLesson $lesson): bool => $lesson instanceof AcademyLesson);
|
||||
|
||||
if ($firstLesson instanceof AcademyLesson) {
|
||||
return route('academy.courses.lessons.show', ['course' => $this->slug, 'lesson' => $firstLesson->slug]);
|
||||
}
|
||||
|
||||
return $this->getPublicUrl();
|
||||
}
|
||||
}
|
||||
44
app/Models/AcademyCourseEnrollment.php
Normal file
44
app/Models/AcademyCourseEnrollment.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class AcademyCourseEnrollment extends Model
|
||||
{
|
||||
public const STATUS_ACTIVE = 'active';
|
||||
public const STATUS_COMPLETED = 'completed';
|
||||
public const STATUS_PAUSED = 'paused';
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'course_id',
|
||||
'status',
|
||||
'last_lesson_id',
|
||||
'started_at',
|
||||
'completed_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'started_at' => 'datetime',
|
||||
'completed_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'user_id');
|
||||
}
|
||||
|
||||
public function course(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(AcademyCourse::class, 'course_id');
|
||||
}
|
||||
|
||||
public function lastLesson(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(AcademyLesson::class, 'last_lesson_id');
|
||||
}
|
||||
}
|
||||
50
app/Models/AcademyCourseLesson.php
Normal file
50
app/Models/AcademyCourseLesson.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\Pivot;
|
||||
|
||||
class AcademyCourseLesson extends Pivot
|
||||
{
|
||||
protected $table = 'academy_course_lessons';
|
||||
|
||||
public $incrementing = true;
|
||||
|
||||
protected $fillable = [
|
||||
'course_id',
|
||||
'section_id',
|
||||
'lesson_id',
|
||||
'order_num',
|
||||
'is_required',
|
||||
'access_override',
|
||||
'unlock_after_lesson_id',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'order_num' => 'integer',
|
||||
'is_required' => 'boolean',
|
||||
];
|
||||
|
||||
public function course(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(AcademyCourse::class, 'course_id');
|
||||
}
|
||||
|
||||
public function section(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(AcademyCourseSection::class, 'section_id');
|
||||
}
|
||||
|
||||
public function lesson(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(AcademyLesson::class, 'lesson_id');
|
||||
}
|
||||
|
||||
public function unlockAfterLesson(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(AcademyLesson::class, 'unlock_after_lesson_id');
|
||||
}
|
||||
}
|
||||
49
app/Models/AcademyCourseSection.php
Normal file
49
app/Models/AcademyCourseSection.php
Normal file
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class AcademyCourseSection extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'course_id',
|
||||
'title',
|
||||
'slug',
|
||||
'description',
|
||||
'order_num',
|
||||
'is_visible',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'order_num' => 'integer',
|
||||
'is_visible' => 'boolean',
|
||||
];
|
||||
|
||||
public function course(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(AcademyCourse::class, 'course_id');
|
||||
}
|
||||
|
||||
public function courseLessons(): HasMany
|
||||
{
|
||||
return $this->hasMany(AcademyCourseLesson::class, 'section_id')
|
||||
->orderBy('order_num')
|
||||
->orderBy('id');
|
||||
}
|
||||
|
||||
public function lessons(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(AcademyLesson::class, 'academy_course_lessons', 'section_id', 'lesson_id')
|
||||
->using(AcademyCourseLesson::class)
|
||||
->withPivot(['course_id', 'order_num', 'is_required', 'access_override', 'unlock_after_lesson_id'])
|
||||
->withTimestamps()
|
||||
->orderBy('academy_course_lessons.order_num')
|
||||
->orderBy('academy_course_lessons.id');
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
@@ -33,12 +34,18 @@ class AcademyLesson extends Model
|
||||
'category_id',
|
||||
'title',
|
||||
'slug',
|
||||
'lesson_number',
|
||||
'course_order',
|
||||
'series_name',
|
||||
'excerpt',
|
||||
'content',
|
||||
'content_markdown',
|
||||
'difficulty',
|
||||
'access_level',
|
||||
'lesson_type',
|
||||
'cover_image',
|
||||
'article_cover_image',
|
||||
'tags',
|
||||
'video_url',
|
||||
'reading_minutes',
|
||||
'featured',
|
||||
@@ -49,12 +56,20 @@ class AcademyLesson extends Model
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'lesson_number' => 'integer',
|
||||
'course_order' => 'integer',
|
||||
'tags' => 'array',
|
||||
'reading_minutes' => 'integer',
|
||||
'featured' => 'boolean',
|
||||
'active' => 'boolean',
|
||||
'published_at' => 'datetime',
|
||||
];
|
||||
|
||||
protected $appends = [
|
||||
'formatted_lesson_number',
|
||||
'lesson_label',
|
||||
];
|
||||
|
||||
public function scopeActive(Builder $query): Builder
|
||||
{
|
||||
return $query->where('active', true);
|
||||
@@ -65,6 +80,17 @@ class AcademyLesson extends Model
|
||||
return $query->whereNotNull('published_at')->where('published_at', '<=', now());
|
||||
}
|
||||
|
||||
public function scopeOrderedForCourse(Builder $query): Builder
|
||||
{
|
||||
return $query
|
||||
->orderByRaw('case when course_order is null then 1 else 0 end')
|
||||
->orderBy('course_order')
|
||||
->orderByRaw('case when lesson_number is null then 1 else 0 end')
|
||||
->orderBy('lesson_number')
|
||||
->orderByDesc('published_at')
|
||||
->orderBy('id');
|
||||
}
|
||||
|
||||
public function category(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(AcademyCategory::class, 'category_id');
|
||||
@@ -75,6 +101,23 @@ class AcademyLesson extends Model
|
||||
return $this->hasMany(AcademyLessonProgress::class, 'lesson_id');
|
||||
}
|
||||
|
||||
public function courseLessons(): HasMany
|
||||
{
|
||||
return $this->hasMany(AcademyCourseLesson::class, 'lesson_id')
|
||||
->orderBy('order_num')
|
||||
->orderBy('id');
|
||||
}
|
||||
|
||||
public function courses(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(AcademyCourse::class, 'academy_course_lessons', 'lesson_id', 'course_id')
|
||||
->using(AcademyCourseLesson::class)
|
||||
->withPivot(['section_id', 'order_num', 'is_required', 'access_override', 'unlock_after_lesson_id'])
|
||||
->withTimestamps()
|
||||
->orderBy('academy_course_lessons.order_num')
|
||||
->orderBy('academy_course_lessons.id');
|
||||
}
|
||||
|
||||
public function blocks(): HasMany
|
||||
{
|
||||
return $this->hasMany(AcademyLessonBlock::class, 'lesson_id')
|
||||
@@ -86,4 +129,30 @@ class AcademyLesson extends Model
|
||||
{
|
||||
return $this->blocks()->where('active', true);
|
||||
}
|
||||
|
||||
public function getFormattedLessonNumberAttribute(): ?string
|
||||
{
|
||||
if (! is_int($this->lesson_number) || $this->lesson_number < 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return sprintf('Lesson %02d', $this->lesson_number);
|
||||
}
|
||||
|
||||
public function getLessonLabelAttribute(): ?string
|
||||
{
|
||||
$formattedLessonNumber = $this->formatted_lesson_number;
|
||||
|
||||
if ($formattedLessonNumber === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$seriesName = trim((string) ($this->series_name ?? ''));
|
||||
|
||||
if ($seriesName === '') {
|
||||
return $formattedLessonNumber;
|
||||
}
|
||||
|
||||
return sprintf('%s · %s', $seriesName, $formattedLessonNumber);
|
||||
}
|
||||
}
|
||||
|
||||
32
app/Models/AcademyLessonRevision.php
Normal file
32
app/Models/AcademyLessonRevision.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class AcademyLessonRevision extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'lesson_id',
|
||||
'user_id',
|
||||
'change_note',
|
||||
'snapshot_json',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'snapshot_json' => 'array',
|
||||
];
|
||||
|
||||
public function lesson(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(AcademyLesson::class, 'lesson_id');
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'user_id');
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ use App\Models\SocialAccount;
|
||||
use App\Models\Conversation;
|
||||
use App\Models\ConversationParticipant;
|
||||
use App\Models\AcademyBadge;
|
||||
use App\Models\AcademyCourseEnrollment;
|
||||
use App\Models\AcademyChallengeSubmission;
|
||||
use App\Models\AcademyLessonProgress;
|
||||
use App\Models\AcademySavedPrompt;
|
||||
@@ -207,6 +208,11 @@ class User extends Authenticatable
|
||||
return $this->hasMany(AcademyLessonProgress::class, 'user_id');
|
||||
}
|
||||
|
||||
public function academyCourseEnrollments(): HasMany
|
||||
{
|
||||
return $this->hasMany(AcademyCourseEnrollment::class, 'user_id');
|
||||
}
|
||||
|
||||
public function academySavedPrompts(): HasMany
|
||||
{
|
||||
return $this->hasMany(AcademySavedPrompt::class, 'user_id');
|
||||
|
||||
@@ -4,6 +4,8 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Academy;
|
||||
|
||||
use App\Models\AcademyCourse;
|
||||
use App\Models\AcademyCourseLesson;
|
||||
use App\Models\AcademyAiComparisonResult;
|
||||
use App\Models\AcademyChallenge;
|
||||
use App\Models\AcademyLesson;
|
||||
@@ -53,15 +55,31 @@ final class AcademyAccessService
|
||||
return $this->canAccessContent($user, (string) $challenge->access_level);
|
||||
}
|
||||
|
||||
public function lessonPayload(AcademyLesson $lesson, ?User $viewer, bool $includeFull = false): array
|
||||
public function canAccessCourseLesson(?User $user, AcademyCourseLesson $courseLesson): bool
|
||||
{
|
||||
$authorized = $this->canAccessLesson($viewer, $lesson);
|
||||
$fullContent = (string) ($lesson->content ?? '');
|
||||
$accessLevel = trim((string) ($courseLesson->access_override ?: $courseLesson->lesson?->access_level ?: 'free'));
|
||||
|
||||
if ($accessLevel === 'premium') {
|
||||
return $user?->isAdmin() ?? false;
|
||||
}
|
||||
|
||||
return $this->canAccessContent($user, $accessLevel === 'mixed' ? 'free' : $accessLevel);
|
||||
}
|
||||
|
||||
public function lessonPayload(AcademyLesson $lesson, ?User $viewer, bool $includeFull = false, ?bool $authorizedOverride = null): array
|
||||
{
|
||||
$authorized = $authorizedOverride ?? $this->canAccessLesson($viewer, $lesson);
|
||||
$fullContent = $this->injectHeadingIds((string) ($lesson->content ?? ''));
|
||||
|
||||
return [
|
||||
'id' => (int) $lesson->id,
|
||||
'title' => (string) $lesson->title,
|
||||
'slug' => (string) $lesson->slug,
|
||||
'lesson_number' => $lesson->lesson_number,
|
||||
'formatted_lesson_number' => $lesson->formatted_lesson_number,
|
||||
'course_order' => $lesson->course_order,
|
||||
'series_name' => (string) ($lesson->series_name ?? ''),
|
||||
'lesson_label' => $lesson->lesson_label,
|
||||
'excerpt' => (string) ($lesson->excerpt ?? ''),
|
||||
'content' => ($authorized && $includeFull) ? $fullContent : null,
|
||||
'content_preview' => $authorized ? null : $this->previewText($fullContent, 360),
|
||||
@@ -70,6 +88,9 @@ final class AcademyAccessService
|
||||
'lesson_type' => (string) $lesson->lesson_type,
|
||||
'cover_image' => $lesson->cover_image,
|
||||
'cover_image_url' => $this->resolveLessonCoverImageUrl((string) ($lesson->cover_image ?? '')),
|
||||
'article_cover_image' => $lesson->article_cover_image,
|
||||
'article_cover_image_url' => $this->resolveLessonCoverImageUrl((string) ($lesson->article_cover_image ?? '')),
|
||||
'tags' => array_values((array) ($lesson->tags ?? [])),
|
||||
'video_url' => $authorized ? $lesson->video_url : null,
|
||||
'reading_minutes' => (int) $lesson->reading_minutes,
|
||||
'featured' => (bool) $lesson->featured,
|
||||
@@ -88,6 +109,66 @@ final class AcademyAccessService
|
||||
];
|
||||
}
|
||||
|
||||
public function coursePayload(AcademyCourse $course, ?User $viewer, array $options = []): array
|
||||
{
|
||||
$progress = is_array($options['progress'] ?? null) ? $options['progress'] : null;
|
||||
$lessonCount = (int) ($course->lessons_count_cache ?: $course->courseLessons()->count());
|
||||
|
||||
return [
|
||||
'id' => (int) $course->id,
|
||||
'title' => (string) $course->title,
|
||||
'slug' => (string) $course->slug,
|
||||
'subtitle' => (string) ($course->subtitle ?? ''),
|
||||
'excerpt' => (string) ($course->excerpt ?? ''),
|
||||
'description' => (string) ($course->description ?? ''),
|
||||
'cover_image' => (string) ($course->cover_image ?? ''),
|
||||
'cover_image_url' => $this->resolveLessonCoverImageUrl((string) ($course->cover_image ?? '')),
|
||||
'teaser_image' => (string) ($course->teaser_image ?? ''),
|
||||
'teaser_image_url' => $this->resolveLessonCoverImageUrl((string) ($course->teaser_image ?? '')),
|
||||
'access_level' => (string) $course->access_level,
|
||||
'difficulty' => (string) $course->difficulty,
|
||||
'status' => (string) $course->status,
|
||||
'is_featured' => (bool) $course->is_featured,
|
||||
'order_num' => (int) ($course->order_num ?? 0),
|
||||
'estimated_minutes' => (int) ($course->estimated_minutes ?? 0),
|
||||
'lessons_count' => $lessonCount,
|
||||
'published_at' => $course->published_at?->toISOString(),
|
||||
'public_url' => route('academy.courses.show', ['course' => $course->slug]),
|
||||
'progress' => $progress ? [
|
||||
'completedRequired' => (int) ($progress['completed_required'] ?? 0),
|
||||
'totalRequired' => (int) ($progress['total_required'] ?? 0),
|
||||
'percent' => (int) ($progress['progress_percent'] ?? 0),
|
||||
'completed' => (bool) ($progress['completed'] ?? false),
|
||||
] : null,
|
||||
'continue_url' => $viewer ? $course->getContinueUrl($viewer) : route('academy.courses.show', ['course' => $course->slug]),
|
||||
];
|
||||
}
|
||||
|
||||
public function courseLessonPayload(AcademyCourseLesson $courseLesson, ?User $viewer, bool $includeFull = false, array $options = []): array
|
||||
{
|
||||
$lesson = $courseLesson->lesson;
|
||||
|
||||
if (! $lesson instanceof AcademyLesson) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$authorized = $this->canAccessCourseLesson($viewer, $courseLesson);
|
||||
$payload = $this->lessonPayload($lesson, $viewer, $includeFull, $authorized);
|
||||
|
||||
$payload['course_lesson_id'] = (int) $courseLesson->id;
|
||||
$payload['course_id'] = (int) $courseLesson->course_id;
|
||||
$payload['section_id'] = $courseLesson->section_id ? (int) $courseLesson->section_id : null;
|
||||
$payload['order_num'] = (int) ($courseLesson->order_num ?? 0);
|
||||
$payload['is_required'] = (bool) $courseLesson->is_required;
|
||||
$payload['access_override'] = $courseLesson->access_override;
|
||||
$payload['course_step_number'] = (int) ($options['course_step_number'] ?? 0);
|
||||
$payload['course_step_label'] = (string) ($options['course_step_label'] ?? '');
|
||||
$payload['completed'] = in_array((int) $courseLesson->lesson_id, array_map('intval', (array) ($options['completed_lesson_ids'] ?? [])), true);
|
||||
$payload['course_url'] = route('academy.courses.lessons.show', ['course' => $courseLesson->course->slug, 'lesson' => $lesson->slug]);
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
public function promptPayload(AcademyPromptTemplate $prompt, ?User $viewer, bool $includeFull = false): array
|
||||
{
|
||||
$authorized = $this->canAccessPrompt($viewer, $prompt);
|
||||
@@ -106,7 +187,7 @@ final class AcademyAccessService
|
||||
'access_level' => (string) $prompt->access_level,
|
||||
'aspect_ratio' => $prompt->aspect_ratio,
|
||||
'tags' => array_values((array) ($prompt->tags ?? [])),
|
||||
'tool_notes' => $authorized ? (array) ($prompt->tool_notes ?? []) : [],
|
||||
'tool_notes' => $authorized ? $this->promptToolNotesPayload((array) ($prompt->tool_notes ?? [])) : [],
|
||||
'preview_image' => $this->resolvePreviewImageUrl((string) ($prompt->preview_image ?? '')),
|
||||
'featured' => (bool) $prompt->featured,
|
||||
'prompt_of_week' => (bool) $prompt->prompt_of_week,
|
||||
@@ -121,6 +202,48 @@ final class AcademyAccessService
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, mixed> $notes
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function promptToolNotesPayload(array $notes): array
|
||||
{
|
||||
return collect($notes)
|
||||
->filter(static fn ($note): bool => is_array($note))
|
||||
->map(function (array $note): array {
|
||||
return [
|
||||
'provider' => trim((string) ($note['provider'] ?? '')),
|
||||
'model_name' => trim((string) ($note['model_name'] ?? '')),
|
||||
'notes' => trim((string) ($note['notes'] ?? '')),
|
||||
'strengths' => trim((string) ($note['strengths'] ?? '')),
|
||||
'weaknesses' => trim((string) ($note['weaknesses'] ?? '')),
|
||||
'best_for' => trim((string) ($note['best_for'] ?? '')),
|
||||
'image_path' => trim((string) ($note['image_path'] ?? '')),
|
||||
'image_url' => $this->resolveLessonMediaUrl((string) ($note['image_path'] ?? '')),
|
||||
'thumb_path' => trim((string) ($note['thumb_path'] ?? '')),
|
||||
'thumb_url' => $this->resolveLessonMediaUrl((string) ($note['thumb_path'] ?? '')),
|
||||
'settings' => trim((string) ($note['settings'] ?? '')),
|
||||
'score' => filled($note['score'] ?? null) ? (int) $note['score'] : null,
|
||||
'active' => filter_var($note['active'] ?? true, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE) ?? true,
|
||||
];
|
||||
})
|
||||
->filter(function (array $note): bool {
|
||||
return collect([
|
||||
$note['provider'],
|
||||
$note['model_name'],
|
||||
$note['notes'],
|
||||
$note['strengths'],
|
||||
$note['weaknesses'],
|
||||
$note['best_for'],
|
||||
$note['image_path'],
|
||||
$note['thumb_path'],
|
||||
$note['settings'],
|
||||
])->contains(fn (string $value): bool => $value !== '') || $note['score'] !== null;
|
||||
})
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
public function packPayload(AcademyPromptPack $pack, ?User $viewer, bool $includePrompts = false): array
|
||||
{
|
||||
$authorized = $this->canAccessPack($viewer, $pack);
|
||||
@@ -190,6 +313,7 @@ final class AcademyAccessService
|
||||
{
|
||||
return match (Str::lower(trim($accessLevel))) {
|
||||
'admin' => 99,
|
||||
'premium' => 40,
|
||||
'pro' => 30,
|
||||
'creator' => 20,
|
||||
default => 10,
|
||||
@@ -322,4 +446,44 @@ final class AcademyAccessService
|
||||
'comparison_results' => $results,
|
||||
];
|
||||
}
|
||||
|
||||
private function injectHeadingIds(string $html): string
|
||||
{
|
||||
if ($html === '') {
|
||||
return $html;
|
||||
}
|
||||
|
||||
$seenIds = [];
|
||||
|
||||
return preg_replace_callback(
|
||||
'/<(h[23])([^>]*)>(.*?)<\/\1>/si',
|
||||
function (array $m) use (&$seenIds): string {
|
||||
[, $tag, $attrs, $inner] = $m;
|
||||
|
||||
if (preg_match('/\bid\s*=/i', $attrs)) {
|
||||
return $m[0];
|
||||
}
|
||||
|
||||
$textContent = strip_tags($inner);
|
||||
$baseId = $this->slugifyHeading($textContent);
|
||||
$count = $seenIds[$baseId] ?? 0;
|
||||
$id = $count > 0 ? $baseId.'-'.($count + 1) : $baseId;
|
||||
$seenIds[$baseId] = $count + 1;
|
||||
|
||||
return "<{$tag} id=\"{$id}\"{$attrs}>{$inner}</{$tag}>";
|
||||
},
|
||||
$html
|
||||
) ?? $html;
|
||||
}
|
||||
|
||||
private function slugifyHeading(string $text): string
|
||||
{
|
||||
$slug = mb_strtolower($text);
|
||||
$slug = preg_replace('/[^a-z0-9_\s\-]/', '', $slug) ?? '';
|
||||
$slug = preg_replace('/\s+/', '-', trim($slug)) ?? '';
|
||||
$slug = preg_replace('/-+/', '-', $slug) ?? '';
|
||||
$slug = trim($slug, '-');
|
||||
|
||||
return $slug !== '' ? $slug : 'section';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Services\Academy;
|
||||
|
||||
use App\Models\AcademyCategory;
|
||||
use App\Models\AcademyCourse;
|
||||
use App\Models\AcademyChallenge;
|
||||
use App\Models\AcademyLesson;
|
||||
use App\Models\AcademyPromptTemplate;
|
||||
@@ -14,6 +15,8 @@ final class AcademyCacheService
|
||||
{
|
||||
private const HOME_KEY = 'academy.home';
|
||||
private const FEATURED_LESSONS_KEY = 'academy.featured_lessons';
|
||||
private const FEATURED_COURSES_KEY = 'academy.featured_courses';
|
||||
private const PUBLISHED_COURSES_KEY = 'academy.published_courses';
|
||||
private const FEATURED_PROMPTS_KEY = 'academy.featured_prompts';
|
||||
private const FEATURED_CHALLENGES_KEY = 'academy.featured_challenges';
|
||||
private const CATEGORIES_KEY = 'academy.categories';
|
||||
@@ -30,12 +33,32 @@ final class AcademyCacheService
|
||||
->active()
|
||||
->published()
|
||||
->where('featured', true)
|
||||
->latest('published_at')
|
||||
->orderedForCourse()
|
||||
->limit(6)
|
||||
->get()
|
||||
->all());
|
||||
}
|
||||
|
||||
public function featuredCourses(): array
|
||||
{
|
||||
return Cache::remember(self::FEATURED_COURSES_KEY, $this->ttl(), static fn (): array => AcademyCourse::query()
|
||||
->published()
|
||||
->featured()
|
||||
->ordered()
|
||||
->limit(6)
|
||||
->get()
|
||||
->all());
|
||||
}
|
||||
|
||||
public function publishedCourses(): array
|
||||
{
|
||||
return Cache::remember(self::PUBLISHED_COURSES_KEY, $this->ttl(), static fn (): array => AcademyCourse::query()
|
||||
->published()
|
||||
->ordered()
|
||||
->get()
|
||||
->all());
|
||||
}
|
||||
|
||||
public function featuredPrompts(): array
|
||||
{
|
||||
return Cache::remember(self::FEATURED_PROMPTS_KEY, $this->ttl(), static fn (): array => AcademyPromptTemplate::query()
|
||||
@@ -79,6 +102,8 @@ final class AcademyCacheService
|
||||
{
|
||||
Cache::forget(self::HOME_KEY);
|
||||
Cache::forget(self::FEATURED_LESSONS_KEY);
|
||||
Cache::forget(self::FEATURED_COURSES_KEY);
|
||||
Cache::forget(self::PUBLISHED_COURSES_KEY);
|
||||
Cache::forget(self::FEATURED_PROMPTS_KEY);
|
||||
Cache::forget(self::FEATURED_CHALLENGES_KEY);
|
||||
|
||||
|
||||
52
app/Services/Academy/AcademyCourseLessonOrderingService.php
Normal file
52
app/Services/Academy/AcademyCourseLessonOrderingService.php
Normal file
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Academy;
|
||||
|
||||
use App\Models\AcademyCourse;
|
||||
use App\Models\AcademyCourseLesson;
|
||||
|
||||
final class AcademyCourseLessonOrderingService
|
||||
{
|
||||
public function syncCourse(AcademyCourse|int $course): void
|
||||
{
|
||||
$courseId = $course instanceof AcademyCourse ? (int) $course->id : (int) $course;
|
||||
|
||||
if ($courseId < 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
AcademyCourseLesson::query()
|
||||
->with('lesson:id,lesson_number,course_order')
|
||||
->where('course_id', $courseId)
|
||||
->orderBy('order_num')
|
||||
->orderBy('id')
|
||||
->get()
|
||||
->values()
|
||||
->each(function (AcademyCourseLesson $courseLesson, int $index): void {
|
||||
$pivotOrder = $index;
|
||||
$lessonOrder = $index + 1;
|
||||
|
||||
if ((int) ($courseLesson->order_num ?? 0) !== $pivotOrder) {
|
||||
$courseLesson->forceFill([
|
||||
'order_num' => $pivotOrder,
|
||||
])->save();
|
||||
}
|
||||
|
||||
if ($courseLesson->lesson === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ((int) ($courseLesson->lesson->course_order ?? 0) === $lessonOrder
|
||||
&& (int) ($courseLesson->lesson->lesson_number ?? 0) === $lessonOrder) {
|
||||
return;
|
||||
}
|
||||
|
||||
$courseLesson->lesson->forceFill([
|
||||
'course_order' => $lessonOrder,
|
||||
'lesson_number' => $lessonOrder,
|
||||
])->save();
|
||||
});
|
||||
}
|
||||
}
|
||||
72
app/Services/Academy/AcademyCourseNavigationService.php
Normal file
72
app/Services/Academy/AcademyCourseNavigationService.php
Normal file
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Academy;
|
||||
|
||||
use App\Models\AcademyCourse;
|
||||
use App\Models\AcademyCourseLesson;
|
||||
use App\Models\AcademyLesson;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
final class AcademyCourseNavigationService
|
||||
{
|
||||
/**
|
||||
* @return Collection<int, AcademyCourseLesson>
|
||||
*/
|
||||
public function orderedCourseLessons(AcademyCourse $course): Collection
|
||||
{
|
||||
return $course->courseLessons()
|
||||
->with(['section', 'lesson.category'])
|
||||
->get()
|
||||
->filter(fn (AcademyCourseLesson $courseLesson): bool => $courseLesson->lesson instanceof AcademyLesson
|
||||
&& (bool) $courseLesson->lesson->active
|
||||
&& $courseLesson->lesson->published_at !== null
|
||||
&& $courseLesson->lesson->published_at->lte(now()))
|
||||
->sort(fn (AcademyCourseLesson $left, AcademyCourseLesson $right): int => $this->courseLessonSortKey($left) <=> $this->courseLessonSortKey($right))
|
||||
->values();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, int>
|
||||
*/
|
||||
private function courseLessonSortKey(AcademyCourseLesson $courseLesson): array
|
||||
{
|
||||
$section = $courseLesson->section;
|
||||
|
||||
return [
|
||||
$courseLesson->section_id === null ? 0 : 1,
|
||||
(int) ($section?->order_num ?? 0),
|
||||
(int) ($section?->id ?? 0),
|
||||
(int) ($courseLesson->order_num ?? 0),
|
||||
(int) $courseLesson->id,
|
||||
];
|
||||
}
|
||||
|
||||
public function firstPublishedLesson(AcademyCourse $course): ?AcademyCourseLesson
|
||||
{
|
||||
return $this->orderedCourseLessons($course)->first();
|
||||
}
|
||||
|
||||
public function findCourseLesson(AcademyCourse $course, AcademyLesson $lesson): ?AcademyCourseLesson
|
||||
{
|
||||
return $this->orderedCourseLessons($course)
|
||||
->first(fn (AcademyCourseLesson $courseLesson): bool => $courseLesson->lesson?->is($lesson) ?? false);
|
||||
}
|
||||
|
||||
public function nextLesson(AcademyCourse $course, AcademyLesson $lesson): ?AcademyCourseLesson
|
||||
{
|
||||
$ordered = $this->orderedCourseLessons($course);
|
||||
$index = $ordered->search(fn (AcademyCourseLesson $courseLesson): bool => $courseLesson->lesson?->is($lesson) ?? false);
|
||||
|
||||
return is_int($index) ? $ordered->get($index + 1) : null;
|
||||
}
|
||||
|
||||
public function previousLesson(AcademyCourse $course, AcademyLesson $lesson): ?AcademyCourseLesson
|
||||
{
|
||||
$ordered = $this->orderedCourseLessons($course);
|
||||
$index = $ordered->search(fn (AcademyCourseLesson $courseLesson): bool => $courseLesson->lesson?->is($lesson) ?? false);
|
||||
|
||||
return is_int($index) && $index > 0 ? $ordered->get($index - 1) : null;
|
||||
}
|
||||
}
|
||||
173
app/Services/Academy/AcademyCourseProgressService.php
Normal file
173
app/Services/Academy/AcademyCourseProgressService.php
Normal file
@@ -0,0 +1,173 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Academy;
|
||||
|
||||
use App\Models\AcademyCourse;
|
||||
use App\Models\AcademyCourseEnrollment;
|
||||
use App\Models\AcademyCourseLesson;
|
||||
use App\Models\AcademyLesson;
|
||||
use App\Models\AcademyLessonProgress;
|
||||
use App\Models\User;
|
||||
|
||||
final class AcademyCourseProgressService
|
||||
{
|
||||
public function __construct(private readonly AcademyCourseNavigationService $navigation)
|
||||
{
|
||||
}
|
||||
|
||||
public function getProgress(?User $user, AcademyCourse $course): array
|
||||
{
|
||||
$totalRequired = $this->getTotalRequiredLessonsCount($course);
|
||||
$completedRequired = $user ? $this->getCompletedRequiredLessonsCount($user, $course) : 0;
|
||||
$enrollment = $user
|
||||
? AcademyCourseEnrollment::query()->with('lastLesson')->where('user_id', $user->id)->where('course_id', $course->id)->first()
|
||||
: null;
|
||||
|
||||
return [
|
||||
'total_required' => $totalRequired,
|
||||
'completed_required' => $completedRequired,
|
||||
'progress_percent' => $this->getProgressPercent($user, $course),
|
||||
'enrollment' => $enrollment,
|
||||
'next_lesson' => $user ? $this->getNextLesson($user, $course) : null,
|
||||
'continue_lesson' => $user ? $this->getContinueLesson($user, $course) : null,
|
||||
'completed' => $enrollment?->status === AcademyCourseEnrollment::STATUS_COMPLETED,
|
||||
];
|
||||
}
|
||||
|
||||
public function getCompletedRequiredLessonsCount(User $user, AcademyCourse $course): int
|
||||
{
|
||||
$lessonIds = $course->courseLessons()
|
||||
->where('is_required', true)
|
||||
->pluck('lesson_id')
|
||||
->filter()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
if ($lessonIds === []) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return AcademyLessonProgress::query()
|
||||
->where('user_id', $user->id)
|
||||
->whereIn('lesson_id', $lessonIds)
|
||||
->whereNotNull('completed_at')
|
||||
->count();
|
||||
}
|
||||
|
||||
public function getCompletedLessonIds(User $user, AcademyCourse $course): array
|
||||
{
|
||||
$lessonIds = $course->courseLessons()
|
||||
->pluck('lesson_id')
|
||||
->filter()
|
||||
->map(fn ($lessonId): int => (int) $lessonId)
|
||||
->values()
|
||||
->all();
|
||||
|
||||
if ($lessonIds === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return AcademyLessonProgress::query()
|
||||
->where('user_id', $user->id)
|
||||
->whereIn('lesson_id', $lessonIds)
|
||||
->whereNotNull('completed_at')
|
||||
->pluck('lesson_id')
|
||||
->map(fn ($lessonId): int => (int) $lessonId)
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
public function getTotalRequiredLessonsCount(AcademyCourse $course): int
|
||||
{
|
||||
return $course->courseLessons()->where('is_required', true)->count();
|
||||
}
|
||||
|
||||
public function getProgressPercent(?User $user, AcademyCourse $course): int
|
||||
{
|
||||
if (! $user) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$totalRequired = $this->getTotalRequiredLessonsCount($course);
|
||||
|
||||
if ($totalRequired < 1) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return (int) floor(($this->getCompletedRequiredLessonsCount($user, $course) / $totalRequired) * 100);
|
||||
}
|
||||
|
||||
public function getNextLesson(User $user, AcademyCourse $course): ?AcademyCourseLesson
|
||||
{
|
||||
$completedLessonIds = $this->getCompletedLessonIds($user, $course);
|
||||
|
||||
return $this->navigation->orderedCourseLessons($course)
|
||||
->first(fn (AcademyCourseLesson $courseLesson): bool => ! in_array((int) $courseLesson->lesson_id, $completedLessonIds, true));
|
||||
}
|
||||
|
||||
public function getPreviousLesson(AcademyCourse $course, AcademyLesson $lesson): ?AcademyCourseLesson
|
||||
{
|
||||
return $this->navigation->previousLesson($course, $lesson);
|
||||
}
|
||||
|
||||
public function getContinueLesson(User $user, AcademyCourse $course): ?AcademyCourseLesson
|
||||
{
|
||||
$enrollment = AcademyCourseEnrollment::query()
|
||||
->where('user_id', $user->id)
|
||||
->where('course_id', $course->id)
|
||||
->first();
|
||||
|
||||
if ($enrollment?->last_lesson_id) {
|
||||
$lastLesson = AcademyLesson::query()->find($enrollment->last_lesson_id);
|
||||
|
||||
if ($lastLesson instanceof AcademyLesson) {
|
||||
$nextLesson = $this->navigation->nextLesson($course, $lastLesson);
|
||||
|
||||
return $nextLesson ?? $this->navigation->findCourseLesson($course, $lastLesson);
|
||||
}
|
||||
}
|
||||
|
||||
return $this->getNextLesson($user, $course) ?? $this->navigation->firstPublishedLesson($course);
|
||||
}
|
||||
|
||||
public function markEnrollmentStarted(User $user, AcademyCourse $course): AcademyCourseEnrollment
|
||||
{
|
||||
return AcademyCourseEnrollment::query()->updateOrCreate(
|
||||
[
|
||||
'user_id' => $user->id,
|
||||
'course_id' => $course->id,
|
||||
],
|
||||
[
|
||||
'status' => AcademyCourseEnrollment::STATUS_ACTIVE,
|
||||
'started_at' => now(),
|
||||
'completed_at' => null,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
public function updateLastLesson(User $user, AcademyCourse $course, AcademyLesson $lesson): AcademyCourseEnrollment
|
||||
{
|
||||
$enrollment = $this->markEnrollmentStarted($user, $course);
|
||||
$enrollment->forceFill([
|
||||
'last_lesson_id' => $lesson->id,
|
||||
])->save();
|
||||
|
||||
return $enrollment;
|
||||
}
|
||||
|
||||
public function markCourseCompletedIfFinished(User $user, AcademyCourse $course): AcademyCourseEnrollment
|
||||
{
|
||||
$enrollment = $this->markEnrollmentStarted($user, $course);
|
||||
$progressPercent = $this->getProgressPercent($user, $course);
|
||||
$isComplete = $this->getTotalRequiredLessonsCount($course) > 0 && $progressPercent >= 100;
|
||||
|
||||
$enrollment->forceFill([
|
||||
'status' => $isComplete ? AcademyCourseEnrollment::STATUS_COMPLETED : AcademyCourseEnrollment::STATUS_ACTIVE,
|
||||
'completed_at' => $isComplete ? ($enrollment->completed_at ?? now()) : null,
|
||||
])->save();
|
||||
|
||||
return $enrollment;
|
||||
}
|
||||
}
|
||||
42
app/Services/Academy/AcademyLessonMarkdownRenderer.php
Normal file
42
app/Services/Academy/AcademyLessonMarkdownRenderer.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Academy;
|
||||
|
||||
use League\CommonMark\Environment\Environment;
|
||||
use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension;
|
||||
use League\CommonMark\Extension\GithubFlavoredMarkdownExtension;
|
||||
use League\CommonMark\MarkdownConverter;
|
||||
|
||||
final class AcademyLessonMarkdownRenderer
|
||||
{
|
||||
private ?MarkdownConverter $converter = null;
|
||||
|
||||
public function render(?string $markdown): string
|
||||
{
|
||||
$trimmed = trim((string) $markdown);
|
||||
|
||||
if ($trimmed === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return trim((string) $this->converter()->convert($trimmed)->getContent());
|
||||
}
|
||||
|
||||
private function converter(): MarkdownConverter
|
||||
{
|
||||
if ($this->converter instanceof MarkdownConverter) {
|
||||
return $this->converter;
|
||||
}
|
||||
|
||||
$environment = new Environment([
|
||||
'html_input' => 'strip',
|
||||
'allow_unsafe_links' => false,
|
||||
]);
|
||||
$environment->addExtension(new CommonMarkCoreExtension());
|
||||
$environment->addExtension(new GithubFlavoredMarkdownExtension());
|
||||
|
||||
return $this->converter = new MarkdownConverter($environment);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Academy;
|
||||
|
||||
use App\Models\AcademyCourse;
|
||||
use App\Models\AcademyLesson;
|
||||
use App\Models\AcademyLessonProgress;
|
||||
use App\Models\AcademyPromptTemplate;
|
||||
@@ -12,11 +13,13 @@ use App\Models\User;
|
||||
|
||||
final class AcademyProgressService
|
||||
{
|
||||
public function __construct(private readonly AcademyBadgeService $badges)
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AcademyBadgeService $badges,
|
||||
private readonly AcademyCourseProgressService $courses,
|
||||
) {
|
||||
}
|
||||
|
||||
public function markLessonComplete(User $user, AcademyLesson $lesson): AcademyLessonProgress
|
||||
public function markLessonComplete(User $user, AcademyLesson $lesson, ?AcademyCourse $course = null): AcademyLessonProgress
|
||||
{
|
||||
$progress = AcademyLessonProgress::query()->updateOrCreate(
|
||||
[
|
||||
@@ -28,6 +31,11 @@ final class AcademyProgressService
|
||||
],
|
||||
);
|
||||
|
||||
if ($course instanceof AcademyCourse) {
|
||||
$this->courses->updateLastLesson($user, $course, $lesson);
|
||||
$this->courses->markCourseCompletedIfFinished($user, $course);
|
||||
}
|
||||
|
||||
$this->badges->syncForUser($user);
|
||||
|
||||
return $progress;
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Sitemaps\Builders;
|
||||
|
||||
use App\Models\AcademyCourse;
|
||||
use App\Services\Sitemaps\AbstractSitemapBuilder;
|
||||
use App\Services\Sitemaps\SitemapUrl;
|
||||
use App\Services\Sitemaps\SitemapUrlBuilder;
|
||||
use DateTimeInterface;
|
||||
|
||||
final class AcademyCoursesSitemapBuilder extends AbstractSitemapBuilder
|
||||
{
|
||||
public function __construct(private readonly SitemapUrlBuilder $urls)
|
||||
{
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'academy-courses';
|
||||
}
|
||||
|
||||
public function items(): array
|
||||
{
|
||||
if (! (bool) config('academy.enabled', true)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$items = [$this->urls->staticRoute('/academy/courses')];
|
||||
|
||||
$details = AcademyCourse::query()
|
||||
->published()
|
||||
->orderBy('id')
|
||||
->cursor()
|
||||
->map(fn (AcademyCourse $course): SitemapUrl => $this->urls->staticRoute('/academy/courses/' . $course->slug, $course->updated_at ?? $course->published_at))
|
||||
->values()
|
||||
->all();
|
||||
|
||||
return array_merge($items, $details);
|
||||
}
|
||||
|
||||
public function lastModified(): ?DateTimeInterface
|
||||
{
|
||||
return $this->dateTime(AcademyCourse::query()->published()->max('updated_at'));
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ namespace App\Services\Sitemaps;
|
||||
|
||||
use App\Services\Sitemaps\Builders\ArtworksSitemapBuilder;
|
||||
use App\Services\Sitemaps\Builders\AcademyChallengesSitemapBuilder;
|
||||
use App\Services\Sitemaps\Builders\AcademyCoursesSitemapBuilder;
|
||||
use App\Services\Sitemaps\Builders\AcademyLessonsSitemapBuilder;
|
||||
use App\Services\Sitemaps\Builders\AcademyPacksSitemapBuilder;
|
||||
use App\Services\Sitemaps\Builders\AcademyPromptsSitemapBuilder;
|
||||
@@ -31,6 +32,7 @@ final class SitemapRegistry
|
||||
|
||||
public function __construct(
|
||||
ArtworksSitemapBuilder $artworks,
|
||||
AcademyCoursesSitemapBuilder $academyCourses,
|
||||
AcademyLessonsSitemapBuilder $academyLessons,
|
||||
AcademyPromptsSitemapBuilder $academyPrompts,
|
||||
AcademyPacksSitemapBuilder $academyPacks,
|
||||
@@ -50,6 +52,7 @@ final class SitemapRegistry
|
||||
) {
|
||||
$this->builders = [
|
||||
$artworks->name() => $artworks,
|
||||
$academyCourses->name() => $academyCourses,
|
||||
$academyLessons->name() => $academyLessons,
|
||||
$academyPrompts->name() => $academyPrompts,
|
||||
$academyPacks->name() => $academyPacks,
|
||||
|
||||
Reference in New Issue
Block a user