chore: commit remaining workspace changes

This commit is contained in:
2026-05-08 21:51:29 +02:00
parent 8d108b8a76
commit ff96ef796e
97 changed files with 18020 additions and 2196 deletions

View 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;
}
}

View File

@@ -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,

View 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');
}
}

View File

@@ -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]);
}
}

View File

@@ -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');
}
}

View File

@@ -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(),

View File

@@ -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,

View File

@@ -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,

View File

@@ -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

View 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]),
];
}
}

View File

@@ -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');
}

View File

@@ -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');
}

View File

@@ -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');
}

View File

@@ -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');

View File

@@ -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',
]);
}

View File

@@ -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
{

View File

@@ -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 ?? '',

View File

@@ -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],

View 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'],
];
}
}

View File

@@ -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'],

View File

@@ -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'],

View 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();
}
}

View 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');
}
}

View 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');
}
}

View 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');
}
}

View File

@@ -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);
}
}

View 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');
}
}

View File

@@ -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');

View File

@@ -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';
}
}

View File

@@ -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);

View 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();
});
}
}

View 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;
}
}

View 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;
}
}

View 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);
}
}

View File

@@ -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;

View File

@@ -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'));
}
}

View File

@@ -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,