chore: commit remaining workspace changes
This commit is contained in:
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],
|
||||
|
||||
Reference in New Issue
Block a user