Compare commits
3 Commits
0c5dde9b22
...
ff96ef796e
| Author | SHA1 | Date | |
|---|---|---|---|
| ff96ef796e | |||
| 8d108b8a76 | |||
| 6b83d76cd1 |
@@ -102,7 +102,7 @@ final class SimilarArtworksPageController extends Controller
|
|||||||
'page_title' => 'Similar to "' . $sourceTitle . '" — Skinbase',
|
'page_title' => 'Similar to "' . $sourceTitle . '" — Skinbase',
|
||||||
'page_meta_description' => 'Discover artworks similar to "' . $sourceTitle . '" on Skinbase.',
|
'page_meta_description' => 'Discover artworks similar to "' . $sourceTitle . '" on Skinbase.',
|
||||||
'page_canonical' => $baseUrl,
|
'page_canonical' => $baseUrl,
|
||||||
'page_robots' => 'noindex,follow',
|
'page_robots' => 'index,follow',
|
||||||
'breadcrumbs' => collect([
|
'breadcrumbs' => collect([
|
||||||
(object) ['name' => 'Explore', 'url' => '/explore'],
|
(object) ['name' => 'Explore', 'url' => '/explore'],
|
||||||
(object) ['name' => $sourceTitle, 'url' => $sourceUrl],
|
(object) ['name' => $sourceTitle, 'url' => $sourceUrl],
|
||||||
|
|||||||
122
app/Console/Commands/AcademyCoursesSyncFoundationsCommand.php
Normal file
122
app/Console/Commands/AcademyCoursesSyncFoundationsCommand.php
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\AcademyCourse;
|
||||||
|
use App\Models\AcademyCourseLesson;
|
||||||
|
use App\Models\AcademyCourseSection;
|
||||||
|
use App\Models\AcademyLesson;
|
||||||
|
use App\Services\Academy\AcademyCacheService;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
final class AcademyCoursesSyncFoundationsCommand extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'academy:courses:sync-foundations';
|
||||||
|
|
||||||
|
protected $description = 'Create or update the default AI-Assisted Digital Art Foundations Academy course.';
|
||||||
|
|
||||||
|
public function __construct(private readonly AcademyCacheService $cache)
|
||||||
|
{
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$course = AcademyCourse::query()->updateOrCreate(
|
||||||
|
['slug' => 'ai-assisted-digital-art-foundations'],
|
||||||
|
[
|
||||||
|
'title' => 'AI-Assisted Digital Art Foundations',
|
||||||
|
'subtitle' => 'A guided path through prompting, publishing, and better Skinbase-ready workflows.',
|
||||||
|
'excerpt' => 'Learn the foundations of AI-assisted digital art, from better prompts and ethical rules to preparing, tagging, and publishing artwork on Skinbase.',
|
||||||
|
'description' => 'A starter course for Skinbase creators who want a structured path from core AI-art concepts to cleaner publishing-ready results.',
|
||||||
|
'access_level' => 'free',
|
||||||
|
'difficulty' => 'beginner',
|
||||||
|
'status' => 'published',
|
||||||
|
'is_featured' => true,
|
||||||
|
'order_num' => 1,
|
||||||
|
'published_at' => now(),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
$sectionOrder = [
|
||||||
|
'Introduction',
|
||||||
|
'Prompting Basics',
|
||||||
|
'Publishing on Skinbase',
|
||||||
|
'Workflow and Quality',
|
||||||
|
];
|
||||||
|
|
||||||
|
$sections = collect($sectionOrder)->mapWithKeys(function (string $title, int $index) use ($course): array {
|
||||||
|
$section = AcademyCourseSection::query()->updateOrCreate(
|
||||||
|
['course_id' => $course->id, 'slug' => Str::slug($title)],
|
||||||
|
[
|
||||||
|
'title' => $title,
|
||||||
|
'order_num' => $index,
|
||||||
|
'is_visible' => true,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
return [$title => $section];
|
||||||
|
});
|
||||||
|
|
||||||
|
$lessonMap = [
|
||||||
|
'Introduction' => [
|
||||||
|
'what-is-ai-assisted-digital-art',
|
||||||
|
'ai-ethics-and-skinbase-upload-rules',
|
||||||
|
'ai-generated-vs-ai-assisted-artwork',
|
||||||
|
],
|
||||||
|
'Prompting Basics' => [
|
||||||
|
'prompting-basics-for-skinbase-creators',
|
||||||
|
'how-to-write-better-wallpaper-prompts',
|
||||||
|
'understanding-style-mood-lighting-and-composition',
|
||||||
|
],
|
||||||
|
'Publishing on Skinbase' => [
|
||||||
|
'how-to-prepare-ai-artwork-for-upload',
|
||||||
|
'how-to-choose-better-tags-and-categories',
|
||||||
|
],
|
||||||
|
'Workflow and Quality' => [
|
||||||
|
'how-to-avoid-common-ai-image-problems',
|
||||||
|
'from-idea-to-artwork-a-simple-skinbase-workflow',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
$orderNum = 0;
|
||||||
|
foreach ($lessonMap as $sectionTitle => $slugs) {
|
||||||
|
$section = $sections->get($sectionTitle);
|
||||||
|
|
||||||
|
foreach ($slugs as $slug) {
|
||||||
|
$lesson = AcademyLesson::query()->where('slug', $slug)->first();
|
||||||
|
|
||||||
|
if (! $lesson instanceof AcademyLesson) {
|
||||||
|
$this->warn(sprintf('Skipped missing lesson [%s].', $slug));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
AcademyCourseLesson::query()->updateOrCreate(
|
||||||
|
[
|
||||||
|
'course_id' => $course->id,
|
||||||
|
'lesson_id' => $lesson->id,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'section_id' => $section?->id,
|
||||||
|
'order_num' => $orderNum,
|
||||||
|
'is_required' => true,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
$orderNum++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$course->forceFill([
|
||||||
|
'lessons_count_cache' => AcademyCourseLesson::query()->where('course_id', $course->id)->count(),
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
$this->cache->clearAll();
|
||||||
|
$this->info('AI-Assisted Digital Art Foundations course synced.');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ use App\Console\Commands\BackfillArtworkVectorIndexCommand;
|
|||||||
use App\Console\Commands\IndexArtworkVectorsCommand;
|
use App\Console\Commands\IndexArtworkVectorsCommand;
|
||||||
use App\Console\Commands\SearchArtworkVectorsCommand;
|
use App\Console\Commands\SearchArtworkVectorsCommand;
|
||||||
use App\Console\Commands\AggregateSimilarArtworkAnalyticsCommand;
|
use App\Console\Commands\AggregateSimilarArtworkAnalyticsCommand;
|
||||||
|
use App\Console\Commands\AcademyCoursesSyncFoundationsCommand;
|
||||||
use App\Console\Commands\AggregateFeedAnalyticsCommand;
|
use App\Console\Commands\AggregateFeedAnalyticsCommand;
|
||||||
use App\Console\Commands\AggregateTagInteractionAnalyticsCommand;
|
use App\Console\Commands\AggregateTagInteractionAnalyticsCommand;
|
||||||
use App\Console\Commands\SeedTagInteractionDemoCommand;
|
use App\Console\Commands\SeedTagInteractionDemoCommand;
|
||||||
@@ -71,6 +72,7 @@ class Kernel extends ConsoleKernel
|
|||||||
ZipUnsupportedArtworkOriginalsCommand::class,
|
ZipUnsupportedArtworkOriginalsCommand::class,
|
||||||
SendTestMail::class,
|
SendTestMail::class,
|
||||||
DispatchCollectionMaintenanceCommand::class,
|
DispatchCollectionMaintenanceCommand::class,
|
||||||
|
AcademyCoursesSyncFoundationsCommand::class,
|
||||||
BackfillArtworkEmbeddingsCommand::class,
|
BackfillArtworkEmbeddingsCommand::class,
|
||||||
BackfillArtworkVectorIndexCommand::class,
|
BackfillArtworkVectorIndexCommand::class,
|
||||||
IndexArtworkVectorsCommand::class,
|
IndexArtworkVectorsCommand::class,
|
||||||
|
|||||||
184
app/Http/Controllers/Academy/AcademyCourseController.php
Normal file
184
app/Http/Controllers/Academy/AcademyCourseController.php
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Academy;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\AcademyCourse;
|
||||||
|
use App\Models\AcademyCourseLesson;
|
||||||
|
use App\Services\Academy\AcademyAccessService;
|
||||||
|
use App\Services\Academy\AcademyCacheService;
|
||||||
|
use App\Services\Academy\AcademyCourseNavigationService;
|
||||||
|
use App\Services\Academy\AcademyCourseProgressService;
|
||||||
|
use App\Support\Seo\SeoFactory;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
use Inertia\Response;
|
||||||
|
|
||||||
|
final class AcademyCourseController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly AcademyAccessService $access,
|
||||||
|
private readonly AcademyCacheService $cache,
|
||||||
|
private readonly AcademyCourseNavigationService $navigation,
|
||||||
|
private readonly AcademyCourseProgressService $progress,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function index(Request $request): Response
|
||||||
|
{
|
||||||
|
abort_unless((bool) config('academy.enabled', true), 404);
|
||||||
|
|
||||||
|
$filters = $request->validate([
|
||||||
|
'difficulty' => ['nullable', 'string', 'max:40'],
|
||||||
|
'access' => ['nullable', 'string', 'max:40'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$query = AcademyCourse::query()->published()->ordered();
|
||||||
|
|
||||||
|
if (filled($filters['difficulty'] ?? null)) {
|
||||||
|
$query->where('difficulty', $filters['difficulty']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filled($filters['access'] ?? null)) {
|
||||||
|
$query->where('access_level', $filters['access']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$courses = $query->paginate(12)->withQueryString();
|
||||||
|
$courses->getCollection()->transform(function (AcademyCourse $course) use ($request): array {
|
||||||
|
return $this->access->coursePayload($course, $request->user(), [
|
||||||
|
'progress' => $this->progress->getProgress($request->user(), $course),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
$featuredCourses = collect($this->cache->featuredCourses())->map(fn (AcademyCourse $course): array => $this->access->coursePayload($course, $request->user(), [
|
||||||
|
'progress' => $this->progress->getProgress($request->user(), $course),
|
||||||
|
]))->values();
|
||||||
|
|
||||||
|
$seoCourses = $featuredCourses
|
||||||
|
->concat(collect($courses->items()))
|
||||||
|
->unique(fn (array $course): string => (string) ($course['slug'] ?? ''))
|
||||||
|
->values();
|
||||||
|
|
||||||
|
$seo = app(SeoFactory::class)
|
||||||
|
->academyCourseListingPage(
|
||||||
|
'Academy Courses — Skinbase',
|
||||||
|
'Follow guided Skinbase AI Academy courses built from reusable lessons, chapters, and creator workflows.',
|
||||||
|
route('academy.courses.index', $request->query()),
|
||||||
|
$seoCourses,
|
||||||
|
[
|
||||||
|
['name' => 'Academy', 'url' => route('academy.index')],
|
||||||
|
['name' => 'Courses', 'url' => route('academy.courses.index')],
|
||||||
|
],
|
||||||
|
)
|
||||||
|
->toArray();
|
||||||
|
|
||||||
|
return Inertia::render('Academy/CoursesIndex', [
|
||||||
|
'seo' => $seo,
|
||||||
|
'title' => 'Academy courses',
|
||||||
|
'description' => 'Guided learning paths built from reusable Academy lessons and creator workflows.',
|
||||||
|
'items' => $courses,
|
||||||
|
'featuredCourses' => $featuredCourses->all(),
|
||||||
|
'filters' => $filters,
|
||||||
|
'pricingUrl' => route('academy.pricing'),
|
||||||
|
])->rootView('collections');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(Request $request, AcademyCourse $course): Response
|
||||||
|
{
|
||||||
|
abort_unless((bool) config('academy.enabled', true), 404);
|
||||||
|
abort_unless($course->isPublished(), 404);
|
||||||
|
|
||||||
|
$course->load(['sections', 'courseLessons.section', 'courseLessons.lesson.category']);
|
||||||
|
|
||||||
|
$progress = $this->progress->getProgress($request->user(), $course);
|
||||||
|
$completedLessonIds = $request->user() ? $this->progress->getCompletedLessonIds($request->user(), $course) : [];
|
||||||
|
$orderedLessons = $this->navigation->orderedCourseLessons($course);
|
||||||
|
$stepMeta = $orderedLessons
|
||||||
|
->values()
|
||||||
|
->mapWithKeys(fn (AcademyCourseLesson $courseLesson, int $index): array => [
|
||||||
|
$courseLesson->id => [
|
||||||
|
'course_step_number' => $index + 1,
|
||||||
|
'course_step_label' => sprintf('Step %02d', $index + 1),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
$sections = $course->sections
|
||||||
|
->sortBy([['order_num', 'asc'], ['id', 'asc']])
|
||||||
|
->values()
|
||||||
|
->map(function ($section) use ($completedLessonIds, $orderedLessons, $request, $stepMeta): array {
|
||||||
|
$sectionLessons = $orderedLessons
|
||||||
|
->where('section_id', $section->id)
|
||||||
|
->values()
|
||||||
|
->map(fn (AcademyCourseLesson $courseLesson): array => $this->access->courseLessonPayload($courseLesson, $request->user(), false, [
|
||||||
|
'completed_lesson_ids' => $completedLessonIds,
|
||||||
|
...((array) $stepMeta->get($courseLesson->id, [])),
|
||||||
|
]))
|
||||||
|
->all();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => (int) $section->id,
|
||||||
|
'title' => (string) $section->title,
|
||||||
|
'slug' => (string) ($section->slug ?? ''),
|
||||||
|
'description' => (string) ($section->description ?? ''),
|
||||||
|
'order_num' => (int) ($section->order_num ?? 0),
|
||||||
|
'is_visible' => (bool) ($section->is_visible ?? true),
|
||||||
|
'lessons' => $sectionLessons,
|
||||||
|
];
|
||||||
|
})
|
||||||
|
->all();
|
||||||
|
|
||||||
|
$unsectionedLessons = $orderedLessons
|
||||||
|
->whereNull('section_id')
|
||||||
|
->values()
|
||||||
|
->map(fn (AcademyCourseLesson $courseLesson): array => $this->access->courseLessonPayload($courseLesson, $request->user(), false, [
|
||||||
|
'completed_lesson_ids' => $completedLessonIds,
|
||||||
|
...((array) $stepMeta->get($courseLesson->id, [])),
|
||||||
|
]))
|
||||||
|
->all();
|
||||||
|
|
||||||
|
$coursePayload = $this->access->coursePayload($course, $request->user(), ['progress' => $progress]);
|
||||||
|
$courseKeywords = collect(explode(',', (string) ($course->meta_keywords ?? '')))
|
||||||
|
->map(fn (string $keyword): string => trim($keyword))
|
||||||
|
->filter()
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
$courseImage = (string) ($coursePayload['cover_image_url'] ?? $coursePayload['teaser_image_url'] ?? $course->og_image ?? $course->cover_image ?? $course->teaser_image ?? '');
|
||||||
|
|
||||||
|
$seo = app(SeoFactory::class)
|
||||||
|
->academyCoursePage(
|
||||||
|
(string) ($course->seo_title ?: ($course->title . ' — Skinbase Academy')),
|
||||||
|
(string) ($course->seo_description ?: $course->excerpt ?: 'Skinbase Academy course'),
|
||||||
|
route('academy.courses.show', ['course' => $course->slug]),
|
||||||
|
$courseImage,
|
||||||
|
[
|
||||||
|
['name' => 'Academy', 'url' => route('academy.index')],
|
||||||
|
['name' => 'Courses', 'url' => route('academy.courses.index')],
|
||||||
|
['name' => (string) $course->title, 'url' => route('academy.courses.show', ['course' => $course->slug])],
|
||||||
|
],
|
||||||
|
$courseKeywords,
|
||||||
|
$course->published_at?->toAtomString(),
|
||||||
|
$course->updated_at?->toAtomString(),
|
||||||
|
(string) ($course->access_level ?? ''),
|
||||||
|
(string) ($course->difficulty ?? ''),
|
||||||
|
(int) ($course->estimated_minutes ?? 0),
|
||||||
|
$orderedLessons
|
||||||
|
->values()
|
||||||
|
->map(fn (AcademyCourseLesson $courseLesson): array => $this->access->courseLessonPayload($courseLesson, $request->user(), false, [
|
||||||
|
'completed_lesson_ids' => $completedLessonIds,
|
||||||
|
...((array) $stepMeta->get($courseLesson->id, [])),
|
||||||
|
]))
|
||||||
|
->all(),
|
||||||
|
)
|
||||||
|
->toArray();
|
||||||
|
|
||||||
|
return Inertia::render('Academy/CoursesShow', [
|
||||||
|
'seo' => $seo,
|
||||||
|
'course' => $coursePayload,
|
||||||
|
'sections' => $sections,
|
||||||
|
'unsectionedLessons' => $unsectionedLessons,
|
||||||
|
'pricingUrl' => route('academy.pricing'),
|
||||||
|
'startUrl' => $request->user() ? route('academy.courses.start', ['course' => $course->slug]) : null,
|
||||||
|
])->rootView('collections');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Academy;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\AcademyCourse;
|
||||||
|
use App\Services\Academy\AcademyCourseProgressService;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
final class AcademyCourseEnrollmentController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(private readonly AcademyCourseProgressService $progress)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function start(Request $request, AcademyCourse $course): RedirectResponse
|
||||||
|
{
|
||||||
|
abort_unless((bool) config('academy.enabled', true), 404);
|
||||||
|
abort_unless($course->isPublished(), 404);
|
||||||
|
|
||||||
|
$this->progress->markEnrollmentStarted($request->user(), $course);
|
||||||
|
$continueLesson = $this->progress->getContinueLesson($request->user(), $course);
|
||||||
|
|
||||||
|
if ($continueLesson?->lesson) {
|
||||||
|
return redirect()->route('academy.courses.lessons.show', ['course' => $course->slug, 'lesson' => $continueLesson->lesson->slug]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()->route('academy.courses.show', ['course' => $course->slug]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Academy;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\AcademyCourse;
|
||||||
|
use App\Models\AcademyLesson;
|
||||||
|
use App\Services\Academy\AcademyAccessService;
|
||||||
|
use App\Services\Academy\AcademyCourseNavigationService;
|
||||||
|
use App\Services\Academy\AcademyCourseProgressService;
|
||||||
|
use App\Support\Seo\SeoFactory;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
use Inertia\Response;
|
||||||
|
|
||||||
|
final class AcademyCourseLessonController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly AcademyAccessService $access,
|
||||||
|
private readonly AcademyCourseNavigationService $navigation,
|
||||||
|
private readonly AcademyCourseProgressService $progress,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(Request $request, AcademyCourse $course, AcademyLesson $lesson): Response
|
||||||
|
{
|
||||||
|
abort_unless((bool) config('academy.enabled', true), 404);
|
||||||
|
abort_unless($course->isPublished(), 404);
|
||||||
|
|
||||||
|
$course->load(['sections', 'courseLessons.section', 'courseLessons.lesson.category']);
|
||||||
|
$courseLesson = $this->navigation->findCourseLesson($course, $lesson);
|
||||||
|
|
||||||
|
abort_unless($courseLesson instanceof \App\Models\AcademyCourseLesson, 404);
|
||||||
|
|
||||||
|
if ($request->user()) {
|
||||||
|
$this->progress->updateLastLesson($request->user(), $course, $lesson);
|
||||||
|
$this->progress->markCourseCompletedIfFinished($request->user(), $course);
|
||||||
|
}
|
||||||
|
|
||||||
|
$progress = $this->progress->getProgress($request->user(), $course);
|
||||||
|
$previousLesson = $this->navigation->previousLesson($course, $lesson);
|
||||||
|
$nextLesson = $this->navigation->nextLesson($course, $lesson);
|
||||||
|
$courseOutline = $this->navigation->orderedCourseLessons($course)
|
||||||
|
->map(fn (\App\Models\AcademyCourseLesson $entry): array => $this->access->courseLessonPayload($entry, $request->user()))
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
|
||||||
|
$payload = $this->access->courseLessonPayload($courseLesson, $request->user(), true);
|
||||||
|
$canonical = route('academy.courses.lessons.show', ['course' => $course->slug, 'lesson' => $lesson->slug]);
|
||||||
|
$description = Str::limit(trim((string) ($lesson->seo_description ?? $lesson->excerpt ?? 'Skinbase Academy course lesson.')), 160, '...');
|
||||||
|
$seo = app(SeoFactory::class)->academyLessonPage(
|
||||||
|
(string) ($lesson->seo_title ?? ($lesson->title . ' — ' . $course->title)),
|
||||||
|
$description,
|
||||||
|
$canonical,
|
||||||
|
(string) ($payload['article_cover_image_url'] ?? $payload['cover_image_url'] ?? $lesson->cover_image ?? ''),
|
||||||
|
[
|
||||||
|
['name' => 'Academy', 'url' => route('academy.index')],
|
||||||
|
['name' => 'Courses', 'url' => route('academy.courses.index')],
|
||||||
|
['name' => (string) $course->title, 'url' => route('academy.courses.show', ['course' => $course->slug])],
|
||||||
|
['name' => (string) $lesson->title, 'url' => $canonical],
|
||||||
|
],
|
||||||
|
array_values((array) ($payload['tags'] ?? [])),
|
||||||
|
$lesson->published_at?->toAtomString(),
|
||||||
|
$lesson->updated_at?->toAtomString(),
|
||||||
|
(string) $course->title,
|
||||||
|
)->toArray();
|
||||||
|
|
||||||
|
return Inertia::render('Academy/Show', [
|
||||||
|
'pageType' => 'lesson',
|
||||||
|
'item' => $payload,
|
||||||
|
'relatedLessons' => [],
|
||||||
|
'relatedCourses' => [],
|
||||||
|
'previousLesson' => $previousLesson ? $this->access->courseLessonPayload($previousLesson, $request->user()) : null,
|
||||||
|
'nextLesson' => $nextLesson ? $this->access->courseLessonPayload($nextLesson, $request->user()) : null,
|
||||||
|
'seo' => $seo,
|
||||||
|
'pricingUrl' => route('academy.pricing'),
|
||||||
|
'completeUrl' => $request->user() ? route('academy.lessons.complete', ['lesson' => $lesson->id]) : null,
|
||||||
|
'completed' => $request->user()?->academyLessonProgress()->where('lesson_id', $lesson->id)->whereNotNull('completed_at')->exists() ?? false,
|
||||||
|
'courseContext' => [
|
||||||
|
'id' => (int) $course->id,
|
||||||
|
'title' => (string) $course->title,
|
||||||
|
'slug' => (string) $course->slug,
|
||||||
|
'subtitle' => (string) ($course->subtitle ?? ''),
|
||||||
|
'showUrl' => route('academy.courses.show', ['course' => $course->slug]),
|
||||||
|
'completePayload' => ['course_id' => $course->id],
|
||||||
|
'progress' => [
|
||||||
|
'percent' => (int) ($progress['progress_percent'] ?? 0),
|
||||||
|
'completedRequired' => (int) ($progress['completed_required'] ?? 0),
|
||||||
|
'totalRequired' => (int) ($progress['total_required'] ?? 0),
|
||||||
|
'completed' => (bool) ($progress['completed'] ?? false),
|
||||||
|
],
|
||||||
|
'outline' => $courseOutline,
|
||||||
|
],
|
||||||
|
])->rootView('collections');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ namespace App\Http\Controllers\Academy;
|
|||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Models\AcademyChallenge;
|
use App\Models\AcademyChallenge;
|
||||||
|
use App\Models\AcademyCourse;
|
||||||
use App\Models\AcademyLesson;
|
use App\Models\AcademyLesson;
|
||||||
use App\Models\AcademyPromptTemplate;
|
use App\Models\AcademyPromptTemplate;
|
||||||
use App\Services\Academy\AcademyAccessService;
|
use App\Services\Academy\AcademyAccessService;
|
||||||
@@ -41,11 +42,13 @@ final class AcademyHomeController extends Controller
|
|||||||
$home = $this->cache->homePayload(function (): array {
|
$home = $this->cache->homePayload(function (): array {
|
||||||
return [
|
return [
|
||||||
'featuredLessons' => $this->cache->featuredLessons(),
|
'featuredLessons' => $this->cache->featuredLessons(),
|
||||||
|
'featuredCourses' => $this->cache->featuredCourses(),
|
||||||
'featuredPrompts' => $this->cache->featuredPrompts(),
|
'featuredPrompts' => $this->cache->featuredPrompts(),
|
||||||
'featuredChallenges' => (bool) config('academy.challenges_enabled', true)
|
'featuredChallenges' => (bool) config('academy.challenges_enabled', true)
|
||||||
? $this->cache->featuredChallenges()
|
? $this->cache->featuredChallenges()
|
||||||
: [],
|
: [],
|
||||||
'lessonCount' => AcademyLesson::query()->active()->published()->count(),
|
'lessonCount' => AcademyLesson::query()->active()->published()->count(),
|
||||||
|
'courseCount' => AcademyCourse::query()->published()->count(),
|
||||||
'promptCount' => AcademyPromptTemplate::query()->active()->published()->count(),
|
'promptCount' => AcademyPromptTemplate::query()->active()->published()->count(),
|
||||||
'challengeCount' => (bool) config('academy.challenges_enabled', true)
|
'challengeCount' => (bool) config('academy.challenges_enabled', true)
|
||||||
? AcademyChallenge::query()->publiclyVisible()->count()
|
? AcademyChallenge::query()->publiclyVisible()->count()
|
||||||
@@ -58,6 +61,7 @@ final class AcademyHomeController extends Controller
|
|||||||
'pricingUrl' => route('academy.pricing'),
|
'pricingUrl' => route('academy.pricing'),
|
||||||
'links' => [
|
'links' => [
|
||||||
'lessons' => route('academy.lessons.index'),
|
'lessons' => route('academy.lessons.index'),
|
||||||
|
'courses' => route('academy.courses.index'),
|
||||||
'prompts' => route('academy.prompts.index'),
|
'prompts' => route('academy.prompts.index'),
|
||||||
'packs' => route('academy.packs.index'),
|
'packs' => route('academy.packs.index'),
|
||||||
'challenges' => route('academy.challenges.index'),
|
'challenges' => route('academy.challenges.index'),
|
||||||
@@ -69,9 +73,11 @@ final class AcademyHomeController extends Controller
|
|||||||
],
|
],
|
||||||
'stats' => [
|
'stats' => [
|
||||||
'lessonCount' => (int) $home['lessonCount'],
|
'lessonCount' => (int) $home['lessonCount'],
|
||||||
|
'courseCount' => (int) $home['courseCount'],
|
||||||
'promptCount' => (int) $home['promptCount'],
|
'promptCount' => (int) $home['promptCount'],
|
||||||
'challengeCount' => (int) $home['challengeCount'],
|
'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(),
|
'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(),
|
'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(),
|
'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;
|
namespace App\Http\Controllers\Academy;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\AcademyCourse;
|
||||||
use App\Models\AcademyLesson;
|
use App\Models\AcademyLesson;
|
||||||
use App\Services\Academy\AcademyAccessService;
|
use App\Services\Academy\AcademyAccessService;
|
||||||
use App\Services\Academy\AcademyCacheService;
|
use App\Services\Academy\AcademyCacheService;
|
||||||
@@ -35,7 +36,7 @@ final class AcademyLessonController extends Controller
|
|||||||
->with('category')
|
->with('category')
|
||||||
->active()
|
->active()
|
||||||
->published()
|
->published()
|
||||||
->latest('published_at');
|
->orderedForCourse();
|
||||||
|
|
||||||
if (filled($filters['q'] ?? null)) {
|
if (filled($filters['q'] ?? null)) {
|
||||||
$query->where(function ($builder) use ($filters): void {
|
$query->where(function ($builder) use ($filters): void {
|
||||||
@@ -87,33 +88,73 @@ final class AcademyLessonController extends Controller
|
|||||||
->firstOrFail();
|
->firstOrFail();
|
||||||
|
|
||||||
$payload = $this->access->lessonPayload($lesson, $request->user(), true);
|
$payload = $this->access->lessonPayload($lesson, $request->user(), true);
|
||||||
$relatedLessons = $lesson->category_id !== null
|
$courseQuery = AcademyLesson::query()
|
||||||
? AcademyLesson::query()
|
|
||||||
->with('category')
|
->with('category')
|
||||||
->active()
|
->active()
|
||||||
->published()
|
->published();
|
||||||
->where('category_id', $lesson->category_id)
|
|
||||||
->where('id', '!=', $lesson->id)
|
if (filled($lesson->series_name)) {
|
||||||
->orderByDesc('published_at')
|
$courseQuery->where('series_name', $lesson->series_name);
|
||||||
->limit(6)
|
} elseif ($lesson->category_id !== null) {
|
||||||
|
$courseQuery->where('category_id', $lesson->category_id);
|
||||||
|
} else {
|
||||||
|
$courseQuery->whereKey($lesson->id);
|
||||||
|
}
|
||||||
|
|
||||||
|
$courseLessons = $courseQuery
|
||||||
|
->orderedForCourse()
|
||||||
->get()
|
->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()))
|
->map(fn (AcademyLesson $relatedLesson): array => $this->access->lessonPayload($relatedLesson, $request->user()))
|
||||||
->values()
|
->values()
|
||||||
->all()
|
->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]);
|
$canonical = route('academy.lessons.show', ['slug' => $lesson->slug]);
|
||||||
$description = Str::limit(trim((string) ($lesson->seo_description ?? $lesson->excerpt ?? 'Skinbase Academy lesson.')), 160, '...');
|
$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')),
|
(string) ($lesson->seo_title ?? ($lesson->title.' — Skinbase Academy')),
|
||||||
$description,
|
$description,
|
||||||
$canonical,
|
$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();
|
)->toArray();
|
||||||
|
|
||||||
return Inertia::render('Academy/Show', [
|
return Inertia::render('Academy/Show', [
|
||||||
'pageType' => 'lesson',
|
'pageType' => 'lesson',
|
||||||
'item' => $payload,
|
'item' => $payload,
|
||||||
'relatedLessons' => $relatedLessons,
|
'relatedLessons' => $relatedLessons,
|
||||||
|
'relatedCourses' => $relatedCourses,
|
||||||
|
'previousLesson' => $previousLesson ? $this->access->lessonPayload($previousLesson, $request->user()) : null,
|
||||||
|
'nextLesson' => $nextLesson ? $this->access->lessonPayload($nextLesson, $request->user()) : null,
|
||||||
'seo' => $seo,
|
'seo' => $seo,
|
||||||
'pricingUrl' => route('academy.pricing'),
|
'pricingUrl' => route('academy.pricing'),
|
||||||
'completeUrl' => $request->user() ? route('academy.lessons.complete', ['lesson' => $lesson->id]) : null,
|
'completeUrl' => $request->user() ? route('academy.lessons.complete', ['lesson' => $lesson->id]) : null,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace App\Http\Controllers\Academy;
|
namespace App\Http\Controllers\Academy;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\AcademyCourse;
|
||||||
use App\Models\AcademyLesson;
|
use App\Models\AcademyLesson;
|
||||||
use App\Services\Academy\AcademyAccessService;
|
use App\Services\Academy\AcademyAccessService;
|
||||||
use App\Services\Academy\AcademyProgressService;
|
use App\Services\Academy\AcademyProgressService;
|
||||||
@@ -24,7 +25,13 @@ final class AcademyProgressController extends Controller
|
|||||||
abort_unless((bool) config('academy.enabled', true), 404);
|
abort_unless((bool) config('academy.enabled', true), 404);
|
||||||
abort_unless($this->access->canAccessLesson($request->user(), $lesson), 403);
|
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([
|
return response()->json([
|
||||||
'ok' => true,
|
'ok' => true,
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ class ForumController extends Controller
|
|||||||
|
|
||||||
$thread->loadMissing([
|
$thread->loadMissing([
|
||||||
'category:id,name,slug',
|
'category:id,name,slug',
|
||||||
'user:id,name',
|
'user:id,name,username',
|
||||||
'user.profile:user_id,avatar_hash',
|
'user.profile:user_id,avatar_hash',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -116,7 +116,7 @@ class ForumController extends Controller
|
|||||||
$opPost = ForumPost::query()
|
$opPost = ForumPost::query()
|
||||||
->where('thread_id', $thread->id)
|
->where('thread_id', $thread->id)
|
||||||
->with([
|
->with([
|
||||||
'user:id,name',
|
'user:id,name,username',
|
||||||
'user.profile:user_id,avatar_hash',
|
'user.profile:user_id,avatar_hash',
|
||||||
'attachments:id,post_id,file_path,file_size,mime_type,width,height',
|
'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)
|
->where('thread_id', $thread->id)
|
||||||
->when($opPost, fn ($query) => $query->where('id', '!=', $opPost->id))
|
->when($opPost, fn ($query) => $query->where('id', '!=', $opPost->id))
|
||||||
->with([
|
->with([
|
||||||
'user:id,name',
|
'user:id,name,username',
|
||||||
'user.profile:user_id,avatar_hash',
|
'user.profile:user_id,avatar_hash',
|
||||||
'attachments:id,post_id,file_path,file_size,mime_type,width,height',
|
'attachments:id,post_id,file_path,file_size,mime_type,width,height',
|
||||||
])
|
])
|
||||||
@@ -148,7 +148,7 @@ class ForumController extends Controller
|
|||||||
if ($quotePostId > 0) {
|
if ($quotePostId > 0) {
|
||||||
$quotedPost = ForumPost::query()
|
$quotedPost = ForumPost::query()
|
||||||
->where('thread_id', $thread->id)
|
->where('thread_id', $thread->id)
|
||||||
->with('user:id,name')
|
->with('user:id,name,username')
|
||||||
->find($quotePostId);
|
->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',
|
'title' => 'Collections Dashboard — Skinbase',
|
||||||
'description' => 'Overview of collection lifecycle, quality, activity, and upcoming collection campaigns.',
|
'description' => 'Overview of collection lifecycle, quality, activity, and upcoming collection campaigns.',
|
||||||
'canonical' => route('settings.collections.dashboard'),
|
'canonical' => route('settings.collections.dashboard'),
|
||||||
'robots' => 'noindex,follow',
|
'robots' => 'index,follow',
|
||||||
],
|
],
|
||||||
])->rootView('collections');
|
])->rootView('collections');
|
||||||
}
|
}
|
||||||
@@ -130,7 +130,7 @@ class CollectionInsightsController extends Controller
|
|||||||
'title' => sprintf('%s Analytics — Skinbase', $collection->title),
|
'title' => sprintf('%s Analytics — Skinbase', $collection->title),
|
||||||
'description' => sprintf('Analytics and performance history for the %s collection.', $collection->title),
|
'description' => sprintf('Analytics and performance history for the %s collection.', $collection->title),
|
||||||
'canonical' => route('settings.collections.analytics', ['collection' => $collection->id]),
|
'canonical' => route('settings.collections.analytics', ['collection' => $collection->id]),
|
||||||
'robots' => 'noindex,follow',
|
'robots' => 'index,follow',
|
||||||
],
|
],
|
||||||
])->rootView('collections');
|
])->rootView('collections');
|
||||||
}
|
}
|
||||||
@@ -153,7 +153,7 @@ class CollectionInsightsController extends Controller
|
|||||||
'title' => sprintf('%s History — Skinbase', $collection->title),
|
'title' => sprintf('%s History — Skinbase', $collection->title),
|
||||||
'description' => sprintf('Audit history and lifecycle changes for the %s collection.', $collection->title),
|
'description' => sprintf('Audit history and lifecycle changes for the %s collection.', $collection->title),
|
||||||
'canonical' => route('settings.collections.history', ['collection' => $collection->id]),
|
'canonical' => route('settings.collections.history', ['collection' => $collection->id]),
|
||||||
'robots' => 'noindex,follow',
|
'robots' => 'index,follow',
|
||||||
],
|
],
|
||||||
])->rootView('collections');
|
])->rootView('collections');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ class CollectionProgrammingController extends Controller
|
|||||||
'title' => 'Collection Programming — Skinbase',
|
'title' => 'Collection Programming — Skinbase',
|
||||||
'description' => 'Staff programming tools for assignments, previews, eligibility diagnostics, and recommendation refreshes.',
|
'description' => 'Staff programming tools for assignments, previews, eligibility diagnostics, and recommendation refreshes.',
|
||||||
'canonical' => route('staff.collections.programming'),
|
'canonical' => route('staff.collections.programming'),
|
||||||
'robots' => 'noindex,follow',
|
'robots' => 'index,follow',
|
||||||
],
|
],
|
||||||
])->rootView('collections');
|
])->rootView('collections');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ class CollectionSurfaceController extends Controller
|
|||||||
'title' => 'Collection Surfaces - Skinbase',
|
'title' => 'Collection Surfaces - Skinbase',
|
||||||
'description' => 'Staff tools for homepage, discovery, and campaign collection surfaces.',
|
'description' => 'Staff tools for homepage, discovery, and campaign collection surfaces.',
|
||||||
'canonical' => route('settings.collections.surfaces.index'),
|
'canonical' => route('settings.collections.surfaces.index'),
|
||||||
'robots' => 'noindex,follow',
|
'robots' => 'index,follow',
|
||||||
],
|
],
|
||||||
])->rootView('collections');
|
])->rootView('collections');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ class FeaturedArtworkAdminController extends Controller
|
|||||||
'title' => 'Featured Artworks — Skinbase',
|
'title' => 'Featured Artworks — Skinbase',
|
||||||
'description' => 'Editorial controls for homepage featured artworks and the current hero winner.',
|
'description' => 'Editorial controls for homepage featured artworks and the current hero winner.',
|
||||||
'canonical' => route($routePrefix . 'main'),
|
'canonical' => route($routePrefix . 'main'),
|
||||||
'robots' => 'noindex,follow',
|
'robots' => 'index,follow',
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
))->rootView($isAdminSurface ? 'admin' : 'collections');
|
))->rootView($isAdminSurface ? 'admin' : 'collections');
|
||||||
|
|||||||
@@ -194,7 +194,7 @@ class StoryController extends Controller
|
|||||||
'storyTypes' => $this->storyCategories(),
|
'storyTypes' => $this->storyCategories(),
|
||||||
'page_title' => 'Create Story - Skinbase',
|
'page_title' => 'Create Story - Skinbase',
|
||||||
'page_meta_description' => 'Write and publish a creator story on 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,
|
'md' => $thumbMd,
|
||||||
'lg' => $thumbLg,
|
'lg' => $thumbLg,
|
||||||
'xl' => $thumbXl,
|
'xl' => $thumbXl,
|
||||||
], $canonical)->toArray();
|
], $canonical, $this->artworkBreadcrumbs($artwork, $canonical))->toArray();
|
||||||
|
|
||||||
$categoryIds = $artwork->categories->pluck('id')->filter()->values();
|
$categoryIds = $artwork->categories->pluck('id')->filter()->values();
|
||||||
$tagIds = $artwork->tags->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. */
|
/** Silently catch suggestion query failures so error page never crashes. */
|
||||||
private function safeSuggestions(callable $fn): mixed
|
private function safeSuggestions(callable $fn): mixed
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -181,7 +181,7 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
|
|||||||
'hero_title' => $contentType->name,
|
'hero_title' => $contentType->name,
|
||||||
'hero_description' => $contentType->description ?? ($contentType->name . ' artworks on Skinbase.'),
|
'hero_description' => $contentType->description ?? ($contentType->name . ' artworks on Skinbase.'),
|
||||||
'breadcrumbs' => collect([
|
'breadcrumbs' => collect([
|
||||||
(object) ['name' => 'Explore', 'url' => '/browse'],
|
(object) ['name' => 'Explore', 'url' => route('explore.index')],
|
||||||
(object) ['name' => $contentType->name, 'url' => '/' . $contentSlug],
|
(object) ['name' => $contentType->name, 'url' => '/' . $contentSlug],
|
||||||
]),
|
]),
|
||||||
'page_title' => $contentType->name . ' – Skinbase',
|
'page_title' => $contentType->name . ' – Skinbase',
|
||||||
@@ -237,7 +237,7 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
|
|||||||
$breadcrumbs = collect(array_merge([
|
$breadcrumbs = collect(array_merge([
|
||||||
(object) [
|
(object) [
|
||||||
'name' => 'Explore',
|
'name' => 'Explore',
|
||||||
'url' => '/browse',
|
'url' => route('explore.index'),
|
||||||
],
|
],
|
||||||
(object) [
|
(object) [
|
||||||
'name' => $contentType->name,
|
'name' => $contentType->name,
|
||||||
@@ -335,6 +335,8 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
|
|||||||
return (object) $this->maturity->decoratePayload([
|
return (object) $this->maturity->decoratePayload([
|
||||||
'id' => $artwork->id,
|
'id' => $artwork->id,
|
||||||
'name' => $artwork->title,
|
'name' => $artwork->title,
|
||||||
|
'slug' => $artwork->slug,
|
||||||
|
'url' => route('art.show', ['id' => $artwork->id, 'slug' => $artwork->slug]),
|
||||||
'content_type_name' => $primaryCategory?->contentType?->name ?? '',
|
'content_type_name' => $primaryCategory?->contentType?->name ?? '',
|
||||||
'content_type_slug' => $primaryCategory?->contentType?->slug ?? '',
|
'content_type_slug' => $primaryCategory?->contentType?->slug ?? '',
|
||||||
'category_name' => $primaryCategory->name ?? '',
|
'category_name' => $primaryCategory->name ?? '',
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ final class SimilarArtworksPageController extends Controller
|
|||||||
'page_title' => 'Similar to "' . $sourceTitle . '" — Skinbase',
|
'page_title' => 'Similar to "' . $sourceTitle . '" — Skinbase',
|
||||||
'page_meta_description' => 'Discover artworks similar to "' . $sourceTitle . '" on Skinbase.',
|
'page_meta_description' => 'Discover artworks similar to "' . $sourceTitle . '" on Skinbase.',
|
||||||
'page_canonical' => $baseUrl,
|
'page_canonical' => $baseUrl,
|
||||||
'page_robots' => 'noindex,follow',
|
'page_robots' => 'index,follow',
|
||||||
'breadcrumbs' => collect([
|
'breadcrumbs' => collect([
|
||||||
(object) ['name' => 'Explore', 'url' => '/explore'],
|
(object) ['name' => 'Explore', 'url' => '/explore'],
|
||||||
(object) ['name' => $sourceTitle, 'url' => $sourceUrl],
|
(object) ['name' => $sourceTitle, 'url' => $sourceUrl],
|
||||||
|
|||||||
53
app/Http/Requests/Academy/UpsertAcademyCourseRequest.php
Normal file
53
app/Http/Requests/Academy/UpsertAcademyCourseRequest.php
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Academy;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class UpsertAcademyCourseRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return (bool) $this->user()?->hasStaffAccess();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function prepareForValidation(): void
|
||||||
|
{
|
||||||
|
$this->merge([
|
||||||
|
'is_featured' => $this->boolean('is_featured'),
|
||||||
|
'order_num' => $this->filled('order_num') ? (int) $this->input('order_num') : 0,
|
||||||
|
'estimated_minutes' => $this->filled('estimated_minutes') ? (int) $this->input('estimated_minutes') : null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
$courseId = $this->route('academyCourse')?->id;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'title' => ['required', 'string', 'max:180'],
|
||||||
|
'slug' => ['required', 'string', 'max:180', Rule::unique('academy_courses', 'slug')->ignore($courseId)],
|
||||||
|
'subtitle' => ['nullable', 'string', 'max:255'],
|
||||||
|
'excerpt' => ['nullable', 'string'],
|
||||||
|
'description' => ['nullable', 'string'],
|
||||||
|
'cover_image' => ['nullable', 'string', 'max:2048'],
|
||||||
|
'teaser_image' => ['nullable', 'string', 'max:2048'],
|
||||||
|
'access_level' => ['required', 'string', Rule::in(['free', 'premium', 'mixed'])],
|
||||||
|
'difficulty' => ['required', 'string', Rule::in(['beginner', 'intermediate', 'advanced'])],
|
||||||
|
'status' => ['required', 'string', Rule::in(['draft', 'review', 'published', 'archived'])],
|
||||||
|
'is_featured' => ['required', 'boolean'],
|
||||||
|
'order_num' => ['required', 'integer', 'min:0'],
|
||||||
|
'estimated_minutes' => ['nullable', 'integer', 'min:1', 'max:10000'],
|
||||||
|
'published_at' => ['nullable', 'date'],
|
||||||
|
'seo_title' => ['nullable', 'string', 'max:180'],
|
||||||
|
'seo_description' => ['nullable', 'string', 'max:255'],
|
||||||
|
'meta_keywords' => ['nullable', 'string'],
|
||||||
|
'og_title' => ['nullable', 'string', 'max:180'],
|
||||||
|
'og_description' => ['nullable', 'string', 'max:255'],
|
||||||
|
'og_image' => ['nullable', 'string', 'max:2048'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -69,6 +69,18 @@ class UpsertAcademyLessonRequest extends FormRequest
|
|||||||
->all();
|
->all();
|
||||||
|
|
||||||
$this->merge([
|
$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,
|
'reading_minutes' => $this->filled('reading_minutes') ? (int) $this->input('reading_minutes') : 5,
|
||||||
'featured' => $this->boolean('featured'),
|
'featured' => $this->boolean('featured'),
|
||||||
'active' => $this->boolean('active', true),
|
'active' => $this->boolean('active', true),
|
||||||
@@ -84,12 +96,22 @@ class UpsertAcademyLessonRequest extends FormRequest
|
|||||||
'category_id' => ['nullable', 'integer', 'exists:academy_categories,id'],
|
'category_id' => ['nullable', 'integer', 'exists:academy_categories,id'],
|
||||||
'title' => ['required', 'string', 'max:180'],
|
'title' => ['required', 'string', 'max:180'],
|
||||||
'slug' => ['required', 'string', 'max:180', Rule::unique('academy_lessons', 'slug')->ignore($lessonId)],
|
'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'],
|
'excerpt' => ['nullable', 'string'],
|
||||||
'content' => ['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', []))],
|
'difficulty' => ['required', 'string', Rule::in((array) config('academy.difficulty_levels', []))],
|
||||||
'access_level' => ['required', 'string', Rule::in(['free', 'creator', 'pro'])],
|
'access_level' => ['required', 'string', Rule::in(['free', 'creator', 'pro'])],
|
||||||
'lesson_type' => ['required', 'string', 'max:80'],
|
'lesson_type' => ['required', 'string', 'max:80'],
|
||||||
'cover_image' => ['nullable', 'string', 'max:2048'],
|
'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'],
|
'video_url' => ['nullable', 'string', 'max:2048'],
|
||||||
'reading_minutes' => ['required', 'integer', 'min:1', 'max:999'],
|
'reading_minutes' => ['required', 'integer', 'min:1', 'max:999'],
|
||||||
'featured' => ['required', 'boolean'],
|
'featured' => ['required', 'boolean'],
|
||||||
|
|||||||
@@ -20,8 +20,31 @@ class UpsertAcademyPromptTemplateRequest extends FormRequest
|
|||||||
'featured' => $this->boolean('featured'),
|
'featured' => $this->boolean('featured'),
|
||||||
'prompt_of_week' => $this->boolean('prompt_of_week'),
|
'prompt_of_week' => $this->boolean('prompt_of_week'),
|
||||||
'active' => $this->boolean('active', true),
|
'active' => $this->boolean('active', true),
|
||||||
|
'new_category_name' => trim((string) $this->input('new_category_name', '')),
|
||||||
'tags' => array_values(array_filter((array) $this->input('tags', []))),
|
'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 [
|
return [
|
||||||
'category_id' => ['nullable', 'integer', 'exists:academy_categories,id'],
|
'category_id' => ['nullable', 'integer', 'exists:academy_categories,id'],
|
||||||
|
'new_category_name' => ['nullable', 'string', 'max:120'],
|
||||||
'title' => ['required', 'string', 'max:180'],
|
'title' => ['required', 'string', 'max:180'],
|
||||||
'slug' => ['required', 'string', 'max:180', Rule::unique('academy_prompt_templates', 'slug')->ignore($promptId)],
|
'slug' => ['required', 'string', 'max:180', Rule::unique('academy_prompt_templates', 'slug')->ignore($promptId)],
|
||||||
'excerpt' => ['nullable', 'string'],
|
'excerpt' => ['nullable', 'string'],
|
||||||
@@ -44,6 +68,17 @@ class UpsertAcademyPromptTemplateRequest extends FormRequest
|
|||||||
'tags' => ['nullable', 'array'],
|
'tags' => ['nullable', 'array'],
|
||||||
'tags.*' => ['string', 'max:60'],
|
'tags.*' => ['string', 'max:60'],
|
||||||
'tool_notes' => ['nullable', 'array'],
|
'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' => ['nullable', 'string', 'max:2048'],
|
||||||
'preview_image_file' => ['nullable', 'file', 'image', 'mimes:jpg,jpeg,png,webp', 'max:5120'],
|
'preview_image_file' => ['nullable', 'file', 'image', 'mimes:jpg,jpeg,png,webp', 'max:5120'],
|
||||||
'featured' => ['required', 'boolean'],
|
'featured' => ['required', 'boolean'],
|
||||||
|
|||||||
170
app/Models/AcademyCourse.php
Normal file
170
app/Models/AcademyCourse.php
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
|
||||||
|
class AcademyCourse extends Model
|
||||||
|
{
|
||||||
|
use SoftDeletes;
|
||||||
|
|
||||||
|
public const STATUS_DRAFT = 'draft';
|
||||||
|
public const STATUS_REVIEW = 'review';
|
||||||
|
public const STATUS_PUBLISHED = 'published';
|
||||||
|
public const STATUS_ARCHIVED = 'archived';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'title',
|
||||||
|
'slug',
|
||||||
|
'subtitle',
|
||||||
|
'excerpt',
|
||||||
|
'description',
|
||||||
|
'cover_image',
|
||||||
|
'teaser_image',
|
||||||
|
'access_level',
|
||||||
|
'difficulty',
|
||||||
|
'status',
|
||||||
|
'is_featured',
|
||||||
|
'order_num',
|
||||||
|
'estimated_minutes',
|
||||||
|
'lessons_count_cache',
|
||||||
|
'published_at',
|
||||||
|
'seo_title',
|
||||||
|
'seo_description',
|
||||||
|
'meta_keywords',
|
||||||
|
'og_title',
|
||||||
|
'og_description',
|
||||||
|
'og_image',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'is_featured' => 'boolean',
|
||||||
|
'order_num' => 'integer',
|
||||||
|
'estimated_minutes' => 'integer',
|
||||||
|
'lessons_count_cache' => 'integer',
|
||||||
|
'published_at' => 'datetime',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function scopePublished(Builder $query): Builder
|
||||||
|
{
|
||||||
|
return $query
|
||||||
|
->where('status', self::STATUS_PUBLISHED)
|
||||||
|
->where(function (Builder $builder): void {
|
||||||
|
$builder->whereNull('published_at')->orWhere('published_at', '<=', now());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeFeatured(Builder $query): Builder
|
||||||
|
{
|
||||||
|
return $query->where('is_featured', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeOrdered(Builder $query): Builder
|
||||||
|
{
|
||||||
|
return $query
|
||||||
|
->orderByDesc('is_featured')
|
||||||
|
->orderBy('order_num')
|
||||||
|
->orderByDesc('published_at')
|
||||||
|
->orderBy('id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeFree(Builder $query): Builder
|
||||||
|
{
|
||||||
|
return $query->where('access_level', 'free');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopePremium(Builder $query): Builder
|
||||||
|
{
|
||||||
|
return $query->where('access_level', 'premium');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeMixed(Builder $query): Builder
|
||||||
|
{
|
||||||
|
return $query->where('access_level', 'mixed');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function sections(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(AcademyCourseSection::class, 'course_id')
|
||||||
|
->orderBy('order_num')
|
||||||
|
->orderBy('id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function courseLessons(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(AcademyCourseLesson::class, 'course_id')
|
||||||
|
->orderBy('order_num')
|
||||||
|
->orderBy('id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function lessons(): BelongsToMany
|
||||||
|
{
|
||||||
|
return $this->belongsToMany(AcademyLesson::class, 'academy_course_lessons', 'course_id', 'lesson_id')
|
||||||
|
->using(AcademyCourseLesson::class)
|
||||||
|
->withPivot(['section_id', 'order_num', 'is_required', 'access_override', 'unlock_after_lesson_id'])
|
||||||
|
->withTimestamps()
|
||||||
|
->orderBy('academy_course_lessons.order_num')
|
||||||
|
->orderBy('academy_course_lessons.id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function enrollments(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(AcademyCourseEnrollment::class, 'course_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isPublished(): bool
|
||||||
|
{
|
||||||
|
return (string) $this->status === self::STATUS_PUBLISHED
|
||||||
|
&& ($this->published_at === null || $this->published_at->lte(now()));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isFree(): bool
|
||||||
|
{
|
||||||
|
return (string) $this->access_level === 'free';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isPremium(): bool
|
||||||
|
{
|
||||||
|
return (string) $this->access_level === 'premium';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isMixed(): bool
|
||||||
|
{
|
||||||
|
return (string) $this->access_level === 'mixed';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getPublicUrl(): string
|
||||||
|
{
|
||||||
|
return route('academy.courses.show', ['course' => $this->slug]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getContinueUrl(?User $user): string
|
||||||
|
{
|
||||||
|
$lastLesson = $user?->academyCourseEnrollments()
|
||||||
|
->where('course_id', $this->id)
|
||||||
|
->with('lastLesson')
|
||||||
|
->first()?->lastLesson;
|
||||||
|
|
||||||
|
if ($lastLesson instanceof AcademyLesson) {
|
||||||
|
return route('academy.courses.lessons.show', ['course' => $this->slug, 'lesson' => $lastLesson->slug]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$firstLesson = $this->courseLessons()
|
||||||
|
->with('lesson')
|
||||||
|
->get()
|
||||||
|
->map(fn (AcademyCourseLesson $courseLesson): ?AcademyLesson => $courseLesson->lesson)
|
||||||
|
->first(fn (?AcademyLesson $lesson): bool => $lesson instanceof AcademyLesson);
|
||||||
|
|
||||||
|
if ($firstLesson instanceof AcademyLesson) {
|
||||||
|
return route('academy.courses.lessons.show', ['course' => $this->slug, 'lesson' => $firstLesson->slug]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->getPublicUrl();
|
||||||
|
}
|
||||||
|
}
|
||||||
44
app/Models/AcademyCourseEnrollment.php
Normal file
44
app/Models/AcademyCourseEnrollment.php
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class AcademyCourseEnrollment extends Model
|
||||||
|
{
|
||||||
|
public const STATUS_ACTIVE = 'active';
|
||||||
|
public const STATUS_COMPLETED = 'completed';
|
||||||
|
public const STATUS_PAUSED = 'paused';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'user_id',
|
||||||
|
'course_id',
|
||||||
|
'status',
|
||||||
|
'last_lesson_id',
|
||||||
|
'started_at',
|
||||||
|
'completed_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'started_at' => 'datetime',
|
||||||
|
'completed_at' => 'datetime',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function user(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'user_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function course(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(AcademyCourse::class, 'course_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function lastLesson(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(AcademyLesson::class, 'last_lesson_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
50
app/Models/AcademyCourseLesson.php
Normal file
50
app/Models/AcademyCourseLesson.php
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\Pivot;
|
||||||
|
|
||||||
|
class AcademyCourseLesson extends Pivot
|
||||||
|
{
|
||||||
|
protected $table = 'academy_course_lessons';
|
||||||
|
|
||||||
|
public $incrementing = true;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'course_id',
|
||||||
|
'section_id',
|
||||||
|
'lesson_id',
|
||||||
|
'order_num',
|
||||||
|
'is_required',
|
||||||
|
'access_override',
|
||||||
|
'unlock_after_lesson_id',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'order_num' => 'integer',
|
||||||
|
'is_required' => 'boolean',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function course(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(AcademyCourse::class, 'course_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function section(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(AcademyCourseSection::class, 'section_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function lesson(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(AcademyLesson::class, 'lesson_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function unlockAfterLesson(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(AcademyLesson::class, 'unlock_after_lesson_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
49
app/Models/AcademyCourseSection.php
Normal file
49
app/Models/AcademyCourseSection.php
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
|
class AcademyCourseSection extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'course_id',
|
||||||
|
'title',
|
||||||
|
'slug',
|
||||||
|
'description',
|
||||||
|
'order_num',
|
||||||
|
'is_visible',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'order_num' => 'integer',
|
||||||
|
'is_visible' => 'boolean',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function course(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(AcademyCourse::class, 'course_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function courseLessons(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(AcademyCourseLesson::class, 'section_id')
|
||||||
|
->orderBy('order_num')
|
||||||
|
->orderBy('id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function lessons(): BelongsToMany
|
||||||
|
{
|
||||||
|
return $this->belongsToMany(AcademyLesson::class, 'academy_course_lessons', 'section_id', 'lesson_id')
|
||||||
|
->using(AcademyCourseLesson::class)
|
||||||
|
->withPivot(['course_id', 'order_num', 'is_required', 'access_override', 'unlock_after_lesson_id'])
|
||||||
|
->withTimestamps()
|
||||||
|
->orderBy('academy_course_lessons.order_num')
|
||||||
|
->orderBy('academy_course_lessons.id');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ namespace App\Models;
|
|||||||
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
@@ -33,12 +34,18 @@ class AcademyLesson extends Model
|
|||||||
'category_id',
|
'category_id',
|
||||||
'title',
|
'title',
|
||||||
'slug',
|
'slug',
|
||||||
|
'lesson_number',
|
||||||
|
'course_order',
|
||||||
|
'series_name',
|
||||||
'excerpt',
|
'excerpt',
|
||||||
'content',
|
'content',
|
||||||
|
'content_markdown',
|
||||||
'difficulty',
|
'difficulty',
|
||||||
'access_level',
|
'access_level',
|
||||||
'lesson_type',
|
'lesson_type',
|
||||||
'cover_image',
|
'cover_image',
|
||||||
|
'article_cover_image',
|
||||||
|
'tags',
|
||||||
'video_url',
|
'video_url',
|
||||||
'reading_minutes',
|
'reading_minutes',
|
||||||
'featured',
|
'featured',
|
||||||
@@ -49,12 +56,20 @@ class AcademyLesson extends Model
|
|||||||
];
|
];
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
|
'lesson_number' => 'integer',
|
||||||
|
'course_order' => 'integer',
|
||||||
|
'tags' => 'array',
|
||||||
'reading_minutes' => 'integer',
|
'reading_minutes' => 'integer',
|
||||||
'featured' => 'boolean',
|
'featured' => 'boolean',
|
||||||
'active' => 'boolean',
|
'active' => 'boolean',
|
||||||
'published_at' => 'datetime',
|
'published_at' => 'datetime',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
protected $appends = [
|
||||||
|
'formatted_lesson_number',
|
||||||
|
'lesson_label',
|
||||||
|
];
|
||||||
|
|
||||||
public function scopeActive(Builder $query): Builder
|
public function scopeActive(Builder $query): Builder
|
||||||
{
|
{
|
||||||
return $query->where('active', true);
|
return $query->where('active', true);
|
||||||
@@ -65,6 +80,17 @@ class AcademyLesson extends Model
|
|||||||
return $query->whereNotNull('published_at')->where('published_at', '<=', now());
|
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
|
public function category(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(AcademyCategory::class, 'category_id');
|
return $this->belongsTo(AcademyCategory::class, 'category_id');
|
||||||
@@ -75,6 +101,23 @@ class AcademyLesson extends Model
|
|||||||
return $this->hasMany(AcademyLessonProgress::class, 'lesson_id');
|
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
|
public function blocks(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(AcademyLessonBlock::class, 'lesson_id')
|
return $this->hasMany(AcademyLessonBlock::class, 'lesson_id')
|
||||||
@@ -86,4 +129,30 @@ class AcademyLesson extends Model
|
|||||||
{
|
{
|
||||||
return $this->blocks()->where('active', true);
|
return $this->blocks()->where('active', true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getFormattedLessonNumberAttribute(): ?string
|
||||||
|
{
|
||||||
|
if (! is_int($this->lesson_number) || $this->lesson_number < 1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return sprintf('Lesson %02d', $this->lesson_number);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getLessonLabelAttribute(): ?string
|
||||||
|
{
|
||||||
|
$formattedLessonNumber = $this->formatted_lesson_number;
|
||||||
|
|
||||||
|
if ($formattedLessonNumber === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$seriesName = trim((string) ($this->series_name ?? ''));
|
||||||
|
|
||||||
|
if ($seriesName === '') {
|
||||||
|
return $formattedLessonNumber;
|
||||||
|
}
|
||||||
|
|
||||||
|
return sprintf('%s · %s', $seriesName, $formattedLessonNumber);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
32
app/Models/AcademyLessonRevision.php
Normal file
32
app/Models/AcademyLessonRevision.php
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class AcademyLessonRevision extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'lesson_id',
|
||||||
|
'user_id',
|
||||||
|
'change_note',
|
||||||
|
'snapshot_json',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'snapshot_json' => 'array',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function lesson(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(AcademyLesson::class, 'lesson_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function user(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'user_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ use App\Models\SocialAccount;
|
|||||||
use App\Models\Conversation;
|
use App\Models\Conversation;
|
||||||
use App\Models\ConversationParticipant;
|
use App\Models\ConversationParticipant;
|
||||||
use App\Models\AcademyBadge;
|
use App\Models\AcademyBadge;
|
||||||
|
use App\Models\AcademyCourseEnrollment;
|
||||||
use App\Models\AcademyChallengeSubmission;
|
use App\Models\AcademyChallengeSubmission;
|
||||||
use App\Models\AcademyLessonProgress;
|
use App\Models\AcademyLessonProgress;
|
||||||
use App\Models\AcademySavedPrompt;
|
use App\Models\AcademySavedPrompt;
|
||||||
@@ -207,6 +208,11 @@ class User extends Authenticatable
|
|||||||
return $this->hasMany(AcademyLessonProgress::class, 'user_id');
|
return $this->hasMany(AcademyLessonProgress::class, 'user_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function academyCourseEnrollments(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(AcademyCourseEnrollment::class, 'user_id');
|
||||||
|
}
|
||||||
|
|
||||||
public function academySavedPrompts(): HasMany
|
public function academySavedPrompts(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(AcademySavedPrompt::class, 'user_id');
|
return $this->hasMany(AcademySavedPrompt::class, 'user_id');
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Services\Academy;
|
namespace App\Services\Academy;
|
||||||
|
|
||||||
|
use App\Models\AcademyCourse;
|
||||||
|
use App\Models\AcademyCourseLesson;
|
||||||
use App\Models\AcademyAiComparisonResult;
|
use App\Models\AcademyAiComparisonResult;
|
||||||
use App\Models\AcademyChallenge;
|
use App\Models\AcademyChallenge;
|
||||||
use App\Models\AcademyLesson;
|
use App\Models\AcademyLesson;
|
||||||
@@ -53,15 +55,31 @@ final class AcademyAccessService
|
|||||||
return $this->canAccessContent($user, (string) $challenge->access_level);
|
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);
|
$accessLevel = trim((string) ($courseLesson->access_override ?: $courseLesson->lesson?->access_level ?: 'free'));
|
||||||
$fullContent = (string) ($lesson->content ?? '');
|
|
||||||
|
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 [
|
return [
|
||||||
'id' => (int) $lesson->id,
|
'id' => (int) $lesson->id,
|
||||||
'title' => (string) $lesson->title,
|
'title' => (string) $lesson->title,
|
||||||
'slug' => (string) $lesson->slug,
|
'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 ?? ''),
|
'excerpt' => (string) ($lesson->excerpt ?? ''),
|
||||||
'content' => ($authorized && $includeFull) ? $fullContent : null,
|
'content' => ($authorized && $includeFull) ? $fullContent : null,
|
||||||
'content_preview' => $authorized ? null : $this->previewText($fullContent, 360),
|
'content_preview' => $authorized ? null : $this->previewText($fullContent, 360),
|
||||||
@@ -70,6 +88,9 @@ final class AcademyAccessService
|
|||||||
'lesson_type' => (string) $lesson->lesson_type,
|
'lesson_type' => (string) $lesson->lesson_type,
|
||||||
'cover_image' => $lesson->cover_image,
|
'cover_image' => $lesson->cover_image,
|
||||||
'cover_image_url' => $this->resolveLessonCoverImageUrl((string) ($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,
|
'video_url' => $authorized ? $lesson->video_url : null,
|
||||||
'reading_minutes' => (int) $lesson->reading_minutes,
|
'reading_minutes' => (int) $lesson->reading_minutes,
|
||||||
'featured' => (bool) $lesson->featured,
|
'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
|
public function promptPayload(AcademyPromptTemplate $prompt, ?User $viewer, bool $includeFull = false): array
|
||||||
{
|
{
|
||||||
$authorized = $this->canAccessPrompt($viewer, $prompt);
|
$authorized = $this->canAccessPrompt($viewer, $prompt);
|
||||||
@@ -106,7 +187,7 @@ final class AcademyAccessService
|
|||||||
'access_level' => (string) $prompt->access_level,
|
'access_level' => (string) $prompt->access_level,
|
||||||
'aspect_ratio' => $prompt->aspect_ratio,
|
'aspect_ratio' => $prompt->aspect_ratio,
|
||||||
'tags' => array_values((array) ($prompt->tags ?? [])),
|
'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 ?? '')),
|
'preview_image' => $this->resolvePreviewImageUrl((string) ($prompt->preview_image ?? '')),
|
||||||
'featured' => (bool) $prompt->featured,
|
'featured' => (bool) $prompt->featured,
|
||||||
'prompt_of_week' => (bool) $prompt->prompt_of_week,
|
'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
|
public function packPayload(AcademyPromptPack $pack, ?User $viewer, bool $includePrompts = false): array
|
||||||
{
|
{
|
||||||
$authorized = $this->canAccessPack($viewer, $pack);
|
$authorized = $this->canAccessPack($viewer, $pack);
|
||||||
@@ -190,6 +313,7 @@ final class AcademyAccessService
|
|||||||
{
|
{
|
||||||
return match (Str::lower(trim($accessLevel))) {
|
return match (Str::lower(trim($accessLevel))) {
|
||||||
'admin' => 99,
|
'admin' => 99,
|
||||||
|
'premium' => 40,
|
||||||
'pro' => 30,
|
'pro' => 30,
|
||||||
'creator' => 20,
|
'creator' => 20,
|
||||||
default => 10,
|
default => 10,
|
||||||
@@ -322,4 +446,44 @@ final class AcademyAccessService
|
|||||||
'comparison_results' => $results,
|
'comparison_results' => $results,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function injectHeadingIds(string $html): string
|
||||||
|
{
|
||||||
|
if ($html === '') {
|
||||||
|
return $html;
|
||||||
|
}
|
||||||
|
|
||||||
|
$seenIds = [];
|
||||||
|
|
||||||
|
return preg_replace_callback(
|
||||||
|
'/<(h[23])([^>]*)>(.*?)<\/\1>/si',
|
||||||
|
function (array $m) use (&$seenIds): string {
|
||||||
|
[, $tag, $attrs, $inner] = $m;
|
||||||
|
|
||||||
|
if (preg_match('/\bid\s*=/i', $attrs)) {
|
||||||
|
return $m[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
$textContent = strip_tags($inner);
|
||||||
|
$baseId = $this->slugifyHeading($textContent);
|
||||||
|
$count = $seenIds[$baseId] ?? 0;
|
||||||
|
$id = $count > 0 ? $baseId.'-'.($count + 1) : $baseId;
|
||||||
|
$seenIds[$baseId] = $count + 1;
|
||||||
|
|
||||||
|
return "<{$tag} id=\"{$id}\"{$attrs}>{$inner}</{$tag}>";
|
||||||
|
},
|
||||||
|
$html
|
||||||
|
) ?? $html;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function slugifyHeading(string $text): string
|
||||||
|
{
|
||||||
|
$slug = mb_strtolower($text);
|
||||||
|
$slug = preg_replace('/[^a-z0-9_\s\-]/', '', $slug) ?? '';
|
||||||
|
$slug = preg_replace('/\s+/', '-', trim($slug)) ?? '';
|
||||||
|
$slug = preg_replace('/-+/', '-', $slug) ?? '';
|
||||||
|
$slug = trim($slug, '-');
|
||||||
|
|
||||||
|
return $slug !== '' ? $slug : 'section';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace App\Services\Academy;
|
namespace App\Services\Academy;
|
||||||
|
|
||||||
use App\Models\AcademyCategory;
|
use App\Models\AcademyCategory;
|
||||||
|
use App\Models\AcademyCourse;
|
||||||
use App\Models\AcademyChallenge;
|
use App\Models\AcademyChallenge;
|
||||||
use App\Models\AcademyLesson;
|
use App\Models\AcademyLesson;
|
||||||
use App\Models\AcademyPromptTemplate;
|
use App\Models\AcademyPromptTemplate;
|
||||||
@@ -14,6 +15,8 @@ final class AcademyCacheService
|
|||||||
{
|
{
|
||||||
private const HOME_KEY = 'academy.home';
|
private const HOME_KEY = 'academy.home';
|
||||||
private const FEATURED_LESSONS_KEY = 'academy.featured_lessons';
|
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_PROMPTS_KEY = 'academy.featured_prompts';
|
||||||
private const FEATURED_CHALLENGES_KEY = 'academy.featured_challenges';
|
private const FEATURED_CHALLENGES_KEY = 'academy.featured_challenges';
|
||||||
private const CATEGORIES_KEY = 'academy.categories';
|
private const CATEGORIES_KEY = 'academy.categories';
|
||||||
@@ -30,12 +33,32 @@ final class AcademyCacheService
|
|||||||
->active()
|
->active()
|
||||||
->published()
|
->published()
|
||||||
->where('featured', true)
|
->where('featured', true)
|
||||||
->latest('published_at')
|
->orderedForCourse()
|
||||||
->limit(6)
|
->limit(6)
|
||||||
->get()
|
->get()
|
||||||
->all());
|
->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
|
public function featuredPrompts(): array
|
||||||
{
|
{
|
||||||
return Cache::remember(self::FEATURED_PROMPTS_KEY, $this->ttl(), static fn (): array => AcademyPromptTemplate::query()
|
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::HOME_KEY);
|
||||||
Cache::forget(self::FEATURED_LESSONS_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_PROMPTS_KEY);
|
||||||
Cache::forget(self::FEATURED_CHALLENGES_KEY);
|
Cache::forget(self::FEATURED_CHALLENGES_KEY);
|
||||||
|
|
||||||
|
|||||||
52
app/Services/Academy/AcademyCourseLessonOrderingService.php
Normal file
52
app/Services/Academy/AcademyCourseLessonOrderingService.php
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services\Academy;
|
||||||
|
|
||||||
|
use App\Models\AcademyCourse;
|
||||||
|
use App\Models\AcademyCourseLesson;
|
||||||
|
|
||||||
|
final class AcademyCourseLessonOrderingService
|
||||||
|
{
|
||||||
|
public function syncCourse(AcademyCourse|int $course): void
|
||||||
|
{
|
||||||
|
$courseId = $course instanceof AcademyCourse ? (int) $course->id : (int) $course;
|
||||||
|
|
||||||
|
if ($courseId < 1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
AcademyCourseLesson::query()
|
||||||
|
->with('lesson:id,lesson_number,course_order')
|
||||||
|
->where('course_id', $courseId)
|
||||||
|
->orderBy('order_num')
|
||||||
|
->orderBy('id')
|
||||||
|
->get()
|
||||||
|
->values()
|
||||||
|
->each(function (AcademyCourseLesson $courseLesson, int $index): void {
|
||||||
|
$pivotOrder = $index;
|
||||||
|
$lessonOrder = $index + 1;
|
||||||
|
|
||||||
|
if ((int) ($courseLesson->order_num ?? 0) !== $pivotOrder) {
|
||||||
|
$courseLesson->forceFill([
|
||||||
|
'order_num' => $pivotOrder,
|
||||||
|
])->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($courseLesson->lesson === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((int) ($courseLesson->lesson->course_order ?? 0) === $lessonOrder
|
||||||
|
&& (int) ($courseLesson->lesson->lesson_number ?? 0) === $lessonOrder) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$courseLesson->lesson->forceFill([
|
||||||
|
'course_order' => $lessonOrder,
|
||||||
|
'lesson_number' => $lessonOrder,
|
||||||
|
])->save();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
72
app/Services/Academy/AcademyCourseNavigationService.php
Normal file
72
app/Services/Academy/AcademyCourseNavigationService.php
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services\Academy;
|
||||||
|
|
||||||
|
use App\Models\AcademyCourse;
|
||||||
|
use App\Models\AcademyCourseLesson;
|
||||||
|
use App\Models\AcademyLesson;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
|
final class AcademyCourseNavigationService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return Collection<int, AcademyCourseLesson>
|
||||||
|
*/
|
||||||
|
public function orderedCourseLessons(AcademyCourse $course): Collection
|
||||||
|
{
|
||||||
|
return $course->courseLessons()
|
||||||
|
->with(['section', 'lesson.category'])
|
||||||
|
->get()
|
||||||
|
->filter(fn (AcademyCourseLesson $courseLesson): bool => $courseLesson->lesson instanceof AcademyLesson
|
||||||
|
&& (bool) $courseLesson->lesson->active
|
||||||
|
&& $courseLesson->lesson->published_at !== null
|
||||||
|
&& $courseLesson->lesson->published_at->lte(now()))
|
||||||
|
->sort(fn (AcademyCourseLesson $left, AcademyCourseLesson $right): int => $this->courseLessonSortKey($left) <=> $this->courseLessonSortKey($right))
|
||||||
|
->values();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, int>
|
||||||
|
*/
|
||||||
|
private function courseLessonSortKey(AcademyCourseLesson $courseLesson): array
|
||||||
|
{
|
||||||
|
$section = $courseLesson->section;
|
||||||
|
|
||||||
|
return [
|
||||||
|
$courseLesson->section_id === null ? 0 : 1,
|
||||||
|
(int) ($section?->order_num ?? 0),
|
||||||
|
(int) ($section?->id ?? 0),
|
||||||
|
(int) ($courseLesson->order_num ?? 0),
|
||||||
|
(int) $courseLesson->id,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function firstPublishedLesson(AcademyCourse $course): ?AcademyCourseLesson
|
||||||
|
{
|
||||||
|
return $this->orderedCourseLessons($course)->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function findCourseLesson(AcademyCourse $course, AcademyLesson $lesson): ?AcademyCourseLesson
|
||||||
|
{
|
||||||
|
return $this->orderedCourseLessons($course)
|
||||||
|
->first(fn (AcademyCourseLesson $courseLesson): bool => $courseLesson->lesson?->is($lesson) ?? false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function nextLesson(AcademyCourse $course, AcademyLesson $lesson): ?AcademyCourseLesson
|
||||||
|
{
|
||||||
|
$ordered = $this->orderedCourseLessons($course);
|
||||||
|
$index = $ordered->search(fn (AcademyCourseLesson $courseLesson): bool => $courseLesson->lesson?->is($lesson) ?? false);
|
||||||
|
|
||||||
|
return is_int($index) ? $ordered->get($index + 1) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function previousLesson(AcademyCourse $course, AcademyLesson $lesson): ?AcademyCourseLesson
|
||||||
|
{
|
||||||
|
$ordered = $this->orderedCourseLessons($course);
|
||||||
|
$index = $ordered->search(fn (AcademyCourseLesson $courseLesson): bool => $courseLesson->lesson?->is($lesson) ?? false);
|
||||||
|
|
||||||
|
return is_int($index) && $index > 0 ? $ordered->get($index - 1) : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
173
app/Services/Academy/AcademyCourseProgressService.php
Normal file
173
app/Services/Academy/AcademyCourseProgressService.php
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services\Academy;
|
||||||
|
|
||||||
|
use App\Models\AcademyCourse;
|
||||||
|
use App\Models\AcademyCourseEnrollment;
|
||||||
|
use App\Models\AcademyCourseLesson;
|
||||||
|
use App\Models\AcademyLesson;
|
||||||
|
use App\Models\AcademyLessonProgress;
|
||||||
|
use App\Models\User;
|
||||||
|
|
||||||
|
final class AcademyCourseProgressService
|
||||||
|
{
|
||||||
|
public function __construct(private readonly AcademyCourseNavigationService $navigation)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getProgress(?User $user, AcademyCourse $course): array
|
||||||
|
{
|
||||||
|
$totalRequired = $this->getTotalRequiredLessonsCount($course);
|
||||||
|
$completedRequired = $user ? $this->getCompletedRequiredLessonsCount($user, $course) : 0;
|
||||||
|
$enrollment = $user
|
||||||
|
? AcademyCourseEnrollment::query()->with('lastLesson')->where('user_id', $user->id)->where('course_id', $course->id)->first()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'total_required' => $totalRequired,
|
||||||
|
'completed_required' => $completedRequired,
|
||||||
|
'progress_percent' => $this->getProgressPercent($user, $course),
|
||||||
|
'enrollment' => $enrollment,
|
||||||
|
'next_lesson' => $user ? $this->getNextLesson($user, $course) : null,
|
||||||
|
'continue_lesson' => $user ? $this->getContinueLesson($user, $course) : null,
|
||||||
|
'completed' => $enrollment?->status === AcademyCourseEnrollment::STATUS_COMPLETED,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCompletedRequiredLessonsCount(User $user, AcademyCourse $course): int
|
||||||
|
{
|
||||||
|
$lessonIds = $course->courseLessons()
|
||||||
|
->where('is_required', true)
|
||||||
|
->pluck('lesson_id')
|
||||||
|
->filter()
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
|
||||||
|
if ($lessonIds === []) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return AcademyLessonProgress::query()
|
||||||
|
->where('user_id', $user->id)
|
||||||
|
->whereIn('lesson_id', $lessonIds)
|
||||||
|
->whereNotNull('completed_at')
|
||||||
|
->count();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCompletedLessonIds(User $user, AcademyCourse $course): array
|
||||||
|
{
|
||||||
|
$lessonIds = $course->courseLessons()
|
||||||
|
->pluck('lesson_id')
|
||||||
|
->filter()
|
||||||
|
->map(fn ($lessonId): int => (int) $lessonId)
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
|
||||||
|
if ($lessonIds === []) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return AcademyLessonProgress::query()
|
||||||
|
->where('user_id', $user->id)
|
||||||
|
->whereIn('lesson_id', $lessonIds)
|
||||||
|
->whereNotNull('completed_at')
|
||||||
|
->pluck('lesson_id')
|
||||||
|
->map(fn ($lessonId): int => (int) $lessonId)
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getTotalRequiredLessonsCount(AcademyCourse $course): int
|
||||||
|
{
|
||||||
|
return $course->courseLessons()->where('is_required', true)->count();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getProgressPercent(?User $user, AcademyCourse $course): int
|
||||||
|
{
|
||||||
|
if (! $user) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$totalRequired = $this->getTotalRequiredLessonsCount($course);
|
||||||
|
|
||||||
|
if ($totalRequired < 1) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (int) floor(($this->getCompletedRequiredLessonsCount($user, $course) / $totalRequired) * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getNextLesson(User $user, AcademyCourse $course): ?AcademyCourseLesson
|
||||||
|
{
|
||||||
|
$completedLessonIds = $this->getCompletedLessonIds($user, $course);
|
||||||
|
|
||||||
|
return $this->navigation->orderedCourseLessons($course)
|
||||||
|
->first(fn (AcademyCourseLesson $courseLesson): bool => ! in_array((int) $courseLesson->lesson_id, $completedLessonIds, true));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getPreviousLesson(AcademyCourse $course, AcademyLesson $lesson): ?AcademyCourseLesson
|
||||||
|
{
|
||||||
|
return $this->navigation->previousLesson($course, $lesson);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getContinueLesson(User $user, AcademyCourse $course): ?AcademyCourseLesson
|
||||||
|
{
|
||||||
|
$enrollment = AcademyCourseEnrollment::query()
|
||||||
|
->where('user_id', $user->id)
|
||||||
|
->where('course_id', $course->id)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($enrollment?->last_lesson_id) {
|
||||||
|
$lastLesson = AcademyLesson::query()->find($enrollment->last_lesson_id);
|
||||||
|
|
||||||
|
if ($lastLesson instanceof AcademyLesson) {
|
||||||
|
$nextLesson = $this->navigation->nextLesson($course, $lastLesson);
|
||||||
|
|
||||||
|
return $nextLesson ?? $this->navigation->findCourseLesson($course, $lastLesson);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->getNextLesson($user, $course) ?? $this->navigation->firstPublishedLesson($course);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function markEnrollmentStarted(User $user, AcademyCourse $course): AcademyCourseEnrollment
|
||||||
|
{
|
||||||
|
return AcademyCourseEnrollment::query()->updateOrCreate(
|
||||||
|
[
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'course_id' => $course->id,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'status' => AcademyCourseEnrollment::STATUS_ACTIVE,
|
||||||
|
'started_at' => now(),
|
||||||
|
'completed_at' => null,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateLastLesson(User $user, AcademyCourse $course, AcademyLesson $lesson): AcademyCourseEnrollment
|
||||||
|
{
|
||||||
|
$enrollment = $this->markEnrollmentStarted($user, $course);
|
||||||
|
$enrollment->forceFill([
|
||||||
|
'last_lesson_id' => $lesson->id,
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
return $enrollment;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function markCourseCompletedIfFinished(User $user, AcademyCourse $course): AcademyCourseEnrollment
|
||||||
|
{
|
||||||
|
$enrollment = $this->markEnrollmentStarted($user, $course);
|
||||||
|
$progressPercent = $this->getProgressPercent($user, $course);
|
||||||
|
$isComplete = $this->getTotalRequiredLessonsCount($course) > 0 && $progressPercent >= 100;
|
||||||
|
|
||||||
|
$enrollment->forceFill([
|
||||||
|
'status' => $isComplete ? AcademyCourseEnrollment::STATUS_COMPLETED : AcademyCourseEnrollment::STATUS_ACTIVE,
|
||||||
|
'completed_at' => $isComplete ? ($enrollment->completed_at ?? now()) : null,
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
return $enrollment;
|
||||||
|
}
|
||||||
|
}
|
||||||
42
app/Services/Academy/AcademyLessonMarkdownRenderer.php
Normal file
42
app/Services/Academy/AcademyLessonMarkdownRenderer.php
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services\Academy;
|
||||||
|
|
||||||
|
use League\CommonMark\Environment\Environment;
|
||||||
|
use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension;
|
||||||
|
use League\CommonMark\Extension\GithubFlavoredMarkdownExtension;
|
||||||
|
use League\CommonMark\MarkdownConverter;
|
||||||
|
|
||||||
|
final class AcademyLessonMarkdownRenderer
|
||||||
|
{
|
||||||
|
private ?MarkdownConverter $converter = null;
|
||||||
|
|
||||||
|
public function render(?string $markdown): string
|
||||||
|
{
|
||||||
|
$trimmed = trim((string) $markdown);
|
||||||
|
|
||||||
|
if ($trimmed === '') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return trim((string) $this->converter()->convert($trimmed)->getContent());
|
||||||
|
}
|
||||||
|
|
||||||
|
private function converter(): MarkdownConverter
|
||||||
|
{
|
||||||
|
if ($this->converter instanceof MarkdownConverter) {
|
||||||
|
return $this->converter;
|
||||||
|
}
|
||||||
|
|
||||||
|
$environment = new Environment([
|
||||||
|
'html_input' => 'strip',
|
||||||
|
'allow_unsafe_links' => false,
|
||||||
|
]);
|
||||||
|
$environment->addExtension(new CommonMarkCoreExtension());
|
||||||
|
$environment->addExtension(new GithubFlavoredMarkdownExtension());
|
||||||
|
|
||||||
|
return $this->converter = new MarkdownConverter($environment);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Services\Academy;
|
namespace App\Services\Academy;
|
||||||
|
|
||||||
|
use App\Models\AcademyCourse;
|
||||||
use App\Models\AcademyLesson;
|
use App\Models\AcademyLesson;
|
||||||
use App\Models\AcademyLessonProgress;
|
use App\Models\AcademyLessonProgress;
|
||||||
use App\Models\AcademyPromptTemplate;
|
use App\Models\AcademyPromptTemplate;
|
||||||
@@ -12,11 +13,13 @@ use App\Models\User;
|
|||||||
|
|
||||||
final class AcademyProgressService
|
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(
|
$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);
|
$this->badges->syncForUser($user);
|
||||||
|
|
||||||
return $progress;
|
return $progress;
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services\Sitemaps\Builders;
|
||||||
|
|
||||||
|
use App\Models\AcademyCourse;
|
||||||
|
use App\Services\Sitemaps\AbstractSitemapBuilder;
|
||||||
|
use App\Services\Sitemaps\SitemapUrl;
|
||||||
|
use App\Services\Sitemaps\SitemapUrlBuilder;
|
||||||
|
use DateTimeInterface;
|
||||||
|
|
||||||
|
final class AcademyCoursesSitemapBuilder extends AbstractSitemapBuilder
|
||||||
|
{
|
||||||
|
public function __construct(private readonly SitemapUrlBuilder $urls)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function name(): string
|
||||||
|
{
|
||||||
|
return 'academy-courses';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function items(): array
|
||||||
|
{
|
||||||
|
if (! (bool) config('academy.enabled', true)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$items = [$this->urls->staticRoute('/academy/courses')];
|
||||||
|
|
||||||
|
$details = AcademyCourse::query()
|
||||||
|
->published()
|
||||||
|
->orderBy('id')
|
||||||
|
->cursor()
|
||||||
|
->map(fn (AcademyCourse $course): SitemapUrl => $this->urls->staticRoute('/academy/courses/' . $course->slug, $course->updated_at ?? $course->published_at))
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
|
||||||
|
return array_merge($items, $details);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function lastModified(): ?DateTimeInterface
|
||||||
|
{
|
||||||
|
return $this->dateTime(AcademyCourse::query()->published()->max('updated_at'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ namespace App\Services\Sitemaps;
|
|||||||
|
|
||||||
use App\Services\Sitemaps\Builders\ArtworksSitemapBuilder;
|
use App\Services\Sitemaps\Builders\ArtworksSitemapBuilder;
|
||||||
use App\Services\Sitemaps\Builders\AcademyChallengesSitemapBuilder;
|
use App\Services\Sitemaps\Builders\AcademyChallengesSitemapBuilder;
|
||||||
|
use App\Services\Sitemaps\Builders\AcademyCoursesSitemapBuilder;
|
||||||
use App\Services\Sitemaps\Builders\AcademyLessonsSitemapBuilder;
|
use App\Services\Sitemaps\Builders\AcademyLessonsSitemapBuilder;
|
||||||
use App\Services\Sitemaps\Builders\AcademyPacksSitemapBuilder;
|
use App\Services\Sitemaps\Builders\AcademyPacksSitemapBuilder;
|
||||||
use App\Services\Sitemaps\Builders\AcademyPromptsSitemapBuilder;
|
use App\Services\Sitemaps\Builders\AcademyPromptsSitemapBuilder;
|
||||||
@@ -31,6 +32,7 @@ final class SitemapRegistry
|
|||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
ArtworksSitemapBuilder $artworks,
|
ArtworksSitemapBuilder $artworks,
|
||||||
|
AcademyCoursesSitemapBuilder $academyCourses,
|
||||||
AcademyLessonsSitemapBuilder $academyLessons,
|
AcademyLessonsSitemapBuilder $academyLessons,
|
||||||
AcademyPromptsSitemapBuilder $academyPrompts,
|
AcademyPromptsSitemapBuilder $academyPrompts,
|
||||||
AcademyPacksSitemapBuilder $academyPacks,
|
AcademyPacksSitemapBuilder $academyPacks,
|
||||||
@@ -50,6 +52,7 @@ final class SitemapRegistry
|
|||||||
) {
|
) {
|
||||||
$this->builders = [
|
$this->builders = [
|
||||||
$artworks->name() => $artworks,
|
$artworks->name() => $artworks,
|
||||||
|
$academyCourses->name() => $academyCourses,
|
||||||
$academyLessons->name() => $academyLessons,
|
$academyLessons->name() => $academyLessons,
|
||||||
$academyPrompts->name() => $academyPrompts,
|
$academyPrompts->name() => $academyPrompts,
|
||||||
$academyPacks->name() => $academyPacks,
|
$academyPacks->name() => $academyPacks,
|
||||||
|
|||||||
@@ -42,8 +42,9 @@ final class SeoFactory
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string, array<string, mixed>|null> $thumbs
|
* @param array<string, array<string, mixed>|null> $thumbs
|
||||||
|
* @param array<int, array{name: string, url: string}>|iterable<mixed> $breadcrumbs
|
||||||
*/
|
*/
|
||||||
public function artwork(Artwork $artwork, array $thumbs, string $canonical): SeoData
|
public function artwork(Artwork $artwork, array $thumbs, string $canonical, iterable $breadcrumbs = []): SeoData
|
||||||
{
|
{
|
||||||
$authorName = html_entity_decode((string) ($artwork->user?->name ?: $artwork->user?->username ?: 'Artist'), ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
$authorName = html_entity_decode((string) ($artwork->user?->name ?: $artwork->user?->username ?: 'Artist'), ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||||
$title = html_entity_decode((string) ($artwork->title ?: 'Artwork'), ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
$title = html_entity_decode((string) ($artwork->title ?: 'Artwork'), ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||||
@@ -51,6 +52,9 @@ final class SeoFactory
|
|||||||
$description = Str::limit($description !== '' ? $description : $title, 160, '…');
|
$description = Str::limit($description !== '' ? $description : $title, 160, '…');
|
||||||
$image = $thumbs['xl']['url'] ?? $thumbs['lg']['url'] ?? $thumbs['md']['url'] ?? null;
|
$image = $thumbs['xl']['url'] ?? $thumbs['lg']['url'] ?? $thumbs['md']['url'] ?? null;
|
||||||
$keywords = $artwork->tags->pluck('name')->filter()->unique()->values()->all();
|
$keywords = $artwork->tags->pluck('name')->filter()->unique()->values()->all();
|
||||||
|
$licenseUrl = $this->clean((string) ($artwork->license_url ?? ''));
|
||||||
|
$publisherName = (string) config('seo.site_name', 'Skinbase');
|
||||||
|
$publisherUrl = url('/');
|
||||||
|
|
||||||
$imageWidth = $thumbs['xl']['width'] ?? $thumbs['lg']['width'] ?? null;
|
$imageWidth = $thumbs['xl']['width'] ?? $thumbs['lg']['width'] ?? null;
|
||||||
$imageHeight = $thumbs['xl']['height'] ?? $thumbs['lg']['height'] ?? null;
|
$imageHeight = $thumbs['xl']['height'] ?? $thumbs['lg']['height'] ?? null;
|
||||||
@@ -60,6 +64,7 @@ final class SeoFactory
|
|||||||
->description($description)
|
->description($description)
|
||||||
->keywords($keywords)
|
->keywords($keywords)
|
||||||
->canonical($canonical)
|
->canonical($canonical)
|
||||||
|
->breadcrumbs($breadcrumbs)
|
||||||
->og(type: 'article', image: $image, imageAlt: sprintf('%s by %s', $title, $authorName), imageWidth: is_int($imageWidth) ? $imageWidth : null, imageHeight: is_int($imageHeight) ? $imageHeight : null)
|
->og(type: 'article', image: $image, imageAlt: sprintf('%s by %s', $title, $authorName), imageWidth: is_int($imageWidth) ? $imageWidth : null, imageHeight: is_int($imageHeight) ? $imageHeight : null)
|
||||||
->addJsonLd(array_filter([
|
->addJsonLd(array_filter([
|
||||||
'@context' => 'https://schema.org',
|
'@context' => 'https://schema.org',
|
||||||
@@ -73,9 +78,13 @@ final class SeoFactory
|
|||||||
'width' => $thumbs['xl']['width'] ?? $thumbs['lg']['width'] ?? null,
|
'width' => $thumbs['xl']['width'] ?? $thumbs['lg']['width'] ?? null,
|
||||||
'height' => $thumbs['xl']['height'] ?? $thumbs['lg']['height'] ?? null,
|
'height' => $thumbs['xl']['height'] ?? $thumbs['lg']['height'] ?? null,
|
||||||
'author' => ['@type' => 'Person', 'name' => $authorName],
|
'author' => ['@type' => 'Person', 'name' => $authorName],
|
||||||
|
'creator' => ['@type' => 'Person', 'name' => $authorName],
|
||||||
|
'publisher' => ['@type' => 'Organization', 'name' => $publisherName, 'url' => $publisherUrl],
|
||||||
|
'creditText' => $authorName,
|
||||||
'datePublished' => optional($artwork->published_at)->toAtomString(),
|
'datePublished' => optional($artwork->published_at)->toAtomString(),
|
||||||
'license' => $artwork->license_url,
|
'license' => $licenseUrl,
|
||||||
'keywords' => $keywords !== [] ? $keywords : null,
|
'keywords' => $keywords !== [] ? $keywords : null,
|
||||||
|
'representativeOfPage' => true,
|
||||||
], fn (mixed $value): bool => $value !== null && $value !== '' && $value !== []))
|
], fn (mixed $value): bool => $value !== null && $value !== '' && $value !== []))
|
||||||
->addJsonLd(array_filter([
|
->addJsonLd(array_filter([
|
||||||
'@context' => 'https://schema.org',
|
'@context' => 'https://schema.org',
|
||||||
@@ -84,14 +93,257 @@ final class SeoFactory
|
|||||||
'description' => $description,
|
'description' => $description,
|
||||||
'url' => $canonical,
|
'url' => $canonical,
|
||||||
'author' => ['@type' => 'Person', 'name' => $authorName],
|
'author' => ['@type' => 'Person', 'name' => $authorName],
|
||||||
|
'publisher' => ['@type' => 'Organization', 'name' => $publisherName, 'url' => $publisherUrl],
|
||||||
'datePublished' => optional($artwork->published_at)->toAtomString(),
|
'datePublished' => optional($artwork->published_at)->toAtomString(),
|
||||||
'license' => $artwork->license_url,
|
'license' => $licenseUrl,
|
||||||
'keywords' => $keywords !== [] ? $keywords : null,
|
'keywords' => $keywords !== [] ? $keywords : null,
|
||||||
'image' => $image,
|
'image' => $image,
|
||||||
], fn (mixed $value): bool => $value !== null && $value !== '' && $value !== []))
|
], fn (mixed $value): bool => $value !== null && $value !== '' && $value !== []))
|
||||||
->build();
|
->build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, array{name: string, url: string}>|iterable<mixed> $breadcrumbs
|
||||||
|
* @param array<int, string> $keywords
|
||||||
|
*/
|
||||||
|
public function academyLessonPage(
|
||||||
|
string $title,
|
||||||
|
string $description,
|
||||||
|
string $canonical,
|
||||||
|
?string $image = null,
|
||||||
|
iterable $breadcrumbs = [],
|
||||||
|
array $keywords = [],
|
||||||
|
?string $publishedAt = null,
|
||||||
|
?string $modifiedAt = null,
|
||||||
|
?string $articleSection = null,
|
||||||
|
): SeoData {
|
||||||
|
$publisherName = (string) config('seo.site_name', 'Skinbase');
|
||||||
|
$publisherUrl = url('/');
|
||||||
|
$licenseUrl = route('terms-of-service');
|
||||||
|
$imageUrl = $this->normalizeUrl($image);
|
||||||
|
|
||||||
|
$builder = SeoDataBuilder::make()
|
||||||
|
->title($title)
|
||||||
|
->description($description)
|
||||||
|
->keywords($keywords)
|
||||||
|
->canonical($canonical)
|
||||||
|
->breadcrumbs($breadcrumbs)
|
||||||
|
->og(type: 'article', image: $imageUrl, imageAlt: $title);
|
||||||
|
|
||||||
|
if ($imageUrl !== null) {
|
||||||
|
$builder->addJsonLd(array_filter([
|
||||||
|
'@context' => 'https://schema.org',
|
||||||
|
'@type' => 'ImageObject',
|
||||||
|
'name' => $title,
|
||||||
|
'description' => $description,
|
||||||
|
'url' => $canonical,
|
||||||
|
'contentUrl' => $imageUrl,
|
||||||
|
'thumbnailUrl' => $imageUrl,
|
||||||
|
'license' => $licenseUrl,
|
||||||
|
'acquireLicensePage' => $licenseUrl,
|
||||||
|
'creditText' => $publisherName,
|
||||||
|
'creator' => [
|
||||||
|
'@type' => 'Organization',
|
||||||
|
'name' => $publisherName,
|
||||||
|
'url' => $publisherUrl,
|
||||||
|
],
|
||||||
|
'publisher' => [
|
||||||
|
'@type' => 'Organization',
|
||||||
|
'name' => $publisherName,
|
||||||
|
'url' => $publisherUrl,
|
||||||
|
],
|
||||||
|
'datePublished' => $publishedAt,
|
||||||
|
'keywords' => $keywords !== [] ? $keywords : null,
|
||||||
|
], fn (mixed $value): bool => $value !== null && $value !== '' && $value !== []));
|
||||||
|
}
|
||||||
|
|
||||||
|
$builder->addJsonLd(array_filter([
|
||||||
|
'@context' => 'https://schema.org',
|
||||||
|
'@type' => 'Article',
|
||||||
|
'headline' => $title,
|
||||||
|
'description' => $description,
|
||||||
|
'url' => $canonical,
|
||||||
|
'mainEntityOfPage' => $canonical,
|
||||||
|
'datePublished' => $publishedAt,
|
||||||
|
'dateModified' => $modifiedAt ?? $publishedAt,
|
||||||
|
'author' => [
|
||||||
|
'@type' => 'Organization',
|
||||||
|
'name' => $publisherName,
|
||||||
|
'url' => $publisherUrl,
|
||||||
|
],
|
||||||
|
'publisher' => [
|
||||||
|
'@type' => 'Organization',
|
||||||
|
'name' => $publisherName,
|
||||||
|
'url' => $publisherUrl,
|
||||||
|
],
|
||||||
|
'articleSection' => $articleSection,
|
||||||
|
'keywords' => $keywords !== [] ? $keywords : null,
|
||||||
|
'image' => $imageUrl,
|
||||||
|
], fn (mixed $value): bool => $value !== null && $value !== '' && $value !== []));
|
||||||
|
|
||||||
|
return $builder->build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param iterable<int, array<string, mixed>> $courses
|
||||||
|
* @param array<int, array{name: string, url: string}>|iterable<mixed> $breadcrumbs
|
||||||
|
*/
|
||||||
|
public function academyCourseListingPage(
|
||||||
|
string $title,
|
||||||
|
string $description,
|
||||||
|
string $canonical,
|
||||||
|
iterable $courses,
|
||||||
|
iterable $breadcrumbs = [],
|
||||||
|
?string $image = null,
|
||||||
|
): SeoData {
|
||||||
|
$publisherName = (string) config('seo.site_name', 'Skinbase');
|
||||||
|
$publisherUrl = url('/');
|
||||||
|
$imageUrl = $this->normalizeUrl($image);
|
||||||
|
|
||||||
|
return SeoDataBuilder::make()
|
||||||
|
->title($title)
|
||||||
|
->description($description)
|
||||||
|
->canonical($canonical)
|
||||||
|
->breadcrumbs($breadcrumbs)
|
||||||
|
->og(type: 'website', image: $imageUrl)
|
||||||
|
->addJsonLd(array_filter([
|
||||||
|
'@context' => 'https://schema.org',
|
||||||
|
'@type' => 'CollectionPage',
|
||||||
|
'name' => $title,
|
||||||
|
'description' => $description,
|
||||||
|
'url' => $canonical,
|
||||||
|
'mainEntity' => [
|
||||||
|
'@type' => 'ItemList',
|
||||||
|
'itemListElement' => collect($courses)
|
||||||
|
->values()
|
||||||
|
->map(function (array $course, int $index) use ($publisherName, $publisherUrl): array {
|
||||||
|
$courseImage = $this->normalizeUrl((string) ($course['cover_image_url'] ?? $course['teaser_image_url'] ?? $course['cover_image'] ?? $course['teaser_image'] ?? ''));
|
||||||
|
|
||||||
|
return array_filter([
|
||||||
|
'@type' => 'ListItem',
|
||||||
|
'position' => $index + 1,
|
||||||
|
'item' => array_filter([
|
||||||
|
'@type' => 'Course',
|
||||||
|
'name' => $this->clean((string) ($course['title'] ?? '')),
|
||||||
|
'description' => $this->clean((string) ($course['excerpt'] ?? $course['description'] ?? '')),
|
||||||
|
'url' => $this->clean((string) ($course['public_url'] ?? '')),
|
||||||
|
'image' => $courseImage,
|
||||||
|
'provider' => [
|
||||||
|
'@type' => 'Organization',
|
||||||
|
'name' => $publisherName,
|
||||||
|
'url' => $publisherUrl,
|
||||||
|
],
|
||||||
|
'isAccessibleForFree' => $this->academyCourseAccessibleForFree($course['access_level'] ?? null),
|
||||||
|
'educationalLevel' => $this->academyCourseEducationalLevel($course['difficulty'] ?? null),
|
||||||
|
'timeRequired' => $this->academyDuration((int) ($course['estimated_minutes'] ?? 0)),
|
||||||
|
], fn (mixed $value): bool => $value !== null && $value !== '' && $value !== []),
|
||||||
|
], fn (mixed $value): bool => $value !== null && $value !== '' && $value !== []);
|
||||||
|
})
|
||||||
|
->filter(fn (array $item): bool => data_get($item, 'item.name') !== null && data_get($item, 'item.url') !== null)
|
||||||
|
->values()
|
||||||
|
->all(),
|
||||||
|
],
|
||||||
|
], fn (mixed $value): bool => $value !== null && $value !== '' && $value !== []))
|
||||||
|
->build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, array{name: string, url: string}>|iterable<mixed> $breadcrumbs
|
||||||
|
* @param array<int, string> $keywords
|
||||||
|
* @param iterable<int, array<string, mixed>> $lessons
|
||||||
|
*/
|
||||||
|
public function academyCoursePage(
|
||||||
|
string $title,
|
||||||
|
string $description,
|
||||||
|
string $canonical,
|
||||||
|
?string $image = null,
|
||||||
|
iterable $breadcrumbs = [],
|
||||||
|
array $keywords = [],
|
||||||
|
?string $publishedAt = null,
|
||||||
|
?string $modifiedAt = null,
|
||||||
|
?string $accessLevel = null,
|
||||||
|
?string $difficulty = null,
|
||||||
|
?int $estimatedMinutes = null,
|
||||||
|
iterable $lessons = [],
|
||||||
|
): SeoData {
|
||||||
|
$publisherName = (string) config('seo.site_name', 'Skinbase');
|
||||||
|
$publisherUrl = url('/');
|
||||||
|
$licenseUrl = route('terms-of-service');
|
||||||
|
$imageUrl = $this->normalizeUrl($image);
|
||||||
|
|
||||||
|
$builder = SeoDataBuilder::make()
|
||||||
|
->title($title)
|
||||||
|
->description($description)
|
||||||
|
->keywords($keywords)
|
||||||
|
->canonical($canonical)
|
||||||
|
->breadcrumbs($breadcrumbs)
|
||||||
|
->og(type: 'website', image: $imageUrl, imageAlt: $title);
|
||||||
|
|
||||||
|
if ($imageUrl !== null) {
|
||||||
|
$builder->addJsonLd(array_filter([
|
||||||
|
'@context' => 'https://schema.org',
|
||||||
|
'@type' => 'ImageObject',
|
||||||
|
'name' => $title,
|
||||||
|
'description' => $description,
|
||||||
|
'url' => $canonical,
|
||||||
|
'contentUrl' => $imageUrl,
|
||||||
|
'thumbnailUrl' => $imageUrl,
|
||||||
|
'license' => $licenseUrl,
|
||||||
|
'acquireLicensePage' => $licenseUrl,
|
||||||
|
'creditText' => $publisherName,
|
||||||
|
'creator' => [
|
||||||
|
'@type' => 'Organization',
|
||||||
|
'name' => $publisherName,
|
||||||
|
'url' => $publisherUrl,
|
||||||
|
],
|
||||||
|
'publisher' => [
|
||||||
|
'@type' => 'Organization',
|
||||||
|
'name' => $publisherName,
|
||||||
|
'url' => $publisherUrl,
|
||||||
|
],
|
||||||
|
'representativeOfPage' => true,
|
||||||
|
'datePublished' => $publishedAt,
|
||||||
|
'keywords' => $keywords !== [] ? $keywords : null,
|
||||||
|
], fn (mixed $value): bool => $value !== null && $value !== '' && $value !== []));
|
||||||
|
}
|
||||||
|
|
||||||
|
$builder->addJsonLd(array_filter([
|
||||||
|
'@context' => 'https://schema.org',
|
||||||
|
'@type' => 'Course',
|
||||||
|
'name' => $title,
|
||||||
|
'description' => $description,
|
||||||
|
'url' => $canonical,
|
||||||
|
'image' => $imageUrl,
|
||||||
|
'provider' => [
|
||||||
|
'@type' => 'Organization',
|
||||||
|
'name' => $publisherName,
|
||||||
|
'url' => $publisherUrl,
|
||||||
|
],
|
||||||
|
'isAccessibleForFree' => $this->academyCourseAccessibleForFree($accessLevel),
|
||||||
|
'educationalLevel' => $this->academyCourseEducationalLevel($difficulty),
|
||||||
|
'timeRequired' => $this->academyDuration($estimatedMinutes),
|
||||||
|
'datePublished' => $publishedAt,
|
||||||
|
'dateModified' => $modifiedAt ?? $publishedAt,
|
||||||
|
'keywords' => $keywords !== [] ? $keywords : null,
|
||||||
|
'hasCourseInstance' => collect($lessons)
|
||||||
|
->values()
|
||||||
|
->map(function (array $lesson, int $index): array {
|
||||||
|
return array_filter([
|
||||||
|
'@type' => 'CourseInstance',
|
||||||
|
'name' => $this->clean((string) ($lesson['title'] ?? '')),
|
||||||
|
'url' => $this->clean((string) ($lesson['course_url'] ?? '')),
|
||||||
|
'courseMode' => 'online',
|
||||||
|
'position' => $index + 1,
|
||||||
|
], fn (mixed $value): bool => $value !== null && $value !== '' && $value !== []);
|
||||||
|
})
|
||||||
|
->filter(fn (array $lesson): bool => ($lesson['name'] ?? null) !== null && ($lesson['url'] ?? null) !== null)
|
||||||
|
->values()
|
||||||
|
->all(),
|
||||||
|
], fn (mixed $value): bool => $value !== null && $value !== '' && $value !== []));
|
||||||
|
|
||||||
|
return $builder->build();
|
||||||
|
}
|
||||||
|
|
||||||
public function profilePage(string $title, string $canonical, string $description, ?string $image = null, iterable $breadcrumbs = []): SeoData
|
public function profilePage(string $title, string $canonical, string $description, ?string $image = null, iterable $breadcrumbs = []): SeoData
|
||||||
{
|
{
|
||||||
$profileName = trim(str_replace([' Gallery on Skinbase', ' on Skinbase'], '', $title));
|
$profileName = trim(str_replace([' Gallery on Skinbase', ' on Skinbase'], '', $title));
|
||||||
@@ -129,6 +381,58 @@ final class SeoFactory
|
|||||||
->build();
|
->build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function normalizeUrl(?string $url): ?string
|
||||||
|
{
|
||||||
|
$url = $this->clean($url);
|
||||||
|
|
||||||
|
if ($url === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match('/^https?:\/\//i', $url) === 1) {
|
||||||
|
return $url;
|
||||||
|
}
|
||||||
|
|
||||||
|
return url($url);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function clean(?string $value): ?string
|
||||||
|
{
|
||||||
|
$value = trim((string) $value);
|
||||||
|
|
||||||
|
return $value === '' ? null : $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function academyCourseAccessibleForFree(mixed $accessLevel): ?bool
|
||||||
|
{
|
||||||
|
$access = strtolower(trim((string) $accessLevel));
|
||||||
|
|
||||||
|
if ($access === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $access === 'free';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function academyCourseEducationalLevel(mixed $difficulty): ?string
|
||||||
|
{
|
||||||
|
$value = strtolower(trim((string) $difficulty));
|
||||||
|
|
||||||
|
return match ($value) {
|
||||||
|
'beginner' => 'Beginner',
|
||||||
|
'intermediate' => 'Intermediate',
|
||||||
|
'advanced' => 'Advanced',
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function academyDuration(?int $minutes): ?string
|
||||||
|
{
|
||||||
|
$minutes = (int) ($minutes ?? 0);
|
||||||
|
|
||||||
|
return $minutes > 0 ? sprintf('PT%dM', $minutes) : null;
|
||||||
|
}
|
||||||
|
|
||||||
public function collectionPage(string $title, string $description, string $canonical, ?string $image = null, bool $indexable = true): SeoData
|
public function collectionPage(string $title, string $description, string $canonical, ?string $image = null, bool $indexable = true): SeoData
|
||||||
{
|
{
|
||||||
return SeoDataBuilder::make()
|
return SeoDataBuilder::make()
|
||||||
@@ -229,48 +533,149 @@ final class SeoFactory
|
|||||||
->values()
|
->values()
|
||||||
->map(function (mixed $artwork, int $index): ?array {
|
->map(function (mixed $artwork, int $index): ?array {
|
||||||
$name = trim((string) (data_get($artwork, 'name') ?? data_get($artwork, 'title') ?? ''));
|
$name = trim((string) (data_get($artwork, 'name') ?? data_get($artwork, 'title') ?? ''));
|
||||||
$url = data_get($artwork, 'url');
|
$url = $this->galleryArtworkUrl($artwork);
|
||||||
|
|
||||||
if (! filled($url)) {
|
|
||||||
$slug = data_get($artwork, 'slug');
|
|
||||||
$id = data_get($artwork, 'id');
|
|
||||||
if (filled($slug) && filled($id)) {
|
|
||||||
$url = route('art.show', ['id' => $id, 'slug' => $slug]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($name === '' && ! filled($url)) {
|
if ($name === '' && ! filled($url)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$thumbnailUrl = $this->normalizeUrl((string) (data_get($artwork, 'thumb_url') ?? ''));
|
||||||
|
$contentUrl = $this->galleryContentUrl($artwork, $thumbnailUrl);
|
||||||
|
$creatorName = $this->clean((string) (data_get($artwork, 'publisher.name') ?? data_get($artwork, 'uname') ?? data_get($artwork, 'username') ?? ''));
|
||||||
|
$creatorUrl = $this->normalizeUrl((string) (data_get($artwork, 'publisher.profile_url') ?? data_get($artwork, 'profile_url') ?? ''));
|
||||||
|
$creatorType = data_get($artwork, 'published_as_type') === 'group' || data_get($artwork, 'publisher.type') === 'group'
|
||||||
|
? 'Organization'
|
||||||
|
: 'Person';
|
||||||
|
$publishedAt = data_get($artwork, 'published_at');
|
||||||
|
$publishedAt = $publishedAt instanceof \DateTimeInterface ? $publishedAt->format(DATE_ATOM) : $this->clean(is_string($publishedAt) ? $publishedAt : null);
|
||||||
|
$width = data_get($artwork, 'width');
|
||||||
|
$height = data_get($artwork, 'height');
|
||||||
|
$genre = $this->clean((string) (data_get($artwork, 'content_type_name') ?? data_get($artwork, 'category_name') ?? ''));
|
||||||
|
$imageFormat = $this->galleryImageFormat($thumbnailUrl ?? $contentUrl);
|
||||||
|
|
||||||
return array_filter([
|
return array_filter([
|
||||||
'@type' => 'ListItem',
|
'@type' => 'ListItem',
|
||||||
'position' => $index + 1,
|
'position' => $index + 1,
|
||||||
|
'url' => filled($url) ? $url : null,
|
||||||
|
'item' => array_filter([
|
||||||
|
'@type' => 'VisualArtwork',
|
||||||
|
'@id' => filled($url) ? $url . '#artwork' : null,
|
||||||
'name' => $name !== '' ? $name : null,
|
'name' => $name !== '' ? $name : null,
|
||||||
'url' => filled($url) ? url((string) $url) : null,
|
'url' => filled($url) ? $url : null,
|
||||||
|
'image' => $thumbnailUrl,
|
||||||
|
'thumbnailUrl' => $thumbnailUrl,
|
||||||
|
'contentUrl' => $contentUrl,
|
||||||
|
'creator' => $creatorName !== null ? array_filter([
|
||||||
|
'@type' => $creatorType,
|
||||||
|
'name' => $creatorName,
|
||||||
|
'url' => $creatorUrl,
|
||||||
|
], fn (mixed $value): bool => $value !== null && $value !== '') : null,
|
||||||
|
'datePublished' => $publishedAt,
|
||||||
|
'genre' => $genre,
|
||||||
|
'encodingFormat' => $imageFormat,
|
||||||
|
'width' => is_numeric($width) ? (int) $width : null,
|
||||||
|
'height' => is_numeric($height) ? (int) $height : null,
|
||||||
|
], fn (mixed $value): bool => $value !== null && $value !== '' && $value !== []),
|
||||||
], fn (mixed $value): bool => $value !== null && $value !== '');
|
], fn (mixed $value): bool => $value !== null && $value !== '');
|
||||||
})
|
})
|
||||||
->filter()
|
->filter()
|
||||||
->values()
|
->values()
|
||||||
->all();
|
->all();
|
||||||
|
|
||||||
if ($itemListElement === []) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$count = $artworks instanceof AbstractPaginator ? $artworks->total() : count($itemListElement);
|
$count = $artworks instanceof AbstractPaginator ? $artworks->total() : count($itemListElement);
|
||||||
|
$name = (string) ($data['page_title'] ?? $data['hero_title'] ?? config('seo.default_title', 'Skinbase'));
|
||||||
|
$description = (string) ($data['page_meta_description'] ?? $data['hero_description'] ?? config('seo.default_description'));
|
||||||
|
$canonical = (string) ($data['page_canonical'] ?? url()->current());
|
||||||
|
$about = collect(preg_split('/\s*,\s*/', (string) ($data['page_meta_keywords'] ?? ''), -1, PREG_SPLIT_NO_EMPTY) ?: [])
|
||||||
|
->map(fn (string $keyword): string => trim($keyword))
|
||||||
|
->filter()
|
||||||
|
->take(5)
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'@context' => 'https://schema.org',
|
'@context' => 'https://schema.org',
|
||||||
'@type' => 'CollectionPage',
|
'@type' => ['CollectionPage', 'ImageGallery'],
|
||||||
'name' => (string) ($data['page_title'] ?? $data['hero_title'] ?? config('seo.default_title', 'Skinbase')),
|
'name' => $name,
|
||||||
'description' => (string) ($data['page_meta_description'] ?? $data['hero_description'] ?? config('seo.default_description')),
|
'headline' => (string) ($data['hero_title'] ?? $name),
|
||||||
'url' => (string) ($data['page_canonical'] ?? url()->current()),
|
'description' => $description,
|
||||||
|
'url' => $canonical,
|
||||||
|
'about' => $about !== [] ? $about : null,
|
||||||
'mainEntity' => [
|
'mainEntity' => [
|
||||||
'@type' => 'ItemList',
|
'@type' => 'ItemList',
|
||||||
|
'name' => (string) ($data['hero_title'] ?? $name),
|
||||||
|
'itemListOrder' => $this->galleryItemListOrder((string) ($data['current_sort'] ?? 'trending')),
|
||||||
'numberOfItems' => $count,
|
'numberOfItems' => $count,
|
||||||
'itemListElement' => $itemListElement,
|
'itemListElement' => $itemListElement,
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function galleryArtworkUrl(mixed $artwork): ?string
|
||||||
|
{
|
||||||
|
$url = $this->normalizeUrl((string) (data_get($artwork, 'url') ?? ''));
|
||||||
|
|
||||||
|
if ($url !== null) {
|
||||||
|
return $url;
|
||||||
|
}
|
||||||
|
|
||||||
|
$id = data_get($artwork, 'id');
|
||||||
|
|
||||||
|
if (! filled($id)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$slug = $this->clean((string) (data_get($artwork, 'slug') ?? ''));
|
||||||
|
|
||||||
|
return route('art.show', array_filter([
|
||||||
|
'id' => $id,
|
||||||
|
'slug' => $slug,
|
||||||
|
], fn (mixed $value): bool => $value !== null && $value !== ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function galleryImageFormat(?string $url): ?string
|
||||||
|
{
|
||||||
|
$path = parse_url((string) $url, PHP_URL_PATH);
|
||||||
|
$extension = strtolower((string) pathinfo((string) $path, PATHINFO_EXTENSION));
|
||||||
|
|
||||||
|
return $extension !== '' ? 'image/' . $extension : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function galleryContentUrl(mixed $artwork, ?string $fallbackUrl): ?string
|
||||||
|
{
|
||||||
|
$raw = trim((string) (data_get($artwork, 'thumb_srcset') ?? ''));
|
||||||
|
|
||||||
|
if ($raw === '') {
|
||||||
|
return $fallbackUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
$candidates = collect(explode(',', $raw))
|
||||||
|
->map(static function (string $candidate): array {
|
||||||
|
$parts = preg_split('/\s+/', trim($candidate)) ?: [];
|
||||||
|
$url = $parts[0] ?? null;
|
||||||
|
$descriptor = $parts[1] ?? null;
|
||||||
|
$width = is_string($descriptor) && preg_match('/^(\d+)w$/', $descriptor, $matches) === 1
|
||||||
|
? (int) $matches[1]
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'url' => $url,
|
||||||
|
'width' => $width,
|
||||||
|
];
|
||||||
|
})
|
||||||
|
->filter(fn (array $candidate): bool => filled($candidate['url']))
|
||||||
|
->sortByDesc('width')
|
||||||
|
->values();
|
||||||
|
|
||||||
|
$bestUrl = $this->normalizeUrl((string) ($candidates->first()['url'] ?? ''));
|
||||||
|
|
||||||
|
return $bestUrl ?? $fallbackUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function galleryItemListOrder(string $sort): string
|
||||||
|
{
|
||||||
|
return $sort === 'oldest'
|
||||||
|
? 'https://schema.org/ItemListOrderAscending'
|
||||||
|
: 'https://schema.org/ItemListOrderDescending';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -18,7 +18,7 @@ import "./vendor-tooltip-CIQaDNlG.js";
|
|||||||
import "node:process";
|
import "node:process";
|
||||||
import "node:path";
|
import "node:path";
|
||||||
import "node:url";
|
import "node:url";
|
||||||
import "./vendor-realtime-DYEIbD6w.js";
|
import "./vendor-realtime-Koiu-_pw.js";
|
||||||
import "buffer";
|
import "buffer";
|
||||||
import "child_process";
|
import "child_process";
|
||||||
import "net";
|
import "net";
|
||||||
640
bootstrap/ssr/assets/turndown.es-8lfE8z0s.js
Normal file
640
bootstrap/ssr/assets/turndown.es-8lfE8z0s.js
Normal file
@@ -0,0 +1,640 @@
|
|||||||
|
function extend(destination) {
|
||||||
|
for (var i = 1; i < arguments.length; i++) {
|
||||||
|
var source = arguments[i];
|
||||||
|
for (var key in source) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(source, key)) destination[key] = source[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return destination;
|
||||||
|
}
|
||||||
|
function repeat(character, count) {
|
||||||
|
return Array(count + 1).join(character);
|
||||||
|
}
|
||||||
|
function trimLeadingNewlines(string) {
|
||||||
|
return string.replace(/^\n*/, "");
|
||||||
|
}
|
||||||
|
function trimTrailingNewlines(string) {
|
||||||
|
var indexEnd = string.length;
|
||||||
|
while (indexEnd > 0 && string[indexEnd - 1] === "\n") indexEnd--;
|
||||||
|
return string.substring(0, indexEnd);
|
||||||
|
}
|
||||||
|
function trimNewlines(string) {
|
||||||
|
return trimTrailingNewlines(trimLeadingNewlines(string));
|
||||||
|
}
|
||||||
|
var blockElements = ["ADDRESS", "ARTICLE", "ASIDE", "AUDIO", "BLOCKQUOTE", "BODY", "CANVAS", "CENTER", "DD", "DIR", "DIV", "DL", "DT", "FIELDSET", "FIGCAPTION", "FIGURE", "FOOTER", "FORM", "FRAMESET", "H1", "H2", "H3", "H4", "H5", "H6", "HEADER", "HGROUP", "HR", "HTML", "ISINDEX", "LI", "MAIN", "MENU", "NAV", "NOFRAMES", "NOSCRIPT", "OL", "OUTPUT", "P", "PRE", "SECTION", "TABLE", "TBODY", "TD", "TFOOT", "TH", "THEAD", "TR", "UL"];
|
||||||
|
function isBlock(node) {
|
||||||
|
return is(node, blockElements);
|
||||||
|
}
|
||||||
|
var voidElements = ["AREA", "BASE", "BR", "COL", "COMMAND", "EMBED", "HR", "IMG", "INPUT", "KEYGEN", "LINK", "META", "PARAM", "SOURCE", "TRACK", "WBR"];
|
||||||
|
function isVoid(node) {
|
||||||
|
return is(node, voidElements);
|
||||||
|
}
|
||||||
|
function hasVoid(node) {
|
||||||
|
return has(node, voidElements);
|
||||||
|
}
|
||||||
|
var meaningfulWhenBlankElements = ["A", "TABLE", "THEAD", "TBODY", "TFOOT", "TH", "TD", "IFRAME", "SCRIPT", "AUDIO", "VIDEO"];
|
||||||
|
function isMeaningfulWhenBlank(node) {
|
||||||
|
return is(node, meaningfulWhenBlankElements);
|
||||||
|
}
|
||||||
|
function hasMeaningfulWhenBlank(node) {
|
||||||
|
return has(node, meaningfulWhenBlankElements);
|
||||||
|
}
|
||||||
|
function is(node, tagNames) {
|
||||||
|
return tagNames.indexOf(node.nodeName) >= 0;
|
||||||
|
}
|
||||||
|
function has(node, tagNames) {
|
||||||
|
return node.getElementsByTagName && tagNames.some(function(tagName) {
|
||||||
|
return node.getElementsByTagName(tagName).length;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
var markdownEscapes = [[/\\/g, "\\\\"], [/\*/g, "\\*"], [/^-/g, "\\-"], [/^\+ /g, "\\+ "], [/^(=+)/g, "\\$1"], [/^(#{1,6}) /g, "\\$1 "], [/`/g, "\\`"], [/^~~~/g, "\\~~~"], [/\[/g, "\\["], [/\]/g, "\\]"], [/^>/g, "\\>"], [/_/g, "\\_"], [/^(\d+)\. /g, "$1\\. "]];
|
||||||
|
function escapeMarkdown(string) {
|
||||||
|
return markdownEscapes.reduce(function(accumulator, escape) {
|
||||||
|
return accumulator.replace(escape[0], escape[1]);
|
||||||
|
}, string);
|
||||||
|
}
|
||||||
|
var rules = {};
|
||||||
|
rules.paragraph = {
|
||||||
|
filter: "p",
|
||||||
|
replacement: function(content) {
|
||||||
|
return "\n\n" + content + "\n\n";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
rules.lineBreak = {
|
||||||
|
filter: "br",
|
||||||
|
replacement: function(content, node, options) {
|
||||||
|
return options.br + "\n";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
rules.heading = {
|
||||||
|
filter: ["h1", "h2", "h3", "h4", "h5", "h6"],
|
||||||
|
replacement: function(content, node, options) {
|
||||||
|
var hLevel = Number(node.nodeName.charAt(1));
|
||||||
|
if (options.headingStyle === "setext" && hLevel < 3) {
|
||||||
|
var underline = repeat(hLevel === 1 ? "=" : "-", content.length);
|
||||||
|
return "\n\n" + content + "\n" + underline + "\n\n";
|
||||||
|
} else {
|
||||||
|
return "\n\n" + repeat("#", hLevel) + " " + content + "\n\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
rules.blockquote = {
|
||||||
|
filter: "blockquote",
|
||||||
|
replacement: function(content) {
|
||||||
|
content = trimNewlines(content).replace(/^/gm, "> ");
|
||||||
|
return "\n\n" + content + "\n\n";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
rules.list = {
|
||||||
|
filter: ["ul", "ol"],
|
||||||
|
replacement: function(content, node) {
|
||||||
|
var parent = node.parentNode;
|
||||||
|
if (parent.nodeName === "LI" && parent.lastElementChild === node) {
|
||||||
|
return "\n" + content;
|
||||||
|
} else {
|
||||||
|
return "\n\n" + content + "\n\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
rules.listItem = {
|
||||||
|
filter: "li",
|
||||||
|
replacement: function(content, node, options) {
|
||||||
|
var prefix = options.bulletListMarker + " ";
|
||||||
|
var parent = node.parentNode;
|
||||||
|
if (parent.nodeName === "OL") {
|
||||||
|
var start = parent.getAttribute("start");
|
||||||
|
var index = Array.prototype.indexOf.call(parent.children, node);
|
||||||
|
prefix = (start ? Number(start) + index : index + 1) + ". ";
|
||||||
|
}
|
||||||
|
var isParagraph = /\n$/.test(content);
|
||||||
|
content = trimNewlines(content) + (isParagraph ? "\n" : "");
|
||||||
|
content = content.replace(/\n/gm, "\n" + " ".repeat(prefix.length));
|
||||||
|
return prefix + content + (node.nextSibling ? "\n" : "");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
rules.indentedCodeBlock = {
|
||||||
|
filter: function(node, options) {
|
||||||
|
return options.codeBlockStyle === "indented" && node.nodeName === "PRE" && node.firstChild && node.firstChild.nodeName === "CODE";
|
||||||
|
},
|
||||||
|
replacement: function(content, node, options) {
|
||||||
|
return "\n\n " + node.firstChild.textContent.replace(/\n/g, "\n ") + "\n\n";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
rules.fencedCodeBlock = {
|
||||||
|
filter: function(node, options) {
|
||||||
|
return options.codeBlockStyle === "fenced" && node.nodeName === "PRE" && node.firstChild && node.firstChild.nodeName === "CODE";
|
||||||
|
},
|
||||||
|
replacement: function(content, node, options) {
|
||||||
|
var className = node.firstChild.getAttribute("class") || "";
|
||||||
|
var language = (className.match(/language-(\S+)/) || [null, ""])[1];
|
||||||
|
var code = node.firstChild.textContent;
|
||||||
|
var fenceChar = options.fence.charAt(0);
|
||||||
|
var fenceSize = 3;
|
||||||
|
var fenceInCodeRegex = new RegExp("^" + fenceChar + "{3,}", "gm");
|
||||||
|
var match;
|
||||||
|
while (match = fenceInCodeRegex.exec(code)) {
|
||||||
|
if (match[0].length >= fenceSize) {
|
||||||
|
fenceSize = match[0].length + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var fence = repeat(fenceChar, fenceSize);
|
||||||
|
return "\n\n" + fence + language + "\n" + code.replace(/\n$/, "") + "\n" + fence + "\n\n";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
rules.horizontalRule = {
|
||||||
|
filter: "hr",
|
||||||
|
replacement: function(content, node, options) {
|
||||||
|
return "\n\n" + options.hr + "\n\n";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
rules.inlineLink = {
|
||||||
|
filter: function(node, options) {
|
||||||
|
return options.linkStyle === "inlined" && node.nodeName === "A" && node.getAttribute("href");
|
||||||
|
},
|
||||||
|
replacement: function(content, node) {
|
||||||
|
var href = escapeLinkDestination(node.getAttribute("href"));
|
||||||
|
var title = escapeLinkTitle(cleanAttribute(node.getAttribute("title")));
|
||||||
|
var titlePart = title ? ' "' + title + '"' : "";
|
||||||
|
return "[" + content + "](" + href + titlePart + ")";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
rules.referenceLink = {
|
||||||
|
filter: function(node, options) {
|
||||||
|
return options.linkStyle === "referenced" && node.nodeName === "A" && node.getAttribute("href");
|
||||||
|
},
|
||||||
|
replacement: function(content, node, options) {
|
||||||
|
var href = escapeLinkDestination(node.getAttribute("href"));
|
||||||
|
var title = cleanAttribute(node.getAttribute("title"));
|
||||||
|
if (title) title = ' "' + escapeLinkTitle(title) + '"';
|
||||||
|
var replacement;
|
||||||
|
var reference;
|
||||||
|
switch (options.linkReferenceStyle) {
|
||||||
|
case "collapsed":
|
||||||
|
replacement = "[" + content + "][]";
|
||||||
|
reference = "[" + content + "]: " + href + title;
|
||||||
|
break;
|
||||||
|
case "shortcut":
|
||||||
|
replacement = "[" + content + "]";
|
||||||
|
reference = "[" + content + "]: " + href + title;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
var id = this.references.length + 1;
|
||||||
|
replacement = "[" + content + "][" + id + "]";
|
||||||
|
reference = "[" + id + "]: " + href + title;
|
||||||
|
}
|
||||||
|
this.references.push(reference);
|
||||||
|
return replacement;
|
||||||
|
},
|
||||||
|
references: [],
|
||||||
|
append: function(options) {
|
||||||
|
var references = "";
|
||||||
|
if (this.references.length) {
|
||||||
|
references = "\n\n" + this.references.join("\n") + "\n\n";
|
||||||
|
this.references = [];
|
||||||
|
}
|
||||||
|
return references;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
rules.emphasis = {
|
||||||
|
filter: ["em", "i"],
|
||||||
|
replacement: function(content, node, options) {
|
||||||
|
if (!content.trim()) return "";
|
||||||
|
return options.emDelimiter + content + options.emDelimiter;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
rules.strong = {
|
||||||
|
filter: ["strong", "b"],
|
||||||
|
replacement: function(content, node, options) {
|
||||||
|
if (!content.trim()) return "";
|
||||||
|
return options.strongDelimiter + content + options.strongDelimiter;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
rules.code = {
|
||||||
|
filter: function(node) {
|
||||||
|
var hasSiblings = node.previousSibling || node.nextSibling;
|
||||||
|
var isCodeBlock = node.parentNode.nodeName === "PRE" && !hasSiblings;
|
||||||
|
return node.nodeName === "CODE" && !isCodeBlock;
|
||||||
|
},
|
||||||
|
replacement: function(content) {
|
||||||
|
if (!content) return "";
|
||||||
|
content = content.replace(/\r?\n|\r/g, " ");
|
||||||
|
var extraSpace = /^`|^ .*?[^ ].* $|`$/.test(content) ? " " : "";
|
||||||
|
var delimiter = "`";
|
||||||
|
var matches = content.match(/`+/gm) || [];
|
||||||
|
while (matches.indexOf(delimiter) !== -1) delimiter = delimiter + "`";
|
||||||
|
return delimiter + extraSpace + content + extraSpace + delimiter;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
rules.image = {
|
||||||
|
filter: "img",
|
||||||
|
replacement: function(content, node) {
|
||||||
|
var alt = escapeMarkdown(cleanAttribute(node.getAttribute("alt")));
|
||||||
|
var src = escapeLinkDestination(node.getAttribute("src") || "");
|
||||||
|
var title = cleanAttribute(node.getAttribute("title"));
|
||||||
|
var titlePart = title ? ' "' + escapeLinkTitle(title) + '"' : "";
|
||||||
|
return src ? "" : "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
function cleanAttribute(attribute) {
|
||||||
|
return attribute ? attribute.replace(/(\n+\s*)+/g, "\n") : "";
|
||||||
|
}
|
||||||
|
function escapeLinkDestination(destination) {
|
||||||
|
var escaped = destination.replace(/([<>()])/g, "\\$1");
|
||||||
|
return escaped.indexOf(" ") >= 0 ? "<" + escaped + ">" : escaped;
|
||||||
|
}
|
||||||
|
function escapeLinkTitle(title) {
|
||||||
|
return title.replace(/"/g, '\\"');
|
||||||
|
}
|
||||||
|
function Rules(options) {
|
||||||
|
this.options = options;
|
||||||
|
this._keep = [];
|
||||||
|
this._remove = [];
|
||||||
|
this.blankRule = {
|
||||||
|
replacement: options.blankReplacement
|
||||||
|
};
|
||||||
|
this.keepReplacement = options.keepReplacement;
|
||||||
|
this.defaultRule = {
|
||||||
|
replacement: options.defaultReplacement
|
||||||
|
};
|
||||||
|
this.array = [];
|
||||||
|
for (var key in options.rules) this.array.push(options.rules[key]);
|
||||||
|
}
|
||||||
|
Rules.prototype = {
|
||||||
|
add: function(key, rule) {
|
||||||
|
this.array.unshift(rule);
|
||||||
|
},
|
||||||
|
keep: function(filter) {
|
||||||
|
this._keep.unshift({
|
||||||
|
filter,
|
||||||
|
replacement: this.keepReplacement
|
||||||
|
});
|
||||||
|
},
|
||||||
|
remove: function(filter) {
|
||||||
|
this._remove.unshift({
|
||||||
|
filter,
|
||||||
|
replacement: function() {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
forNode: function(node) {
|
||||||
|
if (node.isBlank) return this.blankRule;
|
||||||
|
var rule;
|
||||||
|
if (rule = findRule(this.array, node, this.options)) return rule;
|
||||||
|
if (rule = findRule(this._keep, node, this.options)) return rule;
|
||||||
|
if (rule = findRule(this._remove, node, this.options)) return rule;
|
||||||
|
return this.defaultRule;
|
||||||
|
},
|
||||||
|
forEach: function(fn) {
|
||||||
|
for (var i = 0; i < this.array.length; i++) fn(this.array[i], i);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
function findRule(rules2, node, options) {
|
||||||
|
for (var i = 0; i < rules2.length; i++) {
|
||||||
|
var rule = rules2[i];
|
||||||
|
if (filterValue(rule, node, options)) return rule;
|
||||||
|
}
|
||||||
|
return void 0;
|
||||||
|
}
|
||||||
|
function filterValue(rule, node, options) {
|
||||||
|
var filter = rule.filter;
|
||||||
|
if (typeof filter === "string") {
|
||||||
|
if (filter === node.nodeName.toLowerCase()) return true;
|
||||||
|
} else if (Array.isArray(filter)) {
|
||||||
|
if (filter.indexOf(node.nodeName.toLowerCase()) > -1) return true;
|
||||||
|
} else if (typeof filter === "function") {
|
||||||
|
if (filter.call(rule, node, options)) return true;
|
||||||
|
} else {
|
||||||
|
throw new TypeError("`filter` needs to be a string, array, or function");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function collapseWhitespace(options) {
|
||||||
|
var element = options.element;
|
||||||
|
var isBlock2 = options.isBlock;
|
||||||
|
var isVoid2 = options.isVoid;
|
||||||
|
var isPre = options.isPre || function(node2) {
|
||||||
|
return node2.nodeName === "PRE";
|
||||||
|
};
|
||||||
|
if (!element.firstChild || isPre(element)) return;
|
||||||
|
var prevText = null;
|
||||||
|
var keepLeadingWs = false;
|
||||||
|
var prev = null;
|
||||||
|
var node = next(prev, element, isPre);
|
||||||
|
while (node !== element) {
|
||||||
|
if (node.nodeType === 3 || node.nodeType === 4) {
|
||||||
|
var text = node.data.replace(/[ \r\n\t]+/g, " ");
|
||||||
|
if ((!prevText || / $/.test(prevText.data)) && !keepLeadingWs && text[0] === " ") {
|
||||||
|
text = text.substr(1);
|
||||||
|
}
|
||||||
|
if (!text) {
|
||||||
|
node = remove(node);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
node.data = text;
|
||||||
|
prevText = node;
|
||||||
|
} else if (node.nodeType === 1) {
|
||||||
|
if (isBlock2(node) || node.nodeName === "BR") {
|
||||||
|
if (prevText) {
|
||||||
|
prevText.data = prevText.data.replace(/ $/, "");
|
||||||
|
}
|
||||||
|
prevText = null;
|
||||||
|
keepLeadingWs = false;
|
||||||
|
} else if (isVoid2(node) || isPre(node)) {
|
||||||
|
prevText = null;
|
||||||
|
keepLeadingWs = true;
|
||||||
|
} else if (prevText) {
|
||||||
|
keepLeadingWs = false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
node = remove(node);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
var nextNode = next(prev, node, isPre);
|
||||||
|
prev = node;
|
||||||
|
node = nextNode;
|
||||||
|
}
|
||||||
|
if (prevText) {
|
||||||
|
prevText.data = prevText.data.replace(/ $/, "");
|
||||||
|
if (!prevText.data) {
|
||||||
|
remove(prevText);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function remove(node) {
|
||||||
|
var next2 = node.nextSibling || node.parentNode;
|
||||||
|
node.parentNode.removeChild(node);
|
||||||
|
return next2;
|
||||||
|
}
|
||||||
|
function next(prev, current, isPre) {
|
||||||
|
if (prev && prev.parentNode === current || isPre(current)) {
|
||||||
|
return current.nextSibling || current.parentNode;
|
||||||
|
}
|
||||||
|
return current.firstChild || current.nextSibling || current.parentNode;
|
||||||
|
}
|
||||||
|
var root = typeof window !== "undefined" ? window : {};
|
||||||
|
function canParseHTMLNatively() {
|
||||||
|
var Parser = root.DOMParser;
|
||||||
|
var canParse = false;
|
||||||
|
try {
|
||||||
|
if (new Parser().parseFromString("", "text/html")) {
|
||||||
|
canParse = true;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
}
|
||||||
|
return canParse;
|
||||||
|
}
|
||||||
|
function createHTMLParser() {
|
||||||
|
var Parser = function() {
|
||||||
|
};
|
||||||
|
{
|
||||||
|
var domino = require("@mixmark-io/domino");
|
||||||
|
Parser.prototype.parseFromString = function(string) {
|
||||||
|
return domino.createDocument(string);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return Parser;
|
||||||
|
}
|
||||||
|
var HTMLParser = canParseHTMLNatively() ? root.DOMParser : createHTMLParser();
|
||||||
|
function RootNode(input, options) {
|
||||||
|
var root2;
|
||||||
|
if (typeof input === "string") {
|
||||||
|
var doc = htmlParser().parseFromString(
|
||||||
|
// DOM parsers arrange elements in the <head> and <body>.
|
||||||
|
// Wrapping in a custom element ensures elements are reliably arranged in
|
||||||
|
// a single element.
|
||||||
|
'<x-turndown id="turndown-root">' + input + "</x-turndown>",
|
||||||
|
"text/html"
|
||||||
|
);
|
||||||
|
root2 = doc.getElementById("turndown-root");
|
||||||
|
} else {
|
||||||
|
root2 = input.cloneNode(true);
|
||||||
|
}
|
||||||
|
collapseWhitespace({
|
||||||
|
element: root2,
|
||||||
|
isBlock,
|
||||||
|
isVoid,
|
||||||
|
isPre: options.preformattedCode ? isPreOrCode : null
|
||||||
|
});
|
||||||
|
return root2;
|
||||||
|
}
|
||||||
|
var _htmlParser;
|
||||||
|
function htmlParser() {
|
||||||
|
_htmlParser = _htmlParser || new HTMLParser();
|
||||||
|
return _htmlParser;
|
||||||
|
}
|
||||||
|
function isPreOrCode(node) {
|
||||||
|
return node.nodeName === "PRE" || node.nodeName === "CODE";
|
||||||
|
}
|
||||||
|
function Node(node, options) {
|
||||||
|
node.isBlock = isBlock(node);
|
||||||
|
node.isCode = node.nodeName === "CODE" || node.parentNode.isCode;
|
||||||
|
node.isBlank = isBlank(node);
|
||||||
|
node.flankingWhitespace = flankingWhitespace(node, options);
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
function isBlank(node) {
|
||||||
|
return !isVoid(node) && !isMeaningfulWhenBlank(node) && /^\s*$/i.test(node.textContent) && !hasVoid(node) && !hasMeaningfulWhenBlank(node);
|
||||||
|
}
|
||||||
|
function flankingWhitespace(node, options) {
|
||||||
|
if (node.isBlock || options.preformattedCode && node.isCode) {
|
||||||
|
return {
|
||||||
|
leading: "",
|
||||||
|
trailing: ""
|
||||||
|
};
|
||||||
|
}
|
||||||
|
var edges = edgeWhitespace(node.textContent);
|
||||||
|
if (edges.leadingAscii && isFlankedByWhitespace("left", node, options)) {
|
||||||
|
edges.leading = edges.leadingNonAscii;
|
||||||
|
}
|
||||||
|
if (edges.trailingAscii && isFlankedByWhitespace("right", node, options)) {
|
||||||
|
edges.trailing = edges.trailingNonAscii;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
leading: edges.leading,
|
||||||
|
trailing: edges.trailing
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function edgeWhitespace(string) {
|
||||||
|
var m = string.match(/^(([ \t\r\n]*)(\s*))(?:(?=\S)[\s\S]*\S)?((\s*?)([ \t\r\n]*))$/);
|
||||||
|
return {
|
||||||
|
leading: m[1],
|
||||||
|
// whole string for whitespace-only strings
|
||||||
|
leadingAscii: m[2],
|
||||||
|
leadingNonAscii: m[3],
|
||||||
|
trailing: m[4],
|
||||||
|
// empty for whitespace-only strings
|
||||||
|
trailingNonAscii: m[5],
|
||||||
|
trailingAscii: m[6]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function isFlankedByWhitespace(side, node, options) {
|
||||||
|
var sibling;
|
||||||
|
var regExp;
|
||||||
|
var isFlanked;
|
||||||
|
if (side === "left") {
|
||||||
|
sibling = node.previousSibling;
|
||||||
|
regExp = / $/;
|
||||||
|
} else {
|
||||||
|
sibling = node.nextSibling;
|
||||||
|
regExp = /^ /;
|
||||||
|
}
|
||||||
|
if (sibling) {
|
||||||
|
if (sibling.nodeType === 3) {
|
||||||
|
isFlanked = regExp.test(sibling.nodeValue);
|
||||||
|
} else if (options.preformattedCode && sibling.nodeName === "CODE") {
|
||||||
|
isFlanked = false;
|
||||||
|
} else if (sibling.nodeType === 1 && !isBlock(sibling)) {
|
||||||
|
isFlanked = regExp.test(sibling.textContent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return isFlanked;
|
||||||
|
}
|
||||||
|
var reduce = Array.prototype.reduce;
|
||||||
|
function TurndownService(options) {
|
||||||
|
if (!(this instanceof TurndownService)) return new TurndownService(options);
|
||||||
|
var defaults = {
|
||||||
|
rules,
|
||||||
|
headingStyle: "setext",
|
||||||
|
hr: "* * *",
|
||||||
|
bulletListMarker: "*",
|
||||||
|
codeBlockStyle: "indented",
|
||||||
|
fence: "```",
|
||||||
|
emDelimiter: "_",
|
||||||
|
strongDelimiter: "**",
|
||||||
|
linkStyle: "inlined",
|
||||||
|
linkReferenceStyle: "full",
|
||||||
|
br: " ",
|
||||||
|
preformattedCode: false,
|
||||||
|
blankReplacement: function(content, node) {
|
||||||
|
return node.isBlock ? "\n\n" : "";
|
||||||
|
},
|
||||||
|
keepReplacement: function(content, node) {
|
||||||
|
return node.isBlock ? "\n\n" + node.outerHTML + "\n\n" : node.outerHTML;
|
||||||
|
},
|
||||||
|
defaultReplacement: function(content, node) {
|
||||||
|
return node.isBlock ? "\n\n" + content + "\n\n" : content;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
this.options = extend({}, defaults, options);
|
||||||
|
this.rules = new Rules(this.options);
|
||||||
|
}
|
||||||
|
TurndownService.prototype = {
|
||||||
|
/**
|
||||||
|
* The entry point for converting a string or DOM node to Markdown
|
||||||
|
* @public
|
||||||
|
* @param {String|HTMLElement} input The string or DOM node to convert
|
||||||
|
* @returns A Markdown representation of the input
|
||||||
|
* @type String
|
||||||
|
*/
|
||||||
|
turndown: function(input) {
|
||||||
|
if (!canConvert(input)) {
|
||||||
|
throw new TypeError(input + " is not a string, or an element/document/fragment node.");
|
||||||
|
}
|
||||||
|
if (input === "") return "";
|
||||||
|
var output = process.call(this, new RootNode(input, this.options));
|
||||||
|
return postProcess.call(this, output);
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Add one or more plugins
|
||||||
|
* @public
|
||||||
|
* @param {Function|Array} plugin The plugin or array of plugins to add
|
||||||
|
* @returns The Turndown instance for chaining
|
||||||
|
* @type Object
|
||||||
|
*/
|
||||||
|
use: function(plugin) {
|
||||||
|
if (Array.isArray(plugin)) {
|
||||||
|
for (var i = 0; i < plugin.length; i++) this.use(plugin[i]);
|
||||||
|
} else if (typeof plugin === "function") {
|
||||||
|
plugin(this);
|
||||||
|
} else {
|
||||||
|
throw new TypeError("plugin must be a Function or an Array of Functions");
|
||||||
|
}
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Adds a rule
|
||||||
|
* @public
|
||||||
|
* @param {String} key The unique key of the rule
|
||||||
|
* @param {Object} rule The rule
|
||||||
|
* @returns The Turndown instance for chaining
|
||||||
|
* @type Object
|
||||||
|
*/
|
||||||
|
addRule: function(key, rule) {
|
||||||
|
this.rules.add(key, rule);
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Keep a node (as HTML) that matches the filter
|
||||||
|
* @public
|
||||||
|
* @param {String|Array|Function} filter The unique key of the rule
|
||||||
|
* @returns The Turndown instance for chaining
|
||||||
|
* @type Object
|
||||||
|
*/
|
||||||
|
keep: function(filter) {
|
||||||
|
this.rules.keep(filter);
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Remove a node that matches the filter
|
||||||
|
* @public
|
||||||
|
* @param {String|Array|Function} filter The unique key of the rule
|
||||||
|
* @returns The Turndown instance for chaining
|
||||||
|
* @type Object
|
||||||
|
*/
|
||||||
|
remove: function(filter) {
|
||||||
|
this.rules.remove(filter);
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Escapes Markdown syntax
|
||||||
|
* @public
|
||||||
|
* @param {String} string The string to escape
|
||||||
|
* @returns A string with Markdown syntax escaped
|
||||||
|
* @type String
|
||||||
|
*/
|
||||||
|
escape: function(string) {
|
||||||
|
return escapeMarkdown(string);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
function process(parentNode) {
|
||||||
|
var self = this;
|
||||||
|
return reduce.call(parentNode.childNodes, function(output, node) {
|
||||||
|
node = new Node(node, self.options);
|
||||||
|
var replacement = "";
|
||||||
|
if (node.nodeType === 3) {
|
||||||
|
replacement = node.isCode ? node.nodeValue : self.escape(node.nodeValue);
|
||||||
|
} else if (node.nodeType === 1) {
|
||||||
|
replacement = replacementForNode.call(self, node);
|
||||||
|
}
|
||||||
|
return join(output, replacement);
|
||||||
|
}, "");
|
||||||
|
}
|
||||||
|
function postProcess(output) {
|
||||||
|
var self = this;
|
||||||
|
this.rules.forEach(function(rule) {
|
||||||
|
if (typeof rule.append === "function") {
|
||||||
|
output = join(output, rule.append(self.options));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return output.replace(/^[\t\r\n]+/, "").replace(/[\t\r\n\s]+$/, "");
|
||||||
|
}
|
||||||
|
function replacementForNode(node) {
|
||||||
|
var rule = this.rules.forNode(node);
|
||||||
|
var content = process.call(this, node);
|
||||||
|
var whitespace = node.flankingWhitespace;
|
||||||
|
if (whitespace.leading || whitespace.trailing) content = content.trim();
|
||||||
|
return whitespace.leading + rule.replacement(content, node, this.options) + whitespace.trailing;
|
||||||
|
}
|
||||||
|
function join(output, replacement) {
|
||||||
|
var s1 = trimTrailingNewlines(output);
|
||||||
|
var s2 = trimLeadingNewlines(replacement);
|
||||||
|
var nls = Math.max(output.length - s1.length, replacement.length - s2.length);
|
||||||
|
var separator = "\n\n".substring(0, nls);
|
||||||
|
return s1 + separator + s2;
|
||||||
|
}
|
||||||
|
function canConvert(input) {
|
||||||
|
return input != null && (typeof input === "string" || input.nodeType && (input.nodeType === 1 || input.nodeType === 9 || input.nodeType === 11));
|
||||||
|
}
|
||||||
|
export {
|
||||||
|
TurndownService as default
|
||||||
|
};
|
||||||
@@ -1,17 +1,17 @@
|
|||||||
import require$$0 from "util";
|
import require$$0 from "util";
|
||||||
import stream from "stream";
|
import stream from "stream";
|
||||||
|
import require$$4 from "https";
|
||||||
import require$$5 from "url";
|
import require$$5 from "url";
|
||||||
import require$$6 from "fs";
|
import require$$6 from "fs";
|
||||||
import require$$1 from "crypto";
|
import require$$1 from "crypto";
|
||||||
import require$$4$2 from "assert";
|
import require$$4$2 from "assert";
|
||||||
import require$$1$1 from "buffer";
|
import require$$1$1 from "buffer";
|
||||||
import require$$2 from "child_process";
|
import require$$2 from "child_process";
|
||||||
|
import require$$4$1 from "events";
|
||||||
import require$$8 from "net";
|
import require$$8 from "net";
|
||||||
import require$$10 from "tls";
|
import require$$10 from "tls";
|
||||||
import { c as commonjsGlobal, g as getDefaultExportFromCjs } from "./vendor-tiptap-DRFaxGEb.js";
|
import { c as commonjsGlobal, g as getDefaultExportFromCjs } from "./vendor-tiptap-DRFaxGEb.js";
|
||||||
import require$$4$1 from "events";
|
|
||||||
import require$$3 from "http";
|
import require$$3 from "http";
|
||||||
import require$$4 from "https";
|
|
||||||
class u {
|
class u {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.notificationCreatedEvent = ".Illuminate\\Notifications\\Events\\BroadcastNotificationCreated";
|
this.notificationCreatedEvent = ".Illuminate\\Notifications\\Events\\BroadcastNotificationCreated";
|
||||||
@@ -14,10 +14,10 @@
|
|||||||
"\u0000D:/Sites/Skinbase26/node_modules/nprogress/nprogress.js?commonjs-es-import": [],
|
"\u0000D:/Sites/Skinbase26/node_modules/nprogress/nprogress.js?commonjs-es-import": [],
|
||||||
"\u0000D:/Sites/Skinbase26/node_modules/nprogress/nprogress.js?commonjs-module": [],
|
"\u0000D:/Sites/Skinbase26/node_modules/nprogress/nprogress.js?commonjs-module": [],
|
||||||
"\u0000D:/Sites/Skinbase26/node_modules/pusher-js/dist/node/pusher.js?commonjs-es-import": [
|
"\u0000D:/Sites/Skinbase26/node_modules/pusher-js/dist/node/pusher.js?commonjs-es-import": [
|
||||||
"/build/assets/vendor-realtime-DYEIbD6w.js"
|
"/build/assets/vendor-realtime-Koiu-_pw.js"
|
||||||
],
|
],
|
||||||
"\u0000D:/Sites/Skinbase26/node_modules/pusher-js/dist/node/pusher.js?commonjs-module": [
|
"\u0000D:/Sites/Skinbase26/node_modules/pusher-js/dist/node/pusher.js?commonjs-module": [
|
||||||
"/build/assets/vendor-realtime-DYEIbD6w.js"
|
"/build/assets/vendor-realtime-Koiu-_pw.js"
|
||||||
],
|
],
|
||||||
"\u0000D:/Sites/Skinbase26/node_modules/qs/lib/index.js?commonjs-es-import": [],
|
"\u0000D:/Sites/Skinbase26/node_modules/qs/lib/index.js?commonjs-es-import": [],
|
||||||
"\u0000D:/Sites/Skinbase26/node_modules/react-dom/cjs/react-dom-client.development.js?commonjs-exports": [],
|
"\u0000D:/Sites/Skinbase26/node_modules/react-dom/cjs/react-dom-client.development.js?commonjs-exports": [],
|
||||||
@@ -97,46 +97,46 @@
|
|||||||
"/build/assets/vendor-tiptap-DRFaxGEb.js"
|
"/build/assets/vendor-tiptap-DRFaxGEb.js"
|
||||||
],
|
],
|
||||||
"\u0000assert?commonjs-external": [
|
"\u0000assert?commonjs-external": [
|
||||||
"/build/assets/vendor-realtime-DYEIbD6w.js"
|
"/build/assets/vendor-realtime-Koiu-_pw.js"
|
||||||
],
|
],
|
||||||
"\u0000buffer?commonjs-external": [
|
"\u0000buffer?commonjs-external": [
|
||||||
"/build/assets/vendor-realtime-DYEIbD6w.js"
|
"/build/assets/vendor-realtime-Koiu-_pw.js"
|
||||||
],
|
],
|
||||||
"\u0000child_process?commonjs-external": [
|
"\u0000child_process?commonjs-external": [
|
||||||
"/build/assets/vendor-realtime-DYEIbD6w.js"
|
"/build/assets/vendor-realtime-Koiu-_pw.js"
|
||||||
],
|
],
|
||||||
"\u0000commonjsHelpers.js": [
|
"\u0000commonjsHelpers.js": [
|
||||||
"/build/assets/vendor-tiptap-DRFaxGEb.js"
|
"/build/assets/vendor-tiptap-DRFaxGEb.js"
|
||||||
],
|
],
|
||||||
"\u0000crypto?commonjs-external": [
|
"\u0000crypto?commonjs-external": [
|
||||||
"/build/assets/vendor-realtime-DYEIbD6w.js"
|
"/build/assets/vendor-realtime-Koiu-_pw.js"
|
||||||
],
|
],
|
||||||
"\u0000events?commonjs-external": [
|
"\u0000events?commonjs-external": [
|
||||||
"/build/assets/vendor-realtime-DYEIbD6w.js"
|
"/build/assets/vendor-realtime-Koiu-_pw.js"
|
||||||
],
|
],
|
||||||
"\u0000fs?commonjs-external": [
|
"\u0000fs?commonjs-external": [
|
||||||
"/build/assets/vendor-realtime-DYEIbD6w.js"
|
"/build/assets/vendor-realtime-Koiu-_pw.js"
|
||||||
],
|
],
|
||||||
"\u0000http?commonjs-external": [
|
"\u0000http?commonjs-external": [
|
||||||
"/build/assets/vendor-realtime-DYEIbD6w.js"
|
"/build/assets/vendor-realtime-Koiu-_pw.js"
|
||||||
],
|
],
|
||||||
"\u0000https?commonjs-external": [
|
"\u0000https?commonjs-external": [
|
||||||
"/build/assets/vendor-realtime-DYEIbD6w.js"
|
"/build/assets/vendor-realtime-Koiu-_pw.js"
|
||||||
],
|
],
|
||||||
"\u0000net?commonjs-external": [
|
"\u0000net?commonjs-external": [
|
||||||
"/build/assets/vendor-realtime-DYEIbD6w.js"
|
"/build/assets/vendor-realtime-Koiu-_pw.js"
|
||||||
],
|
],
|
||||||
"\u0000stream?commonjs-external": [
|
"\u0000stream?commonjs-external": [
|
||||||
"/build/assets/vendor-realtime-DYEIbD6w.js"
|
"/build/assets/vendor-realtime-Koiu-_pw.js"
|
||||||
],
|
],
|
||||||
"\u0000tls?commonjs-external": [
|
"\u0000tls?commonjs-external": [
|
||||||
"/build/assets/vendor-realtime-DYEIbD6w.js"
|
"/build/assets/vendor-realtime-Koiu-_pw.js"
|
||||||
],
|
],
|
||||||
"\u0000url?commonjs-external": [
|
"\u0000url?commonjs-external": [
|
||||||
"/build/assets/vendor-realtime-DYEIbD6w.js"
|
"/build/assets/vendor-realtime-Koiu-_pw.js"
|
||||||
],
|
],
|
||||||
"\u0000util?commonjs-external": [
|
"\u0000util?commonjs-external": [
|
||||||
"/build/assets/vendor-realtime-DYEIbD6w.js"
|
"/build/assets/vendor-realtime-Koiu-_pw.js"
|
||||||
],
|
],
|
||||||
"node_modules/@emoji-mart/data/sets/15/native.json": [
|
"node_modules/@emoji-mart/data/sets/15/native.json": [
|
||||||
"/build/assets/emoji-data-4xGXbtDn.js"
|
"/build/assets/emoji-data-4xGXbtDn.js"
|
||||||
@@ -1035,12 +1035,13 @@
|
|||||||
"node_modules/inline-style-parser/cjs/index.js": [],
|
"node_modules/inline-style-parser/cjs/index.js": [],
|
||||||
"node_modules/is-plain-obj/index.js": [],
|
"node_modules/is-plain-obj/index.js": [],
|
||||||
"node_modules/laravel-echo/dist/echo.js": [
|
"node_modules/laravel-echo/dist/echo.js": [
|
||||||
"/build/assets/vendor-realtime-DYEIbD6w.js"
|
"/build/assets/vendor-realtime-Koiu-_pw.js"
|
||||||
],
|
],
|
||||||
"node_modules/linkifyjs/dist/linkify.mjs": [
|
"node_modules/linkifyjs/dist/linkify.mjs": [
|
||||||
"/build/assets/vendor-tiptap-DRFaxGEb.js"
|
"/build/assets/vendor-tiptap-DRFaxGEb.js"
|
||||||
],
|
],
|
||||||
"node_modules/lodash.isequal/index.js": [],
|
"node_modules/lodash.isequal/index.js": [],
|
||||||
|
"node_modules/marked/lib/marked.esm.js": [],
|
||||||
"node_modules/math-intrinsics/abs.js": [],
|
"node_modules/math-intrinsics/abs.js": [],
|
||||||
"node_modules/math-intrinsics/floor.js": [],
|
"node_modules/math-intrinsics/floor.js": [],
|
||||||
"node_modules/math-intrinsics/isNaN.js": [],
|
"node_modules/math-intrinsics/isNaN.js": [],
|
||||||
@@ -1934,7 +1935,7 @@
|
|||||||
],
|
],
|
||||||
"node_modules/proxy-from-env/index.js": [],
|
"node_modules/proxy-from-env/index.js": [],
|
||||||
"node_modules/pusher-js/dist/node/pusher.js": [
|
"node_modules/pusher-js/dist/node/pusher.js": [
|
||||||
"/build/assets/vendor-realtime-DYEIbD6w.js"
|
"/build/assets/vendor-realtime-Koiu-_pw.js"
|
||||||
],
|
],
|
||||||
"node_modules/qs/lib/formats.js": [],
|
"node_modules/qs/lib/formats.js": [],
|
||||||
"node_modules/qs/lib/index.js": [],
|
"node_modules/qs/lib/index.js": [],
|
||||||
@@ -1999,6 +2000,9 @@
|
|||||||
],
|
],
|
||||||
"node_modules/trim-lines/index.js": [],
|
"node_modules/trim-lines/index.js": [],
|
||||||
"node_modules/trough/lib/index.js": [],
|
"node_modules/trough/lib/index.js": [],
|
||||||
|
"node_modules/turndown/lib/turndown.es.js": [
|
||||||
|
"/build/assets/turndown.es-8lfE8z0s.js"
|
||||||
|
],
|
||||||
"node_modules/unified/lib/callable-instance.js": [],
|
"node_modules/unified/lib/callable-instance.js": [],
|
||||||
"node_modules/unified/lib/index.js": [],
|
"node_modules/unified/lib/index.js": [],
|
||||||
"node_modules/unist-util-is/lib/index.js": [],
|
"node_modules/unist-util-is/lib/index.js": [],
|
||||||
@@ -2035,10 +2039,14 @@
|
|||||||
"resources/js/Layouts/SettingsLayout.jsx": [],
|
"resources/js/Layouts/SettingsLayout.jsx": [],
|
||||||
"resources/js/Layouts/StudioLayout.jsx": [],
|
"resources/js/Layouts/StudioLayout.jsx": [],
|
||||||
"resources/js/Pages/Academy/ChallengeSubmit.jsx": [],
|
"resources/js/Pages/Academy/ChallengeSubmit.jsx": [],
|
||||||
|
"resources/js/Pages/Academy/CoursesIndex.jsx": [],
|
||||||
|
"resources/js/Pages/Academy/CoursesShow.jsx": [],
|
||||||
"resources/js/Pages/Academy/Index.jsx": [],
|
"resources/js/Pages/Academy/Index.jsx": [],
|
||||||
"resources/js/Pages/Academy/List.jsx": [],
|
"resources/js/Pages/Academy/List.jsx": [],
|
||||||
"resources/js/Pages/Academy/Pricing.jsx": [],
|
"resources/js/Pages/Academy/Pricing.jsx": [],
|
||||||
"resources/js/Pages/Academy/Show.jsx": [],
|
"resources/js/Pages/Academy/Show.jsx": [],
|
||||||
|
"resources/js/Pages/Admin/Academy/CourseBuilder.jsx": [],
|
||||||
|
"resources/js/Pages/Admin/Academy/CourseEditor.jsx": [],
|
||||||
"resources/js/Pages/Admin/Academy/CrudForm.jsx": [],
|
"resources/js/Pages/Admin/Academy/CrudForm.jsx": [],
|
||||||
"resources/js/Pages/Admin/Academy/CrudIndex.jsx": [],
|
"resources/js/Pages/Admin/Academy/CrudIndex.jsx": [],
|
||||||
"resources/js/Pages/Admin/Academy/Dashboard.jsx": [],
|
"resources/js/Pages/Admin/Academy/Dashboard.jsx": [],
|
||||||
@@ -2125,6 +2133,7 @@
|
|||||||
"resources/js/Pages/Moderation/AiBiographyAdmin.jsx": [],
|
"resources/js/Pages/Moderation/AiBiographyAdmin.jsx": [],
|
||||||
"resources/js/Pages/Moderation/ArtworkMaturityQueue.jsx": [],
|
"resources/js/Pages/Moderation/ArtworkMaturityQueue.jsx": [],
|
||||||
"resources/js/Pages/News/NewsComments.jsx": [],
|
"resources/js/Pages/News/NewsComments.jsx": [],
|
||||||
|
"resources/js/Pages/News/NewsImagePreview.jsx": [],
|
||||||
"resources/js/Pages/Profile/ProfileGallery.jsx": [],
|
"resources/js/Pages/Profile/ProfileGallery.jsx": [],
|
||||||
"resources/js/Pages/Profile/ProfileShow.jsx": [],
|
"resources/js/Pages/Profile/ProfileShow.jsx": [],
|
||||||
"resources/js/Pages/Settings/ProfileEdit.jsx": [],
|
"resources/js/Pages/Settings/ProfileEdit.jsx": [],
|
||||||
@@ -2223,7 +2232,7 @@
|
|||||||
"resources/js/components/artwork/ArtworkRecommendationsRails.jsx": [],
|
"resources/js/components/artwork/ArtworkRecommendationsRails.jsx": [],
|
||||||
"resources/js/components/artwork/ArtworkShareButton.jsx": [],
|
"resources/js/components/artwork/ArtworkShareButton.jsx": [],
|
||||||
"resources/js/components/artwork/ArtworkShareModal.jsx": [
|
"resources/js/components/artwork/ArtworkShareModal.jsx": [
|
||||||
"/build/assets/ArtworkShareModal-BI8kkaqs.js"
|
"/build/assets/ArtworkShareModal-BPM8yel5.js"
|
||||||
],
|
],
|
||||||
"resources/js/components/artwork/ArtworkTags.jsx": [],
|
"resources/js/components/artwork/ArtworkTags.jsx": [],
|
||||||
"resources/js/components/artwork/AuthorBioPopover.jsx": [],
|
"resources/js/components/artwork/AuthorBioPopover.jsx": [],
|
||||||
|
|||||||
7189
bootstrap/ssr/ssr.js
7189
bootstrap/ssr/ssr.js
File diff suppressed because one or more lines are too long
572
composer.lock
generated
572
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -21,4 +21,24 @@ return [
|
|||||||
'advanced',
|
'advanced',
|
||||||
'pro',
|
'pro',
|
||||||
],
|
],
|
||||||
|
'prompt_comparison' => [
|
||||||
|
'providers' => [
|
||||||
|
'ChatGPT',
|
||||||
|
'Gemini',
|
||||||
|
'Leonardo',
|
||||||
|
'Bing',
|
||||||
|
'Midjourney',
|
||||||
|
'Flux',
|
||||||
|
'Stable Diffusion',
|
||||||
|
],
|
||||||
|
'models' => [
|
||||||
|
'4o Image',
|
||||||
|
'Gemini Image',
|
||||||
|
'Phoenix',
|
||||||
|
'Designer',
|
||||||
|
'V7',
|
||||||
|
'Flux Pro',
|
||||||
|
'SDXL',
|
||||||
|
],
|
||||||
|
],
|
||||||
];
|
];
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('academy_lessons', function (Blueprint $table): void {
|
||||||
|
$table->longText('content_markdown')->nullable()->after('content');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('academy_lessons', function (Blueprint $table): void {
|
||||||
|
$table->dropColumn('content_markdown');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('academy_lessons', function (Blueprint $table): void {
|
||||||
|
$table->unsignedInteger('lesson_number')->nullable()->after('slug');
|
||||||
|
$table->unsignedInteger('course_order')->nullable()->after('lesson_number');
|
||||||
|
$table->string('series_name', 120)->nullable()->after('course_order');
|
||||||
|
|
||||||
|
$table->index(['series_name', 'course_order', 'lesson_number'], 'academy_lessons_series_course_order_index');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('academy_lessons', function (Blueprint $table): void {
|
||||||
|
$table->dropIndex('academy_lessons_series_course_order_index');
|
||||||
|
$table->dropColumn(['lesson_number', 'course_order', 'series_name']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('academy_lessons', function (Blueprint $table): void {
|
||||||
|
$table->string('article_cover_image')->nullable()->after('cover_image');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('academy_lessons', function (Blueprint $table): void {
|
||||||
|
$table->dropColumn('article_cover_image');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('academy_lessons', function (Blueprint $table): void {
|
||||||
|
$table->json('tags')->nullable()->after('article_cover_image');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('academy_lessons', function (Blueprint $table): void {
|
||||||
|
$table->dropColumn('tags');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('academy_courses', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
$table->string('title');
|
||||||
|
$table->string('slug')->unique();
|
||||||
|
$table->string('subtitle')->nullable();
|
||||||
|
$table->text('excerpt')->nullable();
|
||||||
|
$table->longText('description')->nullable();
|
||||||
|
$table->string('cover_image')->nullable();
|
||||||
|
$table->string('teaser_image')->nullable();
|
||||||
|
$table->string('access_level')->default('free');
|
||||||
|
$table->string('difficulty')->default('beginner');
|
||||||
|
$table->string('status')->default('draft');
|
||||||
|
$table->boolean('is_featured')->default(false);
|
||||||
|
$table->unsignedInteger('order_num')->default(0);
|
||||||
|
$table->unsignedInteger('estimated_minutes')->nullable();
|
||||||
|
$table->unsignedInteger('lessons_count_cache')->default(0);
|
||||||
|
$table->timestamp('published_at')->nullable();
|
||||||
|
$table->string('seo_title')->nullable();
|
||||||
|
$table->text('seo_description')->nullable();
|
||||||
|
$table->text('meta_keywords')->nullable();
|
||||||
|
$table->string('og_title')->nullable();
|
||||||
|
$table->text('og_description')->nullable();
|
||||||
|
$table->string('og_image')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
$table->softDeletes();
|
||||||
|
|
||||||
|
$table->index(['status', 'published_at']);
|
||||||
|
$table->index(['is_featured', 'order_num']);
|
||||||
|
$table->index(['access_level', 'difficulty']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('academy_courses');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('academy_course_sections', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('course_id')->constrained('academy_courses')->cascadeOnDelete();
|
||||||
|
$table->string('title');
|
||||||
|
$table->string('slug')->nullable();
|
||||||
|
$table->text('description')->nullable();
|
||||||
|
$table->unsignedInteger('order_num')->default(0);
|
||||||
|
$table->boolean('is_visible')->default(true);
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->index(['course_id', 'order_num']);
|
||||||
|
$table->unique(['course_id', 'slug']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('academy_course_sections');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('academy_course_lessons', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('course_id')->constrained('academy_courses')->cascadeOnDelete();
|
||||||
|
$table->foreignId('section_id')->nullable()->constrained('academy_course_sections')->nullOnDelete();
|
||||||
|
$table->foreignId('lesson_id')->constrained('academy_lessons')->cascadeOnDelete();
|
||||||
|
$table->unsignedInteger('order_num')->default(0);
|
||||||
|
$table->boolean('is_required')->default(true);
|
||||||
|
$table->string('access_override')->nullable();
|
||||||
|
$table->foreignId('unlock_after_lesson_id')->nullable()->constrained('academy_lessons')->nullOnDelete();
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->unique(['course_id', 'lesson_id']);
|
||||||
|
$table->index(['course_id', 'section_id', 'order_num']);
|
||||||
|
$table->index(['lesson_id']);
|
||||||
|
$table->index(['is_required']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('academy_course_lessons');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('academy_course_enrollments', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||||
|
$table->foreignId('course_id')->constrained('academy_courses')->cascadeOnDelete();
|
||||||
|
$table->string('status')->default('active');
|
||||||
|
$table->foreignId('last_lesson_id')->nullable()->constrained('academy_lessons')->nullOnDelete();
|
||||||
|
$table->timestamp('started_at')->nullable();
|
||||||
|
$table->timestamp('completed_at')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->unique(['user_id', 'course_id']);
|
||||||
|
$table->index(['course_id', 'status']);
|
||||||
|
$table->index(['user_id', 'status']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('academy_course_enrollments');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('academy_lesson_revisions', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('lesson_id')->constrained('academy_lessons')->cascadeOnDelete();
|
||||||
|
$table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
|
||||||
|
$table->string('change_note', 255)->nullable();
|
||||||
|
$table->json('snapshot_json');
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->index(['lesson_id', 'created_at']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('academy_lesson_revisions');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -28,8 +28,12 @@ class AcademyDemoSeeder extends Seeder
|
|||||||
'slug' => 'what-is-ai-assisted-digital-art',
|
'slug' => 'what-is-ai-assisted-digital-art',
|
||||||
'category_slug' => 'prompting-basics',
|
'category_slug' => 'prompting-basics',
|
||||||
'title' => 'What Is AI-Assisted Digital Art?',
|
'title' => 'What Is AI-Assisted Digital Art?',
|
||||||
|
'lesson_number' => 1,
|
||||||
|
'course_order' => 1,
|
||||||
|
'series_name' => 'AI Art Basics',
|
||||||
'excerpt' => 'A grounded overview of how Skinbase creators can use AI as a creative assistant instead of a shortcut.',
|
'excerpt' => 'A grounded overview of how Skinbase creators can use AI as a creative assistant instead of a shortcut.',
|
||||||
'content' => 'AI-assisted digital art combines prompt-driven ideation, composition decisions, and manual finishing into a single workflow that still depends on taste and intent.',
|
'content' => 'AI-assisted digital art combines prompt-driven ideation, composition decisions, and manual finishing into a single workflow that still depends on taste and intent.',
|
||||||
|
'tags' => ['ai art', 'basics', 'workflow'],
|
||||||
'difficulty' => 'beginner',
|
'difficulty' => 'beginner',
|
||||||
'access_level' => 'free',
|
'access_level' => 'free',
|
||||||
'lesson_type' => 'article',
|
'lesson_type' => 'article',
|
||||||
@@ -39,19 +43,42 @@ class AcademyDemoSeeder extends Seeder
|
|||||||
'slug' => 'prompting-basics-for-skinbase-creators',
|
'slug' => 'prompting-basics-for-skinbase-creators',
|
||||||
'category_slug' => 'prompting-basics',
|
'category_slug' => 'prompting-basics',
|
||||||
'title' => 'Prompting Basics for Skinbase Creators',
|
'title' => 'Prompting Basics for Skinbase Creators',
|
||||||
|
'lesson_number' => 2,
|
||||||
|
'course_order' => 2,
|
||||||
|
'series_name' => 'AI Art Basics',
|
||||||
'excerpt' => 'Learn how to describe subject, mood, lighting, composition, and finish for Skinbase-native prompt workflows.',
|
'excerpt' => 'Learn how to describe subject, mood, lighting, composition, and finish for Skinbase-native prompt workflows.',
|
||||||
'content' => 'Start with a clear subject. Add mood and lighting. Then anchor the composition for the output you actually want to upload.',
|
'content' => 'Start with a clear subject. Add mood and lighting. Then anchor the composition for the output you actually want to upload.',
|
||||||
|
'tags' => ['prompting', 'composition', 'lighting'],
|
||||||
'difficulty' => 'beginner',
|
'difficulty' => 'beginner',
|
||||||
'access_level' => 'free',
|
'access_level' => 'free',
|
||||||
'lesson_type' => 'article',
|
'lesson_type' => 'article',
|
||||||
'featured' => true,
|
'featured' => true,
|
||||||
],
|
],
|
||||||
|
[
|
||||||
|
'slug' => 'from-prompt-to-finished-artwork',
|
||||||
|
'category_slug' => 'prompting-basics',
|
||||||
|
'title' => 'From Prompt to Finished Artwork',
|
||||||
|
'lesson_number' => 3,
|
||||||
|
'course_order' => 3,
|
||||||
|
'series_name' => 'AI Art Basics',
|
||||||
|
'excerpt' => 'Move from raw generations to polished, upload-ready artwork with a clear finishing workflow.',
|
||||||
|
'content' => 'Review the generation, choose the best candidate, clean up weak areas, and prepare the final upload with intentional metadata and presentation.',
|
||||||
|
'tags' => ['editing', 'cleanup', 'publishing'],
|
||||||
|
'difficulty' => 'beginner',
|
||||||
|
'access_level' => 'free',
|
||||||
|
'lesson_type' => 'workflow',
|
||||||
|
'featured' => true,
|
||||||
|
],
|
||||||
[
|
[
|
||||||
'slug' => 'ai-ethics-and-skinbase-upload-rules',
|
'slug' => 'ai-ethics-and-skinbase-upload-rules',
|
||||||
'category_slug' => 'ai-ethics',
|
'category_slug' => 'ai-ethics',
|
||||||
'title' => 'AI Ethics and Skinbase Upload Rules',
|
'title' => 'AI Ethics and Skinbase Upload Rules',
|
||||||
|
'lesson_number' => 7,
|
||||||
|
'course_order' => 7,
|
||||||
|
'series_name' => 'AI Art Basics',
|
||||||
'excerpt' => 'Use AI responsibly, label your workflow clearly, and avoid imitation-based prompt packs.',
|
'excerpt' => 'Use AI responsibly, label your workflow clearly, and avoid imitation-based prompt packs.',
|
||||||
'content' => 'Respect artists, disclose your workflow, and avoid packaging prompts around living-artist imitation or deceptive attribution.',
|
'content' => 'Respect artists, disclose your workflow, and avoid packaging prompts around living-artist imitation or deceptive attribution.',
|
||||||
|
'tags' => ['ethics', 'rules', 'publishing'],
|
||||||
'difficulty' => 'beginner',
|
'difficulty' => 'beginner',
|
||||||
'access_level' => 'free',
|
'access_level' => 'free',
|
||||||
'lesson_type' => 'ethics',
|
'lesson_type' => 'ethics',
|
||||||
@@ -61,8 +88,12 @@ class AcademyDemoSeeder extends Seeder
|
|||||||
'slug' => 'how-to-prepare-ai-artwork-for-upload',
|
'slug' => 'how-to-prepare-ai-artwork-for-upload',
|
||||||
'category_slug' => 'wallpapers',
|
'category_slug' => 'wallpapers',
|
||||||
'title' => 'How to Prepare AI Artwork for Upload',
|
'title' => 'How to Prepare AI Artwork for Upload',
|
||||||
|
'lesson_number' => 8,
|
||||||
|
'course_order' => 8,
|
||||||
|
'series_name' => 'AI Art Basics',
|
||||||
'excerpt' => 'A cleanup checklist for exporting Academy-ready artwork to Skinbase.',
|
'excerpt' => 'A cleanup checklist for exporting Academy-ready artwork to Skinbase.',
|
||||||
'content' => 'Check crop, upscale carefully, fix edge artifacts, and export a final image that fits the target category and composition.',
|
'content' => 'Check crop, upscale carefully, fix edge artifacts, and export a final image that fits the target category and composition.',
|
||||||
|
'tags' => ['export', 'quality control', 'upload'],
|
||||||
'difficulty' => 'beginner',
|
'difficulty' => 'beginner',
|
||||||
'access_level' => 'free',
|
'access_level' => 'free',
|
||||||
'lesson_type' => 'workflow',
|
'lesson_type' => 'workflow',
|
||||||
@@ -74,6 +105,7 @@ class AcademyDemoSeeder extends Seeder
|
|||||||
'title' => 'Advanced Wallpaper Prompt Engineering',
|
'title' => 'Advanced Wallpaper Prompt Engineering',
|
||||||
'excerpt' => 'Build prompts that control focal depth, desktop negative space, and repeatable atmosphere.',
|
'excerpt' => 'Build prompts that control focal depth, desktop negative space, and repeatable atmosphere.',
|
||||||
'content' => 'Use layered subject directives, desktop-safe composition notes, and finishing instructions to keep wallpapers clean and reusable.',
|
'content' => 'Use layered subject directives, desktop-safe composition notes, and finishing instructions to keep wallpapers clean and reusable.',
|
||||||
|
'tags' => ['wallpapers', 'prompting', 'advanced'],
|
||||||
'difficulty' => 'advanced',
|
'difficulty' => 'advanced',
|
||||||
'access_level' => 'creator',
|
'access_level' => 'creator',
|
||||||
'lesson_type' => 'workflow',
|
'lesson_type' => 'workflow',
|
||||||
@@ -85,6 +117,7 @@ class AcademyDemoSeeder extends Seeder
|
|||||||
'title' => 'Creating Consistent Robot Mascots',
|
'title' => 'Creating Consistent Robot Mascots',
|
||||||
'excerpt' => 'Keep mascot silhouettes, palettes, and expression readable across multiple prompt iterations.',
|
'excerpt' => 'Keep mascot silhouettes, palettes, and expression readable across multiple prompt iterations.',
|
||||||
'content' => 'Define anchor traits first, then preserve them with repeated descriptors and a fixed framing recipe across generations.',
|
'content' => 'Define anchor traits first, then preserve them with repeated descriptors and a fixed framing recipe across generations.',
|
||||||
|
'tags' => ['character design', 'consistency', 'iteration'],
|
||||||
'difficulty' => 'advanced',
|
'difficulty' => 'advanced',
|
||||||
'access_level' => 'creator',
|
'access_level' => 'creator',
|
||||||
'lesson_type' => 'workflow',
|
'lesson_type' => 'workflow',
|
||||||
@@ -96,6 +129,7 @@ class AcademyDemoSeeder extends Seeder
|
|||||||
'title' => 'Building a Skinbase World From Scratch',
|
'title' => 'Building a Skinbase World From Scratch',
|
||||||
'excerpt' => 'Plan a world cover, atmosphere kit, and visual language that feels coherent beyond one image.',
|
'excerpt' => 'Plan a world cover, atmosphere kit, and visual language that feels coherent beyond one image.',
|
||||||
'content' => 'Start from a world premise, define color anchors, then generate covers, teaser crops, and supporting scenes that share the same visual DNA.',
|
'content' => 'Start from a world premise, define color anchors, then generate covers, teaser crops, and supporting scenes that share the same visual DNA.',
|
||||||
|
'tags' => ['worldbuilding', 'visual language', 'series'],
|
||||||
'difficulty' => 'pro',
|
'difficulty' => 'pro',
|
||||||
'access_level' => 'pro',
|
'access_level' => 'pro',
|
||||||
'lesson_type' => 'workflow',
|
'lesson_type' => 'workflow',
|
||||||
@@ -107,6 +141,7 @@ class AcademyDemoSeeder extends Seeder
|
|||||||
'title' => 'Creating Editorial News Cover Images',
|
'title' => 'Creating Editorial News Cover Images',
|
||||||
'excerpt' => 'Design news cover art that reads fast, supports headlines, and avoids clutter.',
|
'excerpt' => 'Design news cover art that reads fast, supports headlines, and avoids clutter.',
|
||||||
'content' => 'Compose with headline space in mind, simplify the focal idea, and leave enough structure for editorial overlays to stay readable.',
|
'content' => 'Compose with headline space in mind, simplify the focal idea, and leave enough structure for editorial overlays to stay readable.',
|
||||||
|
'tags' => ['covers', 'editorial', 'composition'],
|
||||||
'difficulty' => 'pro',
|
'difficulty' => 'pro',
|
||||||
'access_level' => 'pro',
|
'access_level' => 'pro',
|
||||||
'lesson_type' => 'workflow',
|
'lesson_type' => 'workflow',
|
||||||
@@ -120,8 +155,12 @@ class AcademyDemoSeeder extends Seeder
|
|||||||
[
|
[
|
||||||
'category_id' => $categories->get($lesson['category_slug'])?->id,
|
'category_id' => $categories->get($lesson['category_slug'])?->id,
|
||||||
'title' => $lesson['title'],
|
'title' => $lesson['title'],
|
||||||
|
'lesson_number' => $lesson['lesson_number'] ?? null,
|
||||||
|
'course_order' => $lesson['course_order'] ?? null,
|
||||||
|
'series_name' => $lesson['series_name'] ?? null,
|
||||||
'excerpt' => $lesson['excerpt'],
|
'excerpt' => $lesson['excerpt'],
|
||||||
'content' => $lesson['content'],
|
'content' => $lesson['content'],
|
||||||
|
'tags' => $lesson['tags'] ?? [],
|
||||||
'difficulty' => $lesson['difficulty'],
|
'difficulty' => $lesson['difficulty'],
|
||||||
'access_level' => $lesson['access_level'],
|
'access_level' => $lesson['access_level'],
|
||||||
'lesson_type' => $lesson['lesson_type'],
|
'lesson_type' => $lesson['lesson_type'],
|
||||||
@@ -161,6 +200,38 @@ class AcademyDemoSeeder extends Seeder
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$plannedLessonUpdates = [
|
||||||
|
'writing-better-wallpaper-prompts' => [
|
||||||
|
'lesson_number' => 4,
|
||||||
|
'course_order' => 4,
|
||||||
|
'series_name' => 'AI Art Basics',
|
||||||
|
],
|
||||||
|
'style-mood-lighting-and-composition' => [
|
||||||
|
'lesson_number' => 5,
|
||||||
|
'course_order' => 5,
|
||||||
|
'series_name' => 'AI Art Basics',
|
||||||
|
],
|
||||||
|
'negative-prompts-and-quality-control' => [
|
||||||
|
'lesson_number' => 6,
|
||||||
|
'course_order' => 6,
|
||||||
|
'series_name' => 'AI Art Basics',
|
||||||
|
],
|
||||||
|
'titles-tags-categories-and-discovery' => [
|
||||||
|
'lesson_number' => 9,
|
||||||
|
'course_order' => 9,
|
||||||
|
'series_name' => 'AI Art Basics',
|
||||||
|
],
|
||||||
|
'from-idea-to-artwork-a-simple-skinbase-workflow' => [
|
||||||
|
'lesson_number' => 10,
|
||||||
|
'course_order' => 10,
|
||||||
|
'series_name' => 'AI Art Basics',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($plannedLessonUpdates as $slug => $attributes) {
|
||||||
|
AcademyLesson::query()->where('slug', $slug)->update($attributes);
|
||||||
|
}
|
||||||
|
|
||||||
$prompts = [
|
$prompts = [
|
||||||
[
|
[
|
||||||
'slug' => 'fantasy-floating-island-wallpaper',
|
'slug' => 'fantasy-floating-island-wallpaper',
|
||||||
|
|||||||
@@ -233,7 +233,9 @@ Admin and maintenance commands print directly to the terminal.
|
|||||||
|
|
||||||
Admins can review biographies in cPad at:
|
Admins can review biographies in cPad at:
|
||||||
|
|
||||||
- `/cp/ai-biography`
|
- `/moderation/ai-biography`
|
||||||
|
|
||||||
|
The old `/cp/ai-biography` entry now redirects to the moderation surface.
|
||||||
|
|
||||||
This surface shows stored records, review flags, failures, hidden states, and rebuild controls.
|
This surface shows stored records, review flags, failures, hidden states, and rebuild controls.
|
||||||
|
|
||||||
@@ -396,12 +398,12 @@ It should not refresh just because of tiny download increments or ordering noise
|
|||||||
|
|
||||||
### Admin cPad
|
### Admin cPad
|
||||||
|
|
||||||
- `GET /cp/ai-biography`
|
- `GET /moderation/ai-biography`
|
||||||
- `POST /cp/ai-biography/users/{user}/rebuild`
|
- `POST /moderation/ai-biography/users/{user}/rebuild`
|
||||||
- `POST /cp/ai-biography/records/{biography}/approve`
|
- `POST /moderation/ai-biography/records/{biography}/approve`
|
||||||
- `POST /cp/ai-biography/records/{biography}/flag`
|
- `POST /moderation/ai-biography/records/{biography}/flag`
|
||||||
- `POST /cp/ai-biography/records/{biography}/hide`
|
- `POST /moderation/ai-biography/records/{biography}/hide`
|
||||||
- `POST /cp/ai-biography/records/{biography}/show`
|
- `POST /moderation/ai-biography/records/{biography}/show`
|
||||||
|
|
||||||
## What the Public API Returns
|
## What the Public API Returns
|
||||||
|
|
||||||
|
|||||||
35
package-lock.json
generated
35
package-lock.json
generated
@@ -27,11 +27,13 @@
|
|||||||
"highlight.js": "^11.11.1",
|
"highlight.js": "^11.11.1",
|
||||||
"laravel-echo": "^2.3.1",
|
"laravel-echo": "^2.3.1",
|
||||||
"lowlight": "^3.3.0",
|
"lowlight": "^3.3.0",
|
||||||
|
"marked": "^18.0.3",
|
||||||
"pusher-js": "^8.4.3",
|
"pusher-js": "^8.4.3",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"tippy.js": "^6.3.7"
|
"tippy.js": "^6.3.7",
|
||||||
|
"turndown": "^7.2.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.40.0",
|
"@playwright/test": "^1.40.0",
|
||||||
@@ -781,6 +783,12 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@mixmark-io/domino": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@mixmark-io/domino/-/domino-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==",
|
||||||
|
"license": "BSD-2-Clause"
|
||||||
|
},
|
||||||
"node_modules/@nodelib/fs.scandir": {
|
"node_modules/@nodelib/fs.scandir": {
|
||||||
"version": "2.1.5",
|
"version": "2.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||||
@@ -4592,6 +4600,18 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/marked": {
|
||||||
|
"version": "18.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/marked/-/marked-18.0.3.tgz",
|
||||||
|
"integrity": "sha512-7VT90JOkDeaRWpfjOReRGPEKn0ecdARBkDGL+tT1wZY0efPPqkUxLUSmzy/C7TIylQYJC9STISEsCHrqb/7VIA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"marked": "bin/marked.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/math-intrinsics": {
|
"node_modules/math-intrinsics": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||||
@@ -6815,6 +6835,19 @@
|
|||||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||||
"license": "0BSD"
|
"license": "0BSD"
|
||||||
},
|
},
|
||||||
|
"node_modules/turndown": {
|
||||||
|
"version": "7.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/turndown/-/turndown-7.2.4.tgz",
|
||||||
|
"integrity": "sha512-I8yFsfRzmzK0WV1pNNOA4A7y4RDfFxPRxb3t+e3ui14qSGOxGtiSP6GjeX+Y6CHb7HYaFj7ECUD7VE5kQMZWGQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@mixmark-io/domino": "^2.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18",
|
||||||
|
"npm": ">=9"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/tweetnacl": {
|
"node_modules/tweetnacl": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz",
|
||||||
|
|||||||
@@ -55,10 +55,12 @@
|
|||||||
"highlight.js": "^11.11.1",
|
"highlight.js": "^11.11.1",
|
||||||
"laravel-echo": "^2.3.1",
|
"laravel-echo": "^2.3.1",
|
||||||
"lowlight": "^3.3.0",
|
"lowlight": "^3.3.0",
|
||||||
|
"marked": "^18.0.3",
|
||||||
"pusher-js": "^8.4.3",
|
"pusher-js": "^8.4.3",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"tippy.js": "^6.3.7"
|
"tippy.js": "^6.3.7",
|
||||||
|
"turndown": "^7.2.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1339,6 +1339,11 @@
|
|||||||
text-wrap: pretty;
|
text-wrap: pretty;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.academy-lesson-prose blockquote p,
|
||||||
|
.academy-lesson-prose li p {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.academy-lesson-prose p + p {
|
.academy-lesson-prose p + p {
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
}
|
}
|
||||||
@@ -1420,6 +1425,54 @@
|
|||||||
box-shadow: 0 0 0 4px rgba(56, 189, 248, 0.08);
|
box-shadow: 0 0 0 4px rgba(56, 189, 248, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.academy-lesson-prose li:has(> input[type='checkbox']),
|
||||||
|
.academy-lesson-prose li:has(> p input[type='checkbox']) {
|
||||||
|
padding-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.academy-lesson-prose li:has(> input[type='checkbox'])::before,
|
||||||
|
.academy-lesson-prose li:has(> p input[type='checkbox'])::before {
|
||||||
|
content: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.academy-lesson-prose input[type='checkbox'] {
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
display: inline-block;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
min-width: 18px;
|
||||||
|
min-height: 18px;
|
||||||
|
margin: 0.1rem 0.7rem 0 0;
|
||||||
|
vertical-align: top;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.25);
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
background-color: rgba(255, 255, 255, 0.06);
|
||||||
|
background-position: center;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-size: 10px 10px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
opacity: 1;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.academy-lesson-prose input[type='checkbox']:checked {
|
||||||
|
border-color: #E07A21;
|
||||||
|
background-color: #E07A21;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' fill='none'%3E%3Cpath d='M1.5 6l3 3 6-6' stroke='white' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
|
||||||
|
box-shadow: 0 0 0 0 rgba(224, 122, 33, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.academy-lesson-prose input[type='checkbox']:disabled {
|
||||||
|
opacity: 1;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.academy-lesson-prose li > p:has(input[type='checkbox']) {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
.academy-lesson-prose ol li::before {
|
.academy-lesson-prose ol li::before {
|
||||||
counter-increment: lesson-ordered-list;
|
counter-increment: lesson-ordered-list;
|
||||||
content: counter(lesson-ordered-list);
|
content: counter(lesson-ordered-list);
|
||||||
@@ -1605,7 +1658,7 @@
|
|||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: 0.8rem;
|
gap: 0.8rem;
|
||||||
border-radius: 1rem;
|
border-radius: 1rem;
|
||||||
padding: 0.8rem 0.9rem;
|
padding: 0.3rem 0.4rem;
|
||||||
color: rgb(226 232 240 / 0.88);
|
color: rgb(226 232 240 / 0.88);
|
||||||
font-size: 0.96rem;
|
font-size: 0.96rem;
|
||||||
line-height: 1.55;
|
line-height: 1.55;
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ const buildAdminNavGroups = (isAdmin) => [
|
|||||||
label: 'Academy',
|
label: 'Academy',
|
||||||
items: [
|
items: [
|
||||||
{ label: 'Academy Dashboard', href: '/moderation/academy/dashboard', icon: 'fa-solid fa-graduation-cap' },
|
{ label: 'Academy Dashboard', href: '/moderation/academy/dashboard', icon: 'fa-solid fa-graduation-cap' },
|
||||||
|
{ label: 'Academy Courses', href: '/moderation/academy/courses', icon: 'fa-solid fa-road' },
|
||||||
{ label: 'Academy Lessons', href: '/moderation/academy/lessons', icon: 'fa-solid fa-book-open' },
|
{ label: 'Academy Lessons', href: '/moderation/academy/lessons', icon: 'fa-solid fa-book-open' },
|
||||||
{ label: 'Academy Prompts', href: '/moderation/academy/prompts', icon: 'fa-solid fa-wand-magic-sparkles' },
|
{ label: 'Academy Prompts', href: '/moderation/academy/prompts', icon: 'fa-solid fa-wand-magic-sparkles' },
|
||||||
{ label: 'Academy Challenges', href: '/moderation/academy/challenges', icon: 'fa-solid fa-trophy' },
|
{ label: 'Academy Challenges', href: '/moderation/academy/challenges', icon: 'fa-solid fa-trophy' },
|
||||||
|
|||||||
125
resources/js/Pages/Academy/CoursesIndex.jsx
Normal file
125
resources/js/Pages/Academy/CoursesIndex.jsx
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { Link, router, usePage } from '@inertiajs/react'
|
||||||
|
import SeoHead from '../../components/seo/SeoHead'
|
||||||
|
import NovaSelect from '../../components/ui/NovaSelect'
|
||||||
|
|
||||||
|
function CourseCard({ course, variant = 'default' }) {
|
||||||
|
const isFeatured = variant === 'featured'
|
||||||
|
const progress = course?.progress || null
|
||||||
|
const cover = course?.cover_image_url || course?.teaser_image_url || course?.cover_image || course?.teaser_image || ''
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={course.public_url}
|
||||||
|
className={[
|
||||||
|
'group overflow-hidden rounded-[30px] border border-white/10 transition hover:border-sky-300/25 hover:bg-white/[0.06]',
|
||||||
|
isFeatured ? 'bg-[linear-gradient(135deg,rgba(14,165,233,0.14),rgba(15,23,42,0.92))]' : 'bg-white/[0.04]',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
<div className="relative">
|
||||||
|
{cover ? <img src={cover} alt="" aria-hidden="true" className={`w-full object-cover ${isFeatured ? 'h-56' : 'h-44'}`} /> : <div className={`w-full bg-[linear-gradient(135deg,rgba(14,165,233,0.22),rgba(15,23,42,0.92))] ${isFeatured ? 'h-56' : 'h-44'}`} />}
|
||||||
|
<div className="absolute inset-0 bg-[linear-gradient(180deg,transparent,rgba(2,6,23,0.82))]" />
|
||||||
|
<div className="absolute left-5 top-5 flex flex-wrap gap-2">
|
||||||
|
<span className="rounded-full border border-white/10 bg-black/30 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-100">{course.difficulty}</span>
|
||||||
|
<span className="rounded-full border border-white/10 bg-black/30 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-200">{course.access_level}</span>
|
||||||
|
{course.is_featured ? <span className="rounded-full border border-amber-300/20 bg-amber-300/15 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.22em] text-amber-100">Featured</span> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6">
|
||||||
|
<h2 className={`font-semibold tracking-[-0.05em] text-white transition group-hover:text-sky-100 ${isFeatured ? 'text-3xl' : 'text-2xl'}`}>{course.title}</h2>
|
||||||
|
{course.subtitle ? <p className="mt-2 text-sm font-medium uppercase tracking-[0.18em] text-slate-400">{course.subtitle}</p> : null}
|
||||||
|
<p className="mt-4 text-sm leading-7 text-slate-300">{course.excerpt || course.description || 'Structured Academy course.'}</p>
|
||||||
|
|
||||||
|
<div className="mt-5 grid gap-3 sm:grid-cols-3">
|
||||||
|
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3">
|
||||||
|
<p className="text-[10px] font-semibold uppercase tracking-[0.22em] text-slate-500">Lessons</p>
|
||||||
|
<p className="mt-2 text-sm font-semibold text-white">{course.lessons_count || 0}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3">
|
||||||
|
<p className="text-[10px] font-semibold uppercase tracking-[0.22em] text-slate-500">Duration</p>
|
||||||
|
<p className="mt-2 text-sm font-semibold text-white">{course.estimated_minutes ? `${course.estimated_minutes} min` : 'Flexible'}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3">
|
||||||
|
<p className="text-[10px] font-semibold uppercase tracking-[0.22em] text-slate-500">Progress</p>
|
||||||
|
<p className="mt-2 text-sm font-semibold text-white">{progress ? `${progress.percent}%` : 'Start fresh'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AcademyCoursesIndex({ seo, title, description, items, featuredCourses = [], filters = {}, pricingUrl }) {
|
||||||
|
const flash = usePage().props.flash || {}
|
||||||
|
const difficultyOptions = [
|
||||||
|
{ value: '', label: 'All levels' },
|
||||||
|
{ value: 'beginner', label: 'Beginner' },
|
||||||
|
{ value: 'intermediate', label: 'Intermediate' },
|
||||||
|
{ value: 'advanced', label: 'Advanced' },
|
||||||
|
]
|
||||||
|
const accessOptions = [
|
||||||
|
{ value: '', label: 'All access' },
|
||||||
|
{ value: 'free', label: 'Free' },
|
||||||
|
{ value: 'premium', label: 'Premium' },
|
||||||
|
{ value: 'mixed', label: 'Mixed' },
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.16),_transparent_24%),radial-gradient(circle_at_bottom_right,_rgba(251,191,36,0.16),_transparent_24%),linear-gradient(180deg,_#0f172a_0%,_#111827_100%)] px-4 py-8 sm:px-6 lg:px-8">
|
||||||
|
<SeoHead seo={seo || {}} title={title} description={description} />
|
||||||
|
|
||||||
|
<div className="mx-auto max-w-[1400px] space-y-6">
|
||||||
|
<section className="rounded-[40px] border border-white/10 bg-[linear-gradient(135deg,rgba(15,23,42,0.96),rgba(14,165,233,0.12))] p-8 shadow-[0_24px_90px_rgba(2,6,23,0.36)] md:p-10 lg:p-12">
|
||||||
|
<div className="flex flex-wrap items-end justify-between gap-6">
|
||||||
|
<div className="max-w-4xl">
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-sky-200/80">Skinbase AI Academy</p>
|
||||||
|
<h1 className="mt-4 text-4xl font-semibold tracking-[-0.055em] text-white md:text-5xl lg:text-6xl">{title}</h1>
|
||||||
|
<p className="mt-5 text-base leading-8 text-slate-300 md:text-lg">{description}</p>
|
||||||
|
</div>
|
||||||
|
<Link href={pricingUrl} className="rounded-full border border-amber-300/25 bg-amber-300/12 px-5 py-3 text-sm font-semibold text-amber-100">See Academy plans</Link>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{flash.success ? <div className="rounded-2xl border border-emerald-300/20 bg-emerald-300/10 px-4 py-3 text-sm text-emerald-100">{flash.success}</div> : null}
|
||||||
|
{flash.error ? <div className="rounded-2xl border border-rose-300/20 bg-rose-300/10 px-4 py-3 text-sm text-rose-100">{flash.error}</div> : null}
|
||||||
|
|
||||||
|
{featuredCourses.length ? (
|
||||||
|
<section className="grid gap-5 xl:grid-cols-[minmax(0,1.4fr)_minmax(0,1fr)]">
|
||||||
|
<CourseCard course={featuredCourses[0]} variant="featured" />
|
||||||
|
<div className="grid gap-5">
|
||||||
|
{featuredCourses.slice(1, 3).map((course) => <CourseCard key={course.id} course={course} />)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<section className="grid gap-3 rounded-[30px] border border-white/10 bg-black/20 p-5 md:grid-cols-2">
|
||||||
|
<NovaSelect
|
||||||
|
label="Difficulty"
|
||||||
|
value={filters?.difficulty || ''}
|
||||||
|
onChange={(nextValue) => router.get(window.location.pathname, { ...filters, difficulty: nextValue || undefined }, { preserveScroll: true, preserveState: true })}
|
||||||
|
options={difficultyOptions}
|
||||||
|
searchable={false}
|
||||||
|
className="rounded-2xl bg-white/[0.04]"
|
||||||
|
/>
|
||||||
|
<NovaSelect
|
||||||
|
label="Access"
|
||||||
|
value={filters?.access || ''}
|
||||||
|
onChange={(nextValue) => router.get(window.location.pathname, { ...filters, access: nextValue || undefined }, { preserveScroll: true, preserveState: true })}
|
||||||
|
options={accessOptions}
|
||||||
|
searchable={false}
|
||||||
|
className="rounded-2xl bg-white/[0.04]"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{(items?.data || []).length === 0 ? (
|
||||||
|
<section className="rounded-[30px] border border-white/10 bg-white/[0.04] px-6 py-12 text-center text-slate-400">No published Academy courses matched these filters.</section>
|
||||||
|
) : (
|
||||||
|
<section className="grid gap-5 md:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{items.data.map((course) => <CourseCard key={course.id} course={course} />)}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
339
resources/js/Pages/Academy/CoursesShow.jsx
Normal file
339
resources/js/Pages/Academy/CoursesShow.jsx
Normal file
@@ -0,0 +1,339 @@
|
|||||||
|
import React, { useEffect, useMemo, useState } from 'react'
|
||||||
|
import { Link, usePage } from '@inertiajs/react'
|
||||||
|
import SeoHead from '../../components/seo/SeoHead'
|
||||||
|
|
||||||
|
function CourseBreadcrumbs({ items = [] }) {
|
||||||
|
if (!items.length) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav aria-label="Breadcrumb" className="flex flex-wrap items-center gap-2 text-sm text-slate-400">
|
||||||
|
{items.map((item, index) => {
|
||||||
|
const isLast = index === items.length - 1
|
||||||
|
|
||||||
|
return (
|
||||||
|
<React.Fragment key={`${item.label}-${index}`}>
|
||||||
|
{index > 0 ? <span className="text-slate-600">/</span> : null}
|
||||||
|
{isLast ? (
|
||||||
|
<span className="font-medium text-slate-200">{item.label}</span>
|
||||||
|
) : (
|
||||||
|
<Link href={item.href} className="transition hover:text-white">
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProgressMeter({ progress }) {
|
||||||
|
const percent = Math.max(0, Math.min(100, Number(progress?.percent || 0)))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-[28px] border border-white/10 bg-[linear-gradient(180deg,rgba(15,23,42,0.92),rgba(15,23,42,0.72))] p-5">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Progress</p>
|
||||||
|
<p className="mt-2 text-3xl font-semibold tracking-[-0.04em] text-white">{percent}%</p>
|
||||||
|
</div>
|
||||||
|
<span className="rounded-full border border-white/10 bg-white/[0.05] px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-300">
|
||||||
|
{progress ? 'In progress' : 'Not started'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 h-2.5 overflow-hidden rounded-full bg-white/[0.06]">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-[linear-gradient(90deg,rgba(125,211,252,0.95),rgba(251,191,36,0.9))] transition-[width] duration-500"
|
||||||
|
style={{ width: `${percent}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="mt-3 text-sm leading-7 text-slate-300">
|
||||||
|
{progress
|
||||||
|
? `${progress.completedRequired}/${progress.totalRequired} required lessons completed`
|
||||||
|
: 'Start the course to begin tracking progress through required lessons.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function LessonChip({ lesson }) {
|
||||||
|
const thumbnail = lesson?.cover_image_url || lesson?.article_cover_image_url || lesson?.cover_image || lesson?.article_cover_image || ''
|
||||||
|
const stepLabel = lesson?.course_step_label || null
|
||||||
|
const stepNumber = Number(lesson?.course_step_number || 0)
|
||||||
|
const isCompleted = Boolean(lesson?.completed)
|
||||||
|
const readingMinutes = Number(lesson?.reading_minutes || 0)
|
||||||
|
const ctaLabel = isCompleted ? 'Review lesson' : 'Open lesson'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={lesson.course_url || `/academy/lessons/${lesson.slug}`}
|
||||||
|
className={[
|
||||||
|
'group relative overflow-hidden rounded-[32px] border bg-[linear-gradient(180deg,rgba(15,23,42,0.92),rgba(15,23,42,0.64))] shadow-[0_24px_50px_rgba(2,6,23,0.2)] transition duration-200 hover:-translate-y-1 hover:border-sky-300/30 hover:shadow-[0_28px_70px_rgba(14,165,233,0.12)]',
|
||||||
|
isCompleted ? 'border-emerald-300/25' : 'border-white/10',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_right,rgba(125,211,252,0.09),transparent_24%),linear-gradient(135deg,transparent,rgba(255,255,255,0.02))] opacity-70 transition duration-200 group-hover:opacity-100" />
|
||||||
|
|
||||||
|
<div className="relative grid gap-0 lg:grid-cols-[172px_minmax(0,1fr)]">
|
||||||
|
<div className="relative border-b border-white/10 bg-slate-950 lg:border-b-0 lg:border-r">
|
||||||
|
{thumbnail ? (
|
||||||
|
<img src={thumbnail} alt="" aria-hidden="true" className="h-40 w-full object-cover lg:h-full" />
|
||||||
|
) : (
|
||||||
|
<div className="h-40 w-full bg-[linear-gradient(135deg,rgba(56,189,248,0.18),rgba(15,23,42,0.92))] lg:h-full" />
|
||||||
|
)}
|
||||||
|
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(2,6,23,0.04),rgba(2,6,23,0.84))]" />
|
||||||
|
<div className="absolute inset-x-3 top-3 flex items-start justify-between gap-3">
|
||||||
|
{lesson.is_required ? (
|
||||||
|
<span className="rounded-full border border-white/10 bg-black/35 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-white/80 backdrop-blur">
|
||||||
|
Required
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="rounded-full border border-white/10 bg-black/35 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-white/65 backdrop-blur">
|
||||||
|
Optional
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{isCompleted ? (
|
||||||
|
<span className="inline-flex items-center gap-1.5 rounded-full border border-emerald-300/25 bg-emerald-300/12 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-emerald-100 backdrop-blur">
|
||||||
|
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true" className="h-3.5 w-3.5">
|
||||||
|
<circle cx="8" cy="8" r="7" stroke="currentColor" strokeWidth="1.2" />
|
||||||
|
<path d="M4.75 8.2 7 10.4l4.25-4.8" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
Done
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="absolute inset-x-3 bottom-3 flex items-end justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
{stepLabel ? <p className="text-[10px] font-semibold uppercase tracking-[0.24em] text-sky-100/80">{stepLabel}</p> : null}
|
||||||
|
{stepNumber > 0 ? <p className="mt-1 text-5xl font-semibold tracking-[-0.1em] text-white">{String(stepNumber).padStart(2, '0')}</p> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-5 md:p-6">
|
||||||
|
<div className="grid gap-5 xl:grid-cols-[minmax(0,1fr)_200px] xl:items-start">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex flex-wrap items-center gap-2.5">
|
||||||
|
{stepLabel ? <p className="text-[10px] font-semibold uppercase tracking-[0.22em] text-sky-100">{stepLabel}</p> : null}
|
||||||
|
{lesson.formatted_lesson_number ? <span className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-400">{lesson.formatted_lesson_number}</span> : null}
|
||||||
|
<span className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-400">{lesson.difficulty || 'lesson'}</span>
|
||||||
|
<span className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-400">{lesson.access_level || 'free'}</span>
|
||||||
|
{readingMinutes > 0 ? <span className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-400">{readingMinutes} min</span> : null}
|
||||||
|
</div>
|
||||||
|
<h3 className="mt-3 max-w-3xl text-[1.65rem] font-semibold tracking-[-0.05em] text-white transition group-hover:text-sky-100">{lesson.title}</h3>
|
||||||
|
<p className="mt-2 text-sm text-slate-400">{isCompleted ? 'You already finished this lesson.' : 'Follow this step next in the course path.'}</p>
|
||||||
|
|
||||||
|
<div className="mt-4 rounded-[24px] border border-white/10 bg-black/20 px-4 py-4">
|
||||||
|
<p className="text-sm leading-7 text-slate-300">{lesson.excerpt || lesson.content_preview || 'Open this lesson inside the course.'}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 flex flex-wrap gap-2 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400">
|
||||||
|
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5">{lesson.lesson_type || 'article'}</span>
|
||||||
|
{lesson.category_name ? <span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5">{lesson.category_name}</span> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex h-full flex-col justify-between gap-4 xl:border-l xl:border-white/10 xl:pl-5">
|
||||||
|
<div className="grid gap-3 sm:grid-cols-3 xl:grid-cols-1">
|
||||||
|
<div className="rounded-[22px] border border-white/10 bg-black/20 px-4 py-3">
|
||||||
|
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">Status</p>
|
||||||
|
<p className={`mt-2 text-sm font-semibold ${isCompleted ? 'text-emerald-100' : 'text-white'}`}>{isCompleted ? 'Completed' : 'Up next'}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-[22px] border border-white/10 bg-black/20 px-4 py-3">
|
||||||
|
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">Access</p>
|
||||||
|
<p className="mt-2 text-sm font-semibold text-white">{lesson.access_level || 'Free'}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-[22px] border border-white/10 bg-black/20 px-4 py-3">
|
||||||
|
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">Read time</p>
|
||||||
|
<p className="mt-2 text-sm font-semibold text-white">{readingMinutes > 0 ? `${readingMinutes} min` : 'Quick read'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between gap-3 xl:justify-end">
|
||||||
|
<span className="text-xs uppercase tracking-[0.16em] text-slate-500">Continue path</span>
|
||||||
|
<span className="inline-flex items-center gap-2 rounded-full border border-sky-300/20 bg-sky-300/10 px-4 py-2 text-sm font-semibold text-sky-100 transition group-hover:border-sky-300/35 group-hover:bg-sky-300/14 group-hover:text-white">
|
||||||
|
{ctaLabel}
|
||||||
|
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true" className="h-4 w-4">
|
||||||
|
<path d="M3.5 8h9m0 0-3.5-3.5M12.5 8l-3.5 3.5" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SectionBlock({ section, isActive = false }) {
|
||||||
|
if (!section?.is_visible) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className={`rounded-[32px] border p-6 transition md:p-7 ${isActive ? 'border-sky-300/25 bg-[linear-gradient(180deg,rgba(14,165,233,0.08),rgba(255,255,255,0.04))] shadow-[0_22px_50px_rgba(14,165,233,0.08)]' : 'border-white/10 bg-white/[0.04]'}`}>
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Course section</p>
|
||||||
|
<span className={`rounded-full border px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] ${isActive ? 'border-sky-300/20 bg-sky-300/12 text-sky-100' : 'border-white/10 bg-black/20 text-slate-300'}`}>
|
||||||
|
{section.order_num + 1}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h2 className="mt-2 text-2xl font-semibold tracking-[-0.04em] text-white">{section.title}</h2>
|
||||||
|
{section.description ? <p className="mt-3 max-w-3xl text-sm leading-7 text-slate-300">{section.description}</p> : null}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<span className="rounded-full border border-white/10 bg-black/20 px-4 py-2 text-xs font-semibold uppercase tracking-[0.18em] text-slate-300">{section.lessons?.length || 0} lessons</span>
|
||||||
|
{isActive ? <span className="rounded-full border border-sky-300/20 bg-sky-300/12 px-4 py-2 text-xs font-semibold uppercase tracking-[0.18em] text-sky-100">Reading now</span> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-5 space-y-6">
|
||||||
|
{(section.lessons || []).map((lesson) => (
|
||||||
|
<LessonChip key={lesson.course_lesson_id || lesson.id} lesson={lesson} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AcademyCoursesShow({ seo, course, sections = [], unsectionedLessons = [], pricingUrl }) {
|
||||||
|
const flash = usePage().props.flash || {}
|
||||||
|
const cover = course?.cover_image_url || course?.cover_image || course?.teaser_image_url || course?.teaser_image || ''
|
||||||
|
const progress = course?.progress || null
|
||||||
|
|
||||||
|
const sectionJumpItems = useMemo(
|
||||||
|
() => [
|
||||||
|
...(unsectionedLessons.length ? [{ id: 'course-outline-core', label: 'Core lessons', count: unsectionedLessons.length }] : []),
|
||||||
|
...sections
|
||||||
|
.filter((section) => section?.is_visible)
|
||||||
|
.map((section) => ({ id: `section-${section.id}`, label: section.title, count: (section.lessons || []).length })),
|
||||||
|
],
|
||||||
|
[sections, unsectionedLessons],
|
||||||
|
)
|
||||||
|
|
||||||
|
const [activeJumpId, setActiveJumpId] = useState(sectionJumpItems[0]?.id || null)
|
||||||
|
|
||||||
|
const breadcrumbs = [
|
||||||
|
{ label: 'Academy', href: '/academy' },
|
||||||
|
{ label: 'Courses', href: '/academy/courses' },
|
||||||
|
{ label: course?.title || 'Course', href: course?.public_url || '#' },
|
||||||
|
]
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!sectionJumpItems.length || typeof window === 'undefined' || typeof IntersectionObserver === 'undefined') {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
const visibleEntries = entries.filter((entry) => entry.isIntersecting).sort((left, right) => right.intersectionRatio - left.intersectionRatio)
|
||||||
|
|
||||||
|
if (!visibleEntries.length) return
|
||||||
|
|
||||||
|
setActiveJumpId(visibleEntries[0].target.id)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rootMargin: '-20% 0px -55% 0px',
|
||||||
|
threshold: [0.2, 0.45, 0.7],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const elements = sectionJumpItems.map((item) => document.getElementById(item.id)).filter(Boolean)
|
||||||
|
elements.forEach((element) => observer.observe(element))
|
||||||
|
|
||||||
|
return () => observer.disconnect()
|
||||||
|
}, [sectionJumpItems])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.16),_transparent_24%),radial-gradient(circle_at_bottom_right,_rgba(251,191,36,0.16),_transparent_24%),linear-gradient(180deg,_#0f172a_0%,_#111827_100%)] px-4 py-8 sm:px-6 lg:px-8">
|
||||||
|
<SeoHead seo={seo || {}} title={course?.title} description={course?.excerpt || course?.description} />
|
||||||
|
|
||||||
|
<div className="mx-auto max-w-[1400px] space-y-6">
|
||||||
|
{flash.success ? <div className="rounded-2xl border border-emerald-300/20 bg-emerald-300/10 px-4 py-3 text-sm text-emerald-100">{flash.success}</div> : null}
|
||||||
|
{flash.error ? <div className="rounded-2xl border border-rose-300/20 bg-rose-300/10 px-4 py-3 text-sm text-rose-100">{flash.error}</div> : null}
|
||||||
|
|
||||||
|
<section className="overflow-hidden rounded-[40px] border border-white/10 bg-black/20 shadow-[0_24px_90px_rgba(2,6,23,0.34)]">
|
||||||
|
<div className="grid gap-0 xl:grid-cols-[minmax(0,1.2fr)_360px]">
|
||||||
|
<div className="relative overflow-hidden p-6 md:p-8 lg:p-10 xl:p-12">
|
||||||
|
{cover ? <img src={cover} alt="" aria-hidden="true" className="absolute inset-0 h-full w-full object-cover opacity-[0.18]" /> : null}
|
||||||
|
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_left,_rgba(125,211,252,0.18),_transparent_28%),radial-gradient(circle_at_78%_26%,_rgba(251,191,36,0.12),_transparent_20%),linear-gradient(135deg,_rgba(2,6,23,0.98),_rgba(15,23,42,0.85))]" />
|
||||||
|
<div className="relative z-10 max-w-5xl">
|
||||||
|
<CourseBreadcrumbs items={breadcrumbs} />
|
||||||
|
|
||||||
|
<div className="mt-5 flex flex-wrap items-center gap-2.5">
|
||||||
|
<span className="rounded-full border border-sky-300/20 bg-sky-300/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-100">Academy course</span>
|
||||||
|
<span className="rounded-full border border-white/10 bg-white/[0.06] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-300">{course?.difficulty}</span>
|
||||||
|
<span className="rounded-full border border-white/10 bg-white/[0.06] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-300">{course?.access_level}</span>
|
||||||
|
{progress?.percent ? <span className="rounded-full border border-emerald-300/20 bg-emerald-300/12 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.22em] text-emerald-100">{progress.percent}% complete</span> : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6">
|
||||||
|
<h1 className="text-4xl font-semibold tracking-[-0.06em] text-white md:text-5xl lg:text-[3.75rem]">{course?.title}</h1>
|
||||||
|
{course?.subtitle ? <p className="mt-4 text-sm font-semibold uppercase tracking-[0.24em] text-amber-100/90">{course.subtitle}</p> : null}
|
||||||
|
<p className="mt-5 max-w-3xl text-base leading-8 text-slate-300 md:text-lg">{course?.excerpt || course?.description}</p>
|
||||||
|
|
||||||
|
<div className="mt-7 overflow-hidden rounded-[32px] border border-white/10 bg-slate-950/80 shadow-[0_24px_60px_rgba(2,6,23,0.32)]">
|
||||||
|
{cover ? (
|
||||||
|
<img src={cover} alt="" aria-hidden="true" className="w-full object-contain" />
|
||||||
|
) : (
|
||||||
|
<div className="flex h-[360px] items-center justify-center bg-[linear-gradient(135deg,rgba(56,189,248,0.18),rgba(15,23,42,0.92))] px-6 text-center text-sm text-slate-400">
|
||||||
|
No course cover image yet
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside className="border-t border-white/10 bg-white/[0.03] p-6 xl:border-l xl:border-t-0 xl:p-8">
|
||||||
|
<div className="space-y-4 xl:sticky xl:top-6">
|
||||||
|
<ProgressMeter progress={progress} />
|
||||||
|
|
||||||
|
<div className="rounded-[28px] border border-white/10 bg-black/20 p-5">
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Jump through the course</p>
|
||||||
|
<div className="mt-4 space-y-2">
|
||||||
|
{sectionJumpItems.length ? (
|
||||||
|
sectionJumpItems.map((item) => (
|
||||||
|
<a
|
||||||
|
key={item.id}
|
||||||
|
href={`#${item.id}`}
|
||||||
|
onClick={() => setActiveJumpId(item.id)}
|
||||||
|
className={`flex items-center justify-between rounded-2xl border px-4 py-3 text-sm transition ${activeJumpId === item.id ? 'border-sky-300/25 bg-sky-300/12 text-white' : 'border-white/10 bg-white/[0.03] text-slate-300 hover:border-white/20 hover:bg-white/[0.06]'}`}
|
||||||
|
>
|
||||||
|
<span className="font-medium">{item.label}</span>
|
||||||
|
<span className="rounded-full border border-white/10 bg-black/20 px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-400">{item.count}</span>
|
||||||
|
</a>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<p className="rounded-2xl border border-dashed border-white/10 bg-white/[0.03] px-4 py-3 text-sm text-slate-400">No course outline items are available yet.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
{unsectionedLessons.length ? (
|
||||||
|
<SectionBlock
|
||||||
|
section={{
|
||||||
|
order_num: -1,
|
||||||
|
title: 'Core lessons',
|
||||||
|
description: 'Lessons shown before the course branches into sections.',
|
||||||
|
is_visible: true,
|
||||||
|
lessons: unsectionedLessons,
|
||||||
|
}}
|
||||||
|
isActive={activeJumpId === 'course-outline-core'}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{sections.filter((section) => section?.is_visible).map((section) => (
|
||||||
|
<SectionBlock key={section.id} section={section} isActive={activeJumpId === `section-${section.id}`} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -17,7 +17,29 @@ function FeatureCard({ title, description, href, cta }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AcademyIndex({ seo, pricingUrl, links, featureFlags, stats, featuredLessons, featuredPrompts, featuredChallenges }) {
|
function FeaturedCourseCard({ course }) {
|
||||||
|
const cover = course?.cover_image_url || course?.teaser_image_url || course?.cover_image || course?.teaser_image || ''
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link href={course.public_url} className="group overflow-hidden rounded-[28px] border border-white/10 bg-white/[0.04] transition hover:border-sky-300/25 hover:bg-white/[0.06]">
|
||||||
|
<div className="relative h-44 overflow-hidden bg-[linear-gradient(135deg,rgba(14,165,233,0.24),rgba(15,23,42,0.92))]">
|
||||||
|
{cover ? <img src={cover} alt="" aria-hidden="true" className="h-full w-full object-cover" /> : null}
|
||||||
|
<div className="absolute inset-0 bg-[linear-gradient(180deg,transparent,rgba(2,6,23,0.82))]" />
|
||||||
|
<div className="absolute left-4 top-4 flex flex-wrap gap-2">
|
||||||
|
<span className="rounded-full border border-white/10 bg-black/30 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-sky-100">{course.difficulty}</span>
|
||||||
|
<span className="rounded-full border border-white/10 bg-black/30 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-200">{course.access_level}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-5">
|
||||||
|
<h3 className="text-2xl font-semibold tracking-[-0.04em] text-white transition group-hover:text-sky-100">{course.title}</h3>
|
||||||
|
<p className="mt-3 text-sm leading-7 text-slate-300">{course.excerpt || course.description || 'Guided Academy course.'}</p>
|
||||||
|
<p className="mt-4 text-xs uppercase tracking-[0.18em] text-slate-500">{course.lessons_count || 0} lessons · {course.estimated_minutes ? `${course.estimated_minutes} min` : 'Flexible duration'}</p>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AcademyIndex({ seo, pricingUrl, links, featureFlags, stats, featuredCourses, featuredLessons, featuredPrompts, featuredChallenges }) {
|
||||||
const jsonLd = [{
|
const jsonLd = [{
|
||||||
'@context': 'https://schema.org',
|
'@context': 'https://schema.org',
|
||||||
'@type': 'WebPage',
|
'@type': 'WebPage',
|
||||||
@@ -39,6 +61,7 @@ export default function AcademyIndex({ seo, pricingUrl, links, featureFlags, sta
|
|||||||
<p className="mt-5 max-w-3xl text-base leading-8 text-slate-300 md:text-lg">Skinbase AI Academy is the creative learning hub for AI-assisted art on Skinbase. Start with free lessons, explore prompt templates, and unlock premium workflows later.</p>
|
<p className="mt-5 max-w-3xl text-base leading-8 text-slate-300 md:text-lg">Skinbase AI Academy is the creative learning hub for AI-assisted art on Skinbase. Start with free lessons, explore prompt templates, and unlock premium workflows later.</p>
|
||||||
|
|
||||||
<div className="mt-7 flex flex-wrap gap-3">
|
<div className="mt-7 flex flex-wrap gap-3">
|
||||||
|
<Link href={links.courses} className="rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100 transition hover:border-sky-300/40 hover:bg-sky-300/18">Browse courses</Link>
|
||||||
<Link href={links.lessons} className="rounded-full border border-amber-300/25 bg-amber-300/12 px-5 py-3 text-sm font-semibold text-amber-100 transition hover:border-amber-300/40 hover:bg-amber-300/18">Browse lessons</Link>
|
<Link href={links.lessons} className="rounded-full border border-amber-300/25 bg-amber-300/12 px-5 py-3 text-sm font-semibold text-amber-100 transition hover:border-amber-300/40 hover:bg-amber-300/18">Browse lessons</Link>
|
||||||
<Link href={links.prompts} className="rounded-full border border-white/10 bg-white/[0.04] px-5 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.07]">Open prompt library</Link>
|
<Link href={links.prompts} className="rounded-full border border-white/10 bg-white/[0.04] px-5 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.07]">Open prompt library</Link>
|
||||||
<Link href={pricingUrl} className="rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100 transition hover:border-sky-300/40 hover:bg-sky-300/18">See plans</Link>
|
<Link href={pricingUrl} className="rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100 transition hover:border-sky-300/40 hover:bg-sky-300/18">See plans</Link>
|
||||||
@@ -57,19 +80,35 @@ export default function AcademyIndex({ seo, pricingUrl, links, featureFlags, sta
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="grid gap-5 lg:grid-cols-3">
|
<section className="grid gap-5 lg:grid-cols-3">
|
||||||
|
<FeatureCard title="Courses" description="Follow guided learning paths that stitch reusable Academy lessons into a clean progression with completion tracking." href={links.courses} cta="Browse courses" />
|
||||||
<FeatureCard title="Lessons" description="Structured tutorials for prompt writing, cleanup workflows, AI ethics, and Skinbase-native publishing habits." href={links.lessons} cta="Open lessons" />
|
<FeatureCard title="Lessons" description="Structured tutorials for prompt writing, cleanup workflows, AI ethics, and Skinbase-native publishing habits." href={links.lessons} cta="Open lessons" />
|
||||||
<FeatureCard title="Prompt Library" description="Discover reusable prompt templates, locked premium previews, and creator-focused visual workflows." href={links.prompts} cta="Explore prompts" />
|
<FeatureCard title="Prompt Library" description="Discover reusable prompt templates, locked premium previews, and creator-focused visual workflows." href={links.prompts} cta="Explore prompts" />
|
||||||
<FeatureCard title="Challenges" description="Join Academy creative briefs and submit artworks once the challenge system is enabled for your account." href={links.challenges} cta="View challenges" />
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="grid gap-5 lg:grid-cols-3">
|
<section className="grid gap-5 lg:grid-cols-4">
|
||||||
|
<div className="rounded-[28px] border border-white/10 bg-white/[0.04] p-6"><p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Courses</p><p className="mt-3 text-4xl font-semibold tracking-[-0.05em] text-white">{stats?.courseCount || 0}</p></div>
|
||||||
<div className="rounded-[28px] border border-white/10 bg-white/[0.04] p-6"><p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Lessons</p><p className="mt-3 text-4xl font-semibold tracking-[-0.05em] text-white">{stats?.lessonCount || 0}</p></div>
|
<div className="rounded-[28px] border border-white/10 bg-white/[0.04] p-6"><p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Lessons</p><p className="mt-3 text-4xl font-semibold tracking-[-0.05em] text-white">{stats?.lessonCount || 0}</p></div>
|
||||||
<div className="rounded-[28px] border border-white/10 bg-white/[0.04] p-6"><p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Prompts</p><p className="mt-3 text-4xl font-semibold tracking-[-0.05em] text-white">{stats?.promptCount || 0}</p></div>
|
<div className="rounded-[28px] border border-white/10 bg-white/[0.04] p-6"><p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Prompts</p><p className="mt-3 text-4xl font-semibold tracking-[-0.05em] text-white">{stats?.promptCount || 0}</p></div>
|
||||||
<div className="rounded-[28px] border border-white/10 bg-white/[0.04] p-6"><p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Challenges</p><p className="mt-3 text-4xl font-semibold tracking-[-0.05em] text-white">{stats?.challengeCount || 0}</p></div>
|
<div className="rounded-[28px] border border-white/10 bg-white/[0.04] p-6"><p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Challenges</p><p className="mt-3 text-4xl font-semibold tracking-[-0.05em] text-white">{stats?.challengeCount || 0}</p></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{featuredCourses?.length ? (
|
||||||
|
<section className="space-y-5">
|
||||||
|
<div className="flex flex-wrap items-end justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Featured courses</p>
|
||||||
|
<h2 className="mt-2 text-3xl font-semibold tracking-[-0.045em] text-white">Guided Academy paths</h2>
|
||||||
|
</div>
|
||||||
|
<Link href={links.courses} className="rounded-full border border-white/10 bg-white/[0.04] px-5 py-3 text-sm font-semibold text-white">All courses</Link>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-5 xl:grid-cols-3">
|
||||||
|
{featuredCourses.slice(0, 3).map((course) => <FeaturedCourseCard key={course.id} course={course} />)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<section className="grid gap-5 xl:grid-cols-3">
|
<section className="grid gap-5 xl:grid-cols-3">
|
||||||
<div className="rounded-[28px] border border-white/10 bg-black/20 p-6"><p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Featured lessons</p><div className="mt-4 space-y-3">{(featuredLessons || []).slice(0, 3).map((item) => <Link key={item.id} href={academyHref('lessons', item.slug)} className="block rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-white">{item.title}</Link>)}</div></div>
|
<div className="rounded-[28px] border border-white/10 bg-black/20 p-6"><p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Featured lessons</p><div className="mt-4 space-y-3">{(featuredLessons || []).slice(0, 3).map((item) => <Link key={item.id} href={academyHref('lessons', item.slug)} className="block rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-white"><span className="block text-[10px] font-semibold uppercase tracking-[0.18em] text-amber-100">{item.lesson_label || 'Featured lesson'}</span><span className="mt-1 block">{item.title}</span></Link>)}</div></div>
|
||||||
<div className="rounded-[28px] border border-white/10 bg-black/20 p-6"><p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Featured prompts</p><div className="mt-4 space-y-3">{(featuredPrompts || []).slice(0, 3).map((item) => <Link key={item.id} href={academyHref('prompts', item.slug)} className="block rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-white">{item.title}</Link>)}</div></div>
|
<div className="rounded-[28px] border border-white/10 bg-black/20 p-6"><p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Featured prompts</p><div className="mt-4 space-y-3">{(featuredPrompts || []).slice(0, 3).map((item) => <Link key={item.id} href={academyHref('prompts', item.slug)} className="block rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-white">{item.title}</Link>)}</div></div>
|
||||||
<div className="rounded-[28px] border border-white/10 bg-black/20 p-6"><p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Current challenges</p><div className="mt-4 space-y-3">{(featuredChallenges || []).slice(0, 3).map((item) => <Link key={item.id} href={academyHref('challenges', item.slug)} className="block rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-white">{item.title}</Link>)}</div></div>
|
<div className="rounded-[28px] border border-white/10 bg-black/20 p-6"><p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Current challenges</p><div className="mt-4 space-y-3">{(featuredChallenges || []).slice(0, 3).map((item) => <Link key={item.id} href={academyHref('challenges', item.slug)} className="block rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-white">{item.title}</Link>)}</div></div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -65,15 +65,125 @@ function itemHref(pageType, item) {
|
|||||||
return academyHref('challenges', item.slug)
|
return academyHref('challenges', item.slug)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function PromptLibraryHero({ title, description, items, pricingUrl }) {
|
||||||
|
const featuredImages = (items || [])
|
||||||
|
.map((item) => item?.preview_image)
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, 3)
|
||||||
|
|
||||||
|
const primaryImage = featuredImages[0] || ''
|
||||||
|
const supportingImages = featuredImages.slice(1, 3)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="overflow-hidden rounded-[38px] border border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(125,211,252,0.14),transparent_26%),radial-gradient(circle_at_bottom_right,rgba(255,207,191,0.16),transparent_26%),linear-gradient(135deg,rgba(4,9,18,0.98),rgba(15,23,42,0.92))] p-8 shadow-[0_28px_90px_rgba(2,6,23,0.28)] md:p-10 lg:p-12">
|
||||||
|
<div className="grid gap-8 xl:grid-cols-[minmax(0,1.15fr)_420px] xl:items-end">
|
||||||
|
<div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<span className="rounded-full border border-[#ffcfbf]/20 bg-[#ffcfbf]/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.24em] text-[#fff0ea]">Skinbase AI Academy</span>
|
||||||
|
<span className="rounded-full border border-white/10 bg-white/[0.05] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.24em] text-slate-300">Prompt Library</span>
|
||||||
|
</div>
|
||||||
|
<h1 className="mt-5 max-w-4xl text-4xl font-semibold tracking-[-0.055em] text-white md:text-5xl xl:text-6xl">{title}</h1>
|
||||||
|
<p className="mt-5 max-w-3xl text-base leading-8 text-slate-300 md:text-lg">{description}</p>
|
||||||
|
|
||||||
|
<div className="mt-7 grid gap-3 sm:grid-cols-3">
|
||||||
|
<div className="rounded-[24px] border border-white/10 bg-black/20 px-5 py-4">
|
||||||
|
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">Visual-first</p>
|
||||||
|
<p className="mt-2 text-sm font-semibold text-white">Preview prompt results before opening the detail page.</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-[24px] border border-white/10 bg-black/20 px-5 py-4">
|
||||||
|
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">Reusable</p>
|
||||||
|
<p className="mt-2 text-sm font-semibold text-white">Templates for wallpapers, covers, worlds, portraits, and more.</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-[24px] border border-white/10 bg-black/20 px-5 py-4">
|
||||||
|
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">Comparison-ready</p>
|
||||||
|
<p className="mt-2 text-sm font-semibold text-white">See which prompts include provider-specific notes and outputs.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-7 flex flex-wrap gap-3">
|
||||||
|
<Link href={pricingUrl} className="rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100">Upgrade preview</Link>
|
||||||
|
<span className="rounded-full border border-white/10 bg-white/[0.04] px-5 py-3 text-sm font-semibold text-white/85">{items?.length || 0} prompts in view</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3">
|
||||||
|
{primaryImage ? (
|
||||||
|
<>
|
||||||
|
<div className="overflow-hidden rounded-[28px] border border-white/10 bg-black/20 shadow-[0_18px_45px_rgba(2,6,23,0.18)] aspect-[16/10]">
|
||||||
|
<img src={primaryImage} alt="" aria-hidden="true" className="h-full w-full object-cover" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{supportingImages.length ? (
|
||||||
|
<div className={`grid gap-3 ${supportingImages.length === 1 ? 'grid-cols-1' : 'grid-cols-2'}`}>
|
||||||
|
{supportingImages.map((image, index) => (
|
||||||
|
<div key={`${image}-${index}`} className="overflow-hidden rounded-[28px] border border-white/10 bg-black/20 shadow-[0_18px_45px_rgba(2,6,23,0.18)] aspect-square">
|
||||||
|
<img src={image} alt="" aria-hidden="true" className="h-full w-full object-cover" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="col-span-2 flex aspect-[16/10] items-center justify-center rounded-[28px] border border-white/10 bg-[linear-gradient(135deg,rgba(56,189,248,0.12),rgba(17,24,39,0.92))] px-8 text-center text-sm font-semibold uppercase tracking-[0.24em] text-slate-300">
|
||||||
|
Prompt preview images will appear here
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function AcademyCard({ pageType, item }) {
|
function AcademyCard({ pageType, item }) {
|
||||||
|
const lessonSeries = String(item?.series_name || '').trim()
|
||||||
|
const promptPreviewImage = item?.preview_image || ''
|
||||||
|
|
||||||
|
if (pageType === 'prompts') {
|
||||||
|
return (
|
||||||
|
<Link href={itemHref(pageType, item)} className="group overflow-hidden rounded-[30px] border border-white/10 bg-[linear-gradient(180deg,rgba(15,23,42,0.92),rgba(7,11,18,0.96))] shadow-[0_20px_50px_rgba(2,6,23,0.18)] transition hover:border-sky-300/25 hover:bg-[linear-gradient(180deg,rgba(15,23,42,0.96),rgba(10,15,26,0.98))]">
|
||||||
|
<div className="relative aspect-[16/11] overflow-hidden bg-[linear-gradient(135deg,rgba(56,189,248,0.18),rgba(17,24,39,0.94))]">
|
||||||
|
{promptPreviewImage ? <img src={promptPreviewImage} alt="" aria-hidden="true" className="h-full w-full object-cover transition duration-500 group-hover:scale-[1.04]" /> : null}
|
||||||
|
<div className="absolute inset-0 bg-[linear-gradient(180deg,transparent,rgba(2,6,23,0.72))]" />
|
||||||
|
<div className="absolute left-4 top-4 flex flex-wrap gap-2">
|
||||||
|
<span className="rounded-full border border-white/10 bg-black/30 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-[#fff0ea]">Prompt template</span>
|
||||||
|
<LockBadge item={item} />
|
||||||
|
</div>
|
||||||
|
<div className="absolute bottom-4 left-4 right-4 flex flex-wrap items-end justify-between gap-3">
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{item?.difficulty ? <span className="rounded-full border border-white/10 bg-black/30 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-white">{item.difficulty}</span> : null}
|
||||||
|
{item?.aspect_ratio ? <span className="rounded-full border border-white/10 bg-black/30 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-white">{item.aspect_ratio}</span> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-5">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">{item?.category?.name || 'Academy'}</p>
|
||||||
|
{Array.isArray(item?.tool_notes) && item.tool_notes.length ? <span className="rounded-full border border-[#ffcfbf]/15 bg-[#ffcfbf]/10 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-[#fff0ea]">{item.tool_notes.length} comparisons</span> : null}
|
||||||
|
</div>
|
||||||
|
<h2 className="mt-3 text-2xl font-semibold tracking-[-0.04em] text-white transition group-hover:text-sky-100">{item.title}</h2>
|
||||||
|
<p className="mt-3 text-sm leading-7 text-slate-300">{item.excerpt || item.description || item.prompt_preview || 'No description yet.'}</p>
|
||||||
|
{item.tags?.length ? <p className="mt-4 text-xs uppercase tracking-[0.18em] text-slate-500">{item.tags.slice(0, 4).join(' · ')}</p> : null}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link href={itemHref(pageType, item)} className="rounded-[28px] border border-white/10 bg-white/[0.04] p-5 transition hover:border-white/20 hover:bg-white/[0.06]">
|
<Link href={itemHref(pageType, item)} className="rounded-[28px] border border-white/10 bg-white/[0.04] p-5 transition hover:border-white/20 hover:bg-white/[0.06]">
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">{pageType.slice(0, -1)}</p>
|
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">{pageType.slice(0, -1)}</p>
|
||||||
<LockBadge item={item} />
|
<LockBadge item={item} />
|
||||||
</div>
|
</div>
|
||||||
|
{pageType === 'lessons' && item?.formatted_lesson_number ? (
|
||||||
|
<div className="mt-4 flex flex-wrap items-center gap-2">
|
||||||
|
<span className="rounded-full border border-amber-300/20 bg-amber-300/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.2em] text-amber-100">{item.formatted_lesson_number}</span>
|
||||||
|
{lessonSeries ? <span className="text-xs font-medium uppercase tracking-[0.18em] text-slate-500">{lessonSeries}</span> : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<h2 className="mt-4 text-2xl font-semibold tracking-[-0.04em] text-white">{item.title}</h2>
|
<h2 className="mt-4 text-2xl font-semibold tracking-[-0.04em] text-white">{item.title}</h2>
|
||||||
<p className="mt-3 text-sm leading-7 text-slate-300">{item.excerpt || item.description || item.prompt_preview || item.content_preview || 'No description yet.'}</p>
|
<p className="mt-3 text-sm leading-7 text-slate-300">{item.excerpt || item.description || item.prompt_preview || item.content_preview || 'No description yet.'}</p>
|
||||||
|
{pageType === 'lessons' && item.tags?.length ? <p className="mt-4 text-xs uppercase tracking-[0.18em] text-slate-500">{item.tags.slice(0, 4).join(' · ')}</p> : null}
|
||||||
{pageType === 'prompts' && item.tags?.length ? <p className="mt-4 text-xs uppercase tracking-[0.18em] text-slate-500">{item.tags.slice(0, 4).join(' · ')}</p> : null}
|
{pageType === 'prompts' && item.tags?.length ? <p className="mt-4 text-xs uppercase tracking-[0.18em] text-slate-500">{item.tags.slice(0, 4).join(' · ')}</p> : null}
|
||||||
{pageType === 'challenges' ? <p className="mt-4 text-xs uppercase tracking-[0.18em] text-slate-500">{item.status} · {item.submission_count ?? 0} submissions</p> : null}
|
{pageType === 'challenges' ? <p className="mt-4 text-xs uppercase tracking-[0.18em] text-slate-500">{item.status} · {item.submission_count ?? 0} submissions</p> : null}
|
||||||
</Link>
|
</Link>
|
||||||
@@ -82,12 +192,14 @@ function AcademyCard({ pageType, item }) {
|
|||||||
|
|
||||||
export default function AcademyList({ pageType, title, description, seo, items, filters, categories, pricingUrl }) {
|
export default function AcademyList({ pageType, title, description, seo, items, filters, categories, pricingUrl }) {
|
||||||
const flash = usePage().props.flash || {}
|
const flash = usePage().props.flash || {}
|
||||||
|
const visibleItems = Array.isArray(items?.data) ? items.data : []
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.15),_transparent_24%),radial-gradient(circle_at_bottom_right,_rgba(251,191,36,0.16),_transparent_24%),linear-gradient(180deg,_#0f172a_0%,_#111827_100%)] px-4 py-8 sm:px-6 lg:px-8">
|
<main className="min-h-screen bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.15),_transparent_24%),radial-gradient(circle_at_bottom_right,_rgba(251,191,36,0.16),_transparent_24%),linear-gradient(180deg,_#0f172a_0%,_#111827_100%)] px-4 py-8 sm:px-6 lg:px-8">
|
||||||
<SeoHead seo={seo || {}} title={title} description={description} />
|
<SeoHead seo={seo || {}} title={title} description={description} />
|
||||||
|
|
||||||
<div className="mx-auto max-w-[1360px] space-y-6">
|
<div className="mx-auto max-w-[1360px] space-y-6">
|
||||||
|
{pageType === 'prompts' ? <PromptLibraryHero title={title} description={description} items={visibleItems} pricingUrl={pricingUrl} /> : (
|
||||||
<section className="rounded-[38px] border border-white/10 bg-black/20 p-8 md:p-10">
|
<section className="rounded-[38px] border border-white/10 bg-black/20 p-8 md:p-10">
|
||||||
<div className="flex flex-wrap items-end justify-between gap-5">
|
<div className="flex flex-wrap items-end justify-between gap-5">
|
||||||
<div>
|
<div>
|
||||||
@@ -98,17 +210,18 @@ export default function AcademyList({ pageType, title, description, seo, items,
|
|||||||
<Link href={pricingUrl} className="rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100">Upgrade preview</Link>
|
<Link href={pricingUrl} className="rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100">Upgrade preview</Link>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{flash.success ? <div className="rounded-2xl border border-emerald-300/20 bg-emerald-300/10 px-4 py-3 text-sm text-emerald-100">{flash.success}</div> : null}
|
{flash.success ? <div className="rounded-2xl border border-emerald-300/20 bg-emerald-300/10 px-4 py-3 text-sm text-emerald-100">{flash.success}</div> : null}
|
||||||
{flash.error ? <div className="rounded-2xl border border-rose-300/20 bg-rose-300/10 px-4 py-3 text-sm text-rose-100">{flash.error}</div> : null}
|
{flash.error ? <div className="rounded-2xl border border-rose-300/20 bg-rose-300/10 px-4 py-3 text-sm text-rose-100">{flash.error}</div> : null}
|
||||||
|
|
||||||
<QueryFilters pageType={pageType} filters={filters} categories={categories} />
|
<QueryFilters pageType={pageType} filters={filters} categories={categories} />
|
||||||
|
|
||||||
{(items?.data || []).length === 0 ? (
|
{visibleItems.length === 0 ? (
|
||||||
<section className="rounded-[30px] border border-white/10 bg-white/[0.04] px-6 py-12 text-center text-slate-400">Nothing matched this Academy view yet.</section>
|
<section className="rounded-[30px] border border-white/10 bg-white/[0.04] px-6 py-12 text-center text-slate-400">Nothing matched this Academy view yet.</section>
|
||||||
) : (
|
) : (
|
||||||
<section className="grid gap-5 md:grid-cols-2 xl:grid-cols-3">
|
<section className="grid gap-5 md:grid-cols-2 xl:grid-cols-3">
|
||||||
{items.data.map((item) => <AcademyCard key={`${pageType}-${item.id}`} pageType={pageType} item={item} />)}
|
{visibleItems.map((item) => <AcademyCard key={`${pageType}-${item.id}`} pageType={pageType} item={item} />)}
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,6 +2,35 @@ import React, { useEffect, useRef, useState } from 'react'
|
|||||||
import { Link, router, usePage } from '@inertiajs/react'
|
import { Link, router, usePage } from '@inertiajs/react'
|
||||||
import SeoHead from '../../components/seo/SeoHead'
|
import SeoHead from '../../components/seo/SeoHead'
|
||||||
|
|
||||||
|
function academyHref(section, slug) {
|
||||||
|
return `/academy/${section}/${encodeURIComponent(slug)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function AcademyBreadcrumbs({ items = [] }) {
|
||||||
|
if (!items.length) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav aria-label="Breadcrumb" className="flex flex-wrap items-center gap-2 text-sm text-slate-400">
|
||||||
|
{items.map((item, index) => {
|
||||||
|
const isLast = index === items.length - 1
|
||||||
|
|
||||||
|
return (
|
||||||
|
<React.Fragment key={`${item.label}-${index}`}>
|
||||||
|
{index > 0 ? <span className="text-slate-600">/</span> : null}
|
||||||
|
{isLast ? (
|
||||||
|
<span className="font-medium text-slate-200">{item.label}</span>
|
||||||
|
) : (
|
||||||
|
<Link href={item.href} className="transition hover:text-white">
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function slugifyHeading(value, fallback = 'section') {
|
function slugifyHeading(value, fallback = 'section') {
|
||||||
const normalized = String(value || '')
|
const normalized = String(value || '')
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
@@ -48,6 +77,29 @@ function LessonInfoRow({ label, value }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function LessonNavCard({ direction, lesson }) {
|
||||||
|
if (!lesson) return null
|
||||||
|
|
||||||
|
const eyebrow = direction === 'previous' ? 'Previous lesson' : 'Next lesson'
|
||||||
|
const alignClass = direction === 'previous' ? 'items-start text-left' : 'items-end text-right'
|
||||||
|
const href = lesson.course_url || `/academy/lessons/${lesson.slug}`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={href}
|
||||||
|
aria-label={`${eyebrow}: ${lesson.title}`}
|
||||||
|
className={`group flex min-h-full flex-col justify-between rounded-[28px] border border-white/10 bg-black/20 p-5 transition hover:border-sky-300/25 hover:bg-white/[0.06] ${alignClass}`}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-slate-400">{eyebrow}</p>
|
||||||
|
{lesson.lesson_label ? <p className="mt-3 text-xs font-semibold uppercase tracking-[0.2em] text-amber-100">{lesson.lesson_label}</p> : null}
|
||||||
|
<h3 className="mt-3 text-2xl font-semibold tracking-[-0.04em] text-white transition group-hover:text-sky-100">{lesson.title}</h3>
|
||||||
|
</div>
|
||||||
|
<p className="mt-4 text-sm leading-7 text-slate-300">{lesson.excerpt || lesson.content_preview || 'Open the next step in this Academy sequence.'}</p>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function LockedPanel({ pricingUrl, label }) {
|
function LockedPanel({ pricingUrl, label }) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-[28px] border border-amber-300/20 bg-amber-300/10 p-6 text-amber-50">
|
<div className="rounded-[28px] border border-amber-300/20 bg-amber-300/10 p-6 text-amber-50">
|
||||||
@@ -87,7 +139,7 @@ function copyTextToClipboard(text) {
|
|||||||
return Promise.reject(new Error('Clipboard unavailable'))
|
return Promise.reject(new Error('Clipboard unavailable'))
|
||||||
}
|
}
|
||||||
|
|
||||||
function PromptCopyButton({ prompt }) {
|
function PromptCopyButton({ prompt, label = 'Copy prompt' }) {
|
||||||
const [status, setStatus] = useState('idle')
|
const [status, setStatus] = useState('idle')
|
||||||
const resetTimerRef = useRef(0)
|
const resetTimerRef = useRef(0)
|
||||||
|
|
||||||
@@ -107,11 +159,172 @@ function PromptCopyButton({ prompt }) {
|
|||||||
aria-label="Copy prompt"
|
aria-label="Copy prompt"
|
||||||
>
|
>
|
||||||
<i className={`fa-solid ${status === 'copied' ? 'fa-check' : status === 'failed' ? 'fa-triangle-exclamation' : 'fa-copy'}`} />
|
<i className={`fa-solid ${status === 'copied' ? 'fa-check' : status === 'failed' ? 'fa-triangle-exclamation' : 'fa-copy'}`} />
|
||||||
<span>{status === 'copied' ? 'Copied' : status === 'failed' ? 'Copy failed' : 'Copy prompt'}</span>
|
<span>{status === 'copied' ? 'Copied' : status === 'failed' ? 'Copy failed' : label}</span>
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ImageLightbox({ gallery, onClose, onNavigate }) {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!gallery?.images?.length) return undefined
|
||||||
|
|
||||||
|
const handleEscape = (event) => {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
onClose()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === 'ArrowLeft') {
|
||||||
|
onNavigate(-1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === 'ArrowRight') {
|
||||||
|
onNavigate(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.body.style.overflow = 'hidden'
|
||||||
|
window.addEventListener('keydown', handleEscape)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.body.style.overflow = ''
|
||||||
|
window.removeEventListener('keydown', handleEscape)
|
||||||
|
}
|
||||||
|
}, [gallery, onClose, onNavigate])
|
||||||
|
|
||||||
|
const images = Array.isArray(gallery?.images) ? gallery.images : []
|
||||||
|
const currentIndex = Math.max(0, Math.min(images.length - 1, Number(gallery?.index || 0)))
|
||||||
|
const currentImage = images[currentIndex]
|
||||||
|
|
||||||
|
if (!currentImage?.src) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-[120] flex items-center justify-center bg-[#020611e6] p-4 backdrop-blur-md" onClick={onClose} role="dialog" aria-modal="true" aria-label={currentImage.alt || 'Image preview'}>
|
||||||
|
<button type="button" onClick={onClose} className="absolute right-4 top-4 inline-flex h-11 w-11 items-center justify-center rounded-full border border-white/10 bg-black/35 text-white transition hover:border-white/25 hover:bg-white/10" aria-label="Close image preview">
|
||||||
|
<i className="fa-solid fa-xmark text-lg" />
|
||||||
|
</button>
|
||||||
|
{images.length > 1 ? (
|
||||||
|
<button type="button" onClick={(event) => { event.stopPropagation(); onNavigate(-1) }} className="absolute left-4 top-1/2 inline-flex h-12 w-12 -translate-y-1/2 items-center justify-center rounded-full border border-white/10 bg-black/35 text-white transition hover:border-white/25 hover:bg-white/10" aria-label="Previous image">
|
||||||
|
<i className="fa-solid fa-chevron-left" />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
{images.length > 1 ? (
|
||||||
|
<button type="button" onClick={(event) => { event.stopPropagation(); onNavigate(1) }} className="absolute right-4 top-1/2 inline-flex h-12 w-12 -translate-y-1/2 items-center justify-center rounded-full border border-white/10 bg-black/35 text-white transition hover:border-white/25 hover:bg-white/10" aria-label="Next image">
|
||||||
|
<i className="fa-solid fa-chevron-right" />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
<div className="max-h-[92vh] max-w-[min(1400px,96vw)] overflow-hidden rounded-[30px] border border-white/10 bg-black/30 shadow-[0_30px_120px_rgba(0,0,0,0.5)]" onClick={(event) => event.stopPropagation()}>
|
||||||
|
<img src={currentImage.src} alt={currentImage.alt || ''} className="max-h-[92vh] w-full object-contain" />
|
||||||
|
{images.length > 1 ? (
|
||||||
|
<div className="flex items-center justify-between gap-4 border-t border-white/10 bg-black/35 px-5 py-3 text-sm text-slate-200">
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-white">{currentImage.alt || `Image ${currentIndex + 1}`}</p>
|
||||||
|
<p className="text-xs uppercase tracking-[0.18em] text-slate-400">{`Image ${currentIndex + 1} of ${images.length}`}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{images.map((image, index) => (
|
||||||
|
<button
|
||||||
|
key={`${image.src}-${index}`}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onNavigate(index - currentIndex)}
|
||||||
|
className={`h-2.5 w-2.5 rounded-full transition ${index === currentIndex ? 'bg-white' : 'bg-white/25 hover:bg-white/45'}`}
|
||||||
|
aria-label={`Go to image ${index + 1}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PromptToolNoteCard({ note, index, galleryIndex, onOpenImage }) {
|
||||||
|
if (!note || typeof note !== 'object') return null
|
||||||
|
|
||||||
|
const title = note.model_name || note.provider || `Comparison ${String(index + 1).padStart(2, '0')}`
|
||||||
|
const subtitle = [note.provider, note.model_name].filter(Boolean).join(' · ')
|
||||||
|
const previewUrl = note.image_url || note.thumb_url || ''
|
||||||
|
const hasContent = Boolean(note.notes || note.strengths || note.weaknesses || note.best_for || note.settings || previewUrl || note.score || subtitle)
|
||||||
|
|
||||||
|
if (!hasContent) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className="rounded-[28px] border border-white/10 bg-[linear-gradient(180deg,rgba(255,255,255,0.05),rgba(15,23,42,0.22))] p-5 shadow-[0_16px_40px_rgba(2,6,23,0.16)]">
|
||||||
|
{previewUrl ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onOpenImage?.(galleryIndex)}
|
||||||
|
className="group mb-5 block w-full overflow-hidden rounded-[24px] border border-white/10 bg-slate-950 text-left transition hover:border-sky-300/25 focus:outline-none focus:ring-2 focus:ring-sky-300/35"
|
||||||
|
aria-label={`Open comparison image for ${title}`}
|
||||||
|
>
|
||||||
|
<div className="relative">
|
||||||
|
<img src={previewUrl} alt={title} loading="lazy" className="aspect-[4/3] w-full object-cover transition duration-500 group-hover:scale-[1.03]" />
|
||||||
|
<div className="absolute inset-x-0 bottom-0 flex items-center justify-between gap-3 bg-[linear-gradient(180deg,transparent,rgba(2,6,23,0.72))] px-4 py-3">
|
||||||
|
<span className="text-[10px] font-semibold uppercase tracking-[0.2em] text-slate-100/90">Click to zoom</span>
|
||||||
|
<span className="inline-flex h-9 w-9 items-center justify-center rounded-full border border-white/10 bg-black/25 text-white">
|
||||||
|
<i className="fa-solid fa-expand" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-[#ffcfbf]">AI comparison</p>
|
||||||
|
<h3 className="mt-2 text-xl font-semibold tracking-[-0.03em] text-white">{title}</h3>
|
||||||
|
{subtitle ? <p className="mt-1 text-sm text-slate-400">{subtitle}</p> : null}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-end gap-2">
|
||||||
|
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-300">{String(index + 1).padStart(2, '0')}</span>
|
||||||
|
{note.score ? <span className="rounded-full border border-[#ffcfbf]/20 bg-[#ffcfbf]/10 px-3 py-1 text-xs font-semibold text-[#fff0ea]">{`Score ${note.score}/10`}</span> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-5 space-y-4">
|
||||||
|
{note.settings ? (
|
||||||
|
<div className="rounded-[22px] border border-white/10 bg-black/25 p-4">
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">Generated in</p>
|
||||||
|
<p className="mt-2 whitespace-pre-wrap text-sm leading-7 text-slate-200">{note.settings}</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{note.notes ? (
|
||||||
|
<div>
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">Overall notes</p>
|
||||||
|
<p className="mt-2 whitespace-pre-wrap text-sm leading-7 text-slate-200">{note.notes}</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{note.best_for ? (
|
||||||
|
<div className="rounded-[22px] border border-sky-300/15 bg-sky-300/10 p-4">
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-100">Best for</p>
|
||||||
|
<p className="mt-2 whitespace-pre-wrap text-sm leading-7 text-slate-100">{note.best_for}</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
{note.strengths ? (
|
||||||
|
<div className="rounded-[22px] border border-emerald-300/15 bg-emerald-300/10 p-4">
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-emerald-100">Strengths</p>
|
||||||
|
<p className="mt-2 whitespace-pre-wrap text-sm leading-7 text-slate-100">{note.strengths}</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{note.weaknesses ? (
|
||||||
|
<div className="rounded-[22px] border border-amber-300/15 bg-amber-300/10 p-4">
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-amber-100">Weaknesses</p>
|
||||||
|
<p className="mt-2 whitespace-pre-wrap text-sm leading-7 text-slate-100">{note.weaknesses}</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function AiComparisonSection({ block }) {
|
function AiComparisonSection({ block }) {
|
||||||
const payload = block?.payload || {}
|
const payload = block?.payload || {}
|
||||||
const criteria = Array.isArray(payload.criteria) ? payload.criteria.filter(Boolean) : []
|
const criteria = Array.isArray(payload.criteria) ? payload.criteria.filter(Boolean) : []
|
||||||
@@ -227,42 +440,89 @@ function AiComparisonSection({ block }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AcademyShow({ pageType, item, relatedLessons = [], seo, pricingUrl, completeUrl, completed: initialCompleted, saveUrl, unsaveUrl, saved: initialSaved, submitUrl }) {
|
export default function AcademyShow({ pageType, item, relatedLessons = [], relatedCourses = [], previousLesson = null, nextLesson = null, seo, pricingUrl, completeUrl, completed: initialCompleted, saveUrl, unsaveUrl, saved: initialSaved, submitUrl, courseContext = null }) {
|
||||||
const flash = usePage().props.flash || {}
|
const flash = usePage().props.flash || {}
|
||||||
const [completed, setCompleted] = useState(Boolean(initialCompleted))
|
const [completed, setCompleted] = useState(Boolean(initialCompleted))
|
||||||
const [saved, setSaved] = useState(Boolean(initialSaved))
|
const [saved, setSaved] = useState(Boolean(initialSaved))
|
||||||
const [tableOfContents, setTableOfContents] = useState([])
|
const [tableOfContents, setTableOfContents] = useState([])
|
||||||
const [activeHeadingId, setActiveHeadingId] = useState('')
|
const [activeHeadingId, setActiveHeadingId] = useState('')
|
||||||
|
const [lightboxGallery, setLightboxGallery] = useState(null)
|
||||||
const articleContentRef = useRef(null)
|
const articleContentRef = useRef(null)
|
||||||
|
const handledInitialHashRef = useRef(false)
|
||||||
const lessonCover = item?.cover_image_url || item?.cover_image || ''
|
const lessonCover = item?.cover_image_url || item?.cover_image || ''
|
||||||
|
const articleCover = item?.article_cover_image_url || item?.article_cover_image || ''
|
||||||
const lessonCategory = item?.category?.name || 'Academy'
|
const lessonCategory = item?.category?.name || 'Academy'
|
||||||
|
const lessonSeries = String(item?.series_name || '').trim() || lessonCategory
|
||||||
const lessonDifficulty = item?.difficulty || 'Intermediate'
|
const lessonDifficulty = item?.difficulty || 'Intermediate'
|
||||||
const lessonMinutes = formatLessonMinutes(item?.reading_minutes)
|
const lessonMinutes = formatLessonMinutes(item?.reading_minutes)
|
||||||
const lessonUpdated = formatLessonDate(item?.published_at)
|
const lessonUpdated = formatLessonDate(item?.published_at)
|
||||||
const lessonBlocks = Array.isArray(item?.blocks) ? item.blocks : []
|
const lessonBlocks = Array.isArray(item?.blocks) ? item.blocks : []
|
||||||
const relatedLessonList = Array.isArray(relatedLessons) ? relatedLessons : []
|
const relatedLessonList = Array.isArray(relatedLessons) ? relatedLessons : []
|
||||||
|
const relatedCourseList = Array.isArray(relatedCourses) ? relatedCourses : []
|
||||||
|
const courseOutline = Array.isArray(courseContext?.outline) ? courseContext.outline : []
|
||||||
const lessonSummary = item.excerpt || item.description || item.prompt_preview || item.content_preview || 'A focused Academy lesson with practical guidance and examples.'
|
const lessonSummary = item.excerpt || item.description || item.prompt_preview || item.content_preview || 'A focused Academy lesson with practical guidance and examples.'
|
||||||
|
const lessonTags = Array.isArray(item?.tags) ? item.tags.filter(Boolean) : []
|
||||||
|
const promptPreviewImage = item?.preview_image || ''
|
||||||
|
const promptBody = item?.prompt || item?.prompt_preview || ''
|
||||||
|
const promptComparisons = Array.isArray(item?.tool_notes)
|
||||||
|
? item.tool_notes.filter((note) => note && typeof note === 'object' && note.active !== false && [
|
||||||
|
note.provider,
|
||||||
|
note.model_name,
|
||||||
|
note.notes,
|
||||||
|
note.strengths,
|
||||||
|
note.weaknesses,
|
||||||
|
note.best_for,
|
||||||
|
note.image_path,
|
||||||
|
note.image_url,
|
||||||
|
note.thumb_path,
|
||||||
|
note.thumb_url,
|
||||||
|
note.settings,
|
||||||
|
note.score,
|
||||||
|
].some(Boolean))
|
||||||
|
: []
|
||||||
|
const promptUsageNotes = String(item?.usage_notes || '').trim()
|
||||||
|
const promptWorkflowNotes = String(item?.workflow_notes || '').trim()
|
||||||
|
const promptHasFullAccess = Boolean(item?.prompt)
|
||||||
|
const promptModelsCovered = promptComparisons.map((note, index) => note.model_name || note.provider || `Model ${index + 1}`)
|
||||||
|
const promptComparisonGalleryImages = promptComparisons
|
||||||
|
.map((note, index) => {
|
||||||
|
const src = note.image_url || note.thumb_url || ''
|
||||||
|
if (!src) return null
|
||||||
|
|
||||||
|
return {
|
||||||
|
src,
|
||||||
|
alt: note.model_name || note.provider || `Comparison ${index + 1}`,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
const academyBreadcrumbs = pageType === 'prompt'
|
||||||
|
? [
|
||||||
|
{ label: 'Academy', href: '/academy' },
|
||||||
|
{ label: 'Prompt Library', href: '/academy/prompts' },
|
||||||
|
{ label: item?.title || 'Prompt' },
|
||||||
|
]
|
||||||
|
: []
|
||||||
const fontScaleStorageKey = 'academy.lesson.font-scale'
|
const fontScaleStorageKey = 'academy.lesson.font-scale'
|
||||||
const fontScaleMin = 0.95
|
const fontScaleMin = 0.95
|
||||||
const fontScaleMax = 1.12
|
const fontScaleMax = 1.12
|
||||||
const fontScaleStep = 0.04
|
const fontScaleStep = 0.04
|
||||||
const [lessonFontScale, setLessonFontScale] = useState(() => {
|
const [lessonFontScale, setLessonFontScale] = useState(1.04)
|
||||||
if (typeof window === 'undefined') {
|
|
||||||
return 1.04
|
const findArticleHeading = (headingId) => {
|
||||||
|
if (!headingId || typeof document === 'undefined') {
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const storedValue = Number(window.localStorage.getItem(fontScaleStorageKey))
|
const escapedHeadingId = typeof CSS !== 'undefined' && typeof CSS.escape === 'function'
|
||||||
|
? CSS.escape(headingId)
|
||||||
|
: String(headingId).replace(/[^a-zA-Z0-9_-]/g, '')
|
||||||
|
|
||||||
if (Number.isFinite(storedValue)) {
|
return articleContentRef.current?.querySelector(`#${escapedHeadingId}`) || document.getElementById(headingId)
|
||||||
return Math.min(fontScaleMax, Math.max(fontScaleMin, storedValue))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return 1.04
|
|
||||||
})
|
|
||||||
|
|
||||||
const markComplete = () => {
|
const markComplete = () => {
|
||||||
if (!completeUrl || completed) return
|
if (!completeUrl || completed) return
|
||||||
router.post(completeUrl, {}, {
|
router.post(completeUrl, courseContext?.completePayload || {}, {
|
||||||
preserveScroll: true,
|
preserveScroll: true,
|
||||||
onSuccess: () => setCompleted(true),
|
onSuccess: () => setCompleted(true),
|
||||||
})
|
})
|
||||||
@@ -285,6 +545,64 @@ export default function AcademyShow({ pageType, item, relatedLessons = [], seo,
|
|||||||
setLessonFontScale((current) => Math.min(fontScaleMax, Number((current + fontScaleStep).toFixed(2))))
|
setLessonFontScale((current) => Math.min(fontScaleMax, Number((current + fontScaleStep).toFixed(2))))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const openPromptPreviewImage = () => {
|
||||||
|
if (!promptPreviewImage) return
|
||||||
|
|
||||||
|
setLightboxGallery({
|
||||||
|
images: [{ src: promptPreviewImage, alt: item?.title || 'Prompt preview' }],
|
||||||
|
index: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const openPromptComparisonGallery = (index) => {
|
||||||
|
if (!promptComparisonGalleryImages.length) return
|
||||||
|
|
||||||
|
setLightboxGallery({
|
||||||
|
images: promptComparisonGalleryImages,
|
||||||
|
index: Math.max(0, Math.min(promptComparisonGalleryImages.length - 1, Number(index || 0))),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const navigateLightboxGallery = (direction) => {
|
||||||
|
setLightboxGallery((current) => {
|
||||||
|
if (!current?.images?.length) return current
|
||||||
|
|
||||||
|
const total = current.images.length
|
||||||
|
const nextIndex = typeof direction === 'number' && Math.abs(direction) > 1
|
||||||
|
? Math.max(0, Math.min(total - 1, current.index + direction))
|
||||||
|
: (current.index + direction + total) % total
|
||||||
|
|
||||||
|
return {
|
||||||
|
...current,
|
||||||
|
index: nextIndex,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const scrollToHeading = (headingId, behavior = 'smooth') => {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const heading = findArticleHeading(headingId)
|
||||||
|
|
||||||
|
if (!heading) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const top = Math.max(0, window.scrollY + heading.getBoundingClientRect().top - 112)
|
||||||
|
window.scrollTo({ top, behavior })
|
||||||
|
setActiveHeadingId(headingId)
|
||||||
|
|
||||||
|
if (window.history?.replaceState) {
|
||||||
|
window.history.replaceState(null, '', `${window.location.pathname}${window.location.search}#${headingId}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
handledInitialHashRef.current = false
|
||||||
|
}, [item?.slug])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (pageType !== 'lesson' || !item?.content || !articleContentRef.current) {
|
if (pageType !== 'lesson' || !item?.content || !articleContentRef.current) {
|
||||||
setTableOfContents([])
|
setTableOfContents([])
|
||||||
@@ -301,6 +619,7 @@ export default function AcademyShow({ pageType, item, relatedLessons = [], seo,
|
|||||||
|
|
||||||
seenIds.set(baseId, seenCount + 1)
|
seenIds.set(baseId, seenCount + 1)
|
||||||
heading.id = nextId
|
heading.id = nextId
|
||||||
|
heading.style.scrollMarginTop = '128px'
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: nextId,
|
id: nextId,
|
||||||
@@ -312,42 +631,98 @@ export default function AcademyShow({ pageType, item, relatedLessons = [], seo,
|
|||||||
setTableOfContents(nextTableOfContents)
|
setTableOfContents(nextTableOfContents)
|
||||||
}, [item?.content, pageType])
|
}, [item?.content, pageType])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (pageType !== 'lesson' || tableOfContents.length === 0 || handledInitialHashRef.current || typeof window === 'undefined') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const hash = window.location.hash.replace(/^#/, '').trim()
|
||||||
|
|
||||||
|
if (!hash) {
|
||||||
|
handledInitialHashRef.current = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchingEntry = tableOfContents.find((entry) => entry.id === hash)
|
||||||
|
|
||||||
|
if (!matchingEntry) {
|
||||||
|
handledInitialHashRef.current = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
handledInitialHashRef.current = true
|
||||||
|
window.requestAnimationFrame(() => scrollToHeading(matchingEntry.id, 'auto'))
|
||||||
|
}, [pageType, tableOfContents])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (pageType !== 'lesson' || tableOfContents.length === 0 || typeof window === 'undefined') {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleHashChange = () => {
|
||||||
|
const hash = window.location.hash.replace(/^#/, '').trim()
|
||||||
|
|
||||||
|
if (!hash) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchingEntry = tableOfContents.find((entry) => entry.id === hash)
|
||||||
|
|
||||||
|
if (!matchingEntry) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
window.requestAnimationFrame(() => scrollToHeading(matchingEntry.id, 'auto'))
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('hashchange', handleHashChange)
|
||||||
|
return () => window.removeEventListener('hashchange', handleHashChange)
|
||||||
|
}, [pageType, tableOfContents])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (pageType !== 'lesson' || tableOfContents.length === 0 || !articleContentRef.current) {
|
if (pageType !== 'lesson' || tableOfContents.length === 0 || !articleContentRef.current) {
|
||||||
setActiveHeadingId('')
|
setActiveHeadingId('')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const headingElements = Array.from(articleContentRef.current.querySelectorAll('h2, h3'))
|
const getActiveId = () => {
|
||||||
|
const headings = Array.from(articleContentRef.current.querySelectorAll('h2[id], h3[id]'))
|
||||||
|
if (!headings.length) return ''
|
||||||
|
|
||||||
if (!headingElements.length) {
|
// offset accounts for sticky header height + small buffer
|
||||||
setActiveHeadingId('')
|
const offset = 140
|
||||||
|
let activeId = headings[0].id
|
||||||
|
|
||||||
|
for (const heading of headings) {
|
||||||
|
if (heading.getBoundingClientRect().top <= offset) {
|
||||||
|
activeId = heading.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return activeId
|
||||||
|
}
|
||||||
|
|
||||||
|
setActiveHeadingId(getActiveId())
|
||||||
|
|
||||||
|
const onScroll = () => setActiveHeadingId(getActiveId())
|
||||||
|
window.addEventListener('scroll', onScroll, { passive: true })
|
||||||
|
|
||||||
|
return () => window.removeEventListener('scroll', onScroll)
|
||||||
|
}, [pageType, tableOfContents, lessonFontScale])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const observer = new IntersectionObserver((entries) => {
|
const storedValue = Number(window.localStorage.getItem(fontScaleStorageKey))
|
||||||
const visibleEntries = entries
|
|
||||||
.filter((entry) => entry.isIntersecting)
|
|
||||||
.sort((left, right) => left.boundingClientRect.top - right.boundingClientRect.top)
|
|
||||||
|
|
||||||
if (visibleEntries.length) {
|
if (!Number.isFinite(storedValue)) {
|
||||||
setActiveHeadingId((current) => visibleEntries[0].target.id || current)
|
return
|
||||||
}
|
|
||||||
}, {
|
|
||||||
root: null,
|
|
||||||
rootMargin: '-18% 0px -68% 0px',
|
|
||||||
threshold: [0, 1],
|
|
||||||
})
|
|
||||||
|
|
||||||
headingElements.forEach((heading) => observer.observe(heading))
|
|
||||||
|
|
||||||
const firstVisibleHeading = headingElements.find((heading) => heading.getBoundingClientRect().top >= 0) || headingElements[0]
|
|
||||||
if (firstVisibleHeading?.id) {
|
|
||||||
setActiveHeadingId(firstVisibleHeading.id)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => observer.disconnect()
|
setLessonFontScale(Math.min(fontScaleMax, Math.max(fontScaleMin, storedValue)))
|
||||||
}, [pageType, tableOfContents, lessonFontScale])
|
}, [fontScaleMax, fontScaleMin, fontScaleStorageKey])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
@@ -452,7 +827,7 @@ export default function AcademyShow({ pageType, item, relatedLessons = [], seo,
|
|||||||
}, [item?.content, lessonFontScale, pageType])
|
}, [item?.content, lessonFontScale, pageType])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.15),_transparent_24%),radial-gradient(circle_at_bottom_right,_rgba(251,191,36,0.16),_transparent_24%),linear-gradient(180deg,_#0f172a_0%,_#111827_100%)] px-4 py-8 sm:px-6 lg:px-8">
|
<main className="min-h-screen bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.16),_transparent_24%),radial-gradient(circle_at_bottom_right,_rgba(59,130,246,0.14),_transparent_26%),linear-gradient(180deg,_#0b1220_0%,_#111827_46%,_#0f172a_100%)] px-4 py-8 sm:px-6 lg:px-8">
|
||||||
<SeoHead seo={seo || {}} title={item?.title} description={item?.excerpt || item?.description} />
|
<SeoHead seo={seo || {}} title={item?.title} description={item?.excerpt || item?.description} />
|
||||||
|
|
||||||
<div className="mx-auto max-w-[1320px] space-y-6">
|
<div className="mx-auto max-w-[1320px] space-y-6">
|
||||||
@@ -475,9 +850,27 @@ export default function AcademyShow({ pageType, item, relatedLessons = [], seo,
|
|||||||
<span className="rounded-full border border-white/10 bg-white/[0.05] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.24em] text-slate-300">{lessonDifficulty}</span>
|
<span className="rounded-full border border-white/10 bg-white/[0.05] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.24em] text-slate-300">{lessonDifficulty}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{item.lesson_label ? <p className="mt-5 text-sm font-semibold uppercase tracking-[0.24em] text-amber-100">{item.lesson_label}</p> : null}
|
||||||
|
|
||||||
<h1 className="mt-5 text-4xl font-semibold tracking-[-0.055em] text-white md:text-5xl lg:text-6xl">{item.title}</h1>
|
<h1 className="mt-5 text-4xl font-semibold tracking-[-0.055em] text-white md:text-5xl lg:text-6xl">{item.title}</h1>
|
||||||
<p className="mt-5 max-w-2xl text-base leading-8 text-slate-300 md:text-lg">{lessonSummary}</p>
|
<p className="mt-5 max-w-2xl text-base leading-8 text-slate-300 md:text-lg">{lessonSummary}</p>
|
||||||
|
|
||||||
|
{lessonTags.length ? (
|
||||||
|
<div className="mt-5 flex flex-wrap gap-2">
|
||||||
|
{lessonTags.map((tag) => (
|
||||||
|
<span key={tag} className="rounded-full border border-white/10 bg-white/[0.05] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-200">{tag}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{courseContext?.title ? (
|
||||||
|
<div className="mt-6 max-w-2xl rounded-[24px] border border-white/10 bg-black/25 p-5">
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Part of course</p>
|
||||||
|
<Link href={courseContext.showUrl} className="mt-2 inline-flex text-lg font-semibold text-sky-100 transition hover:text-white">{courseContext.title}</Link>
|
||||||
|
<p className="mt-2 text-sm leading-7 text-slate-300">{courseContext.subtitle || 'This lesson is being viewed inside a structured Academy course path.'}</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div className="mt-7 flex flex-wrap gap-3">
|
<div className="mt-7 flex flex-wrap gap-3">
|
||||||
{completeUrl ? <button type="button" onClick={markComplete} className="rounded-full border border-emerald-300/25 bg-emerald-300/12 px-5 py-3 text-sm font-semibold text-emerald-100">{completed ? 'Completed' : 'Mark complete'}</button> : null}
|
{completeUrl ? <button type="button" onClick={markComplete} className="rounded-full border border-emerald-300/25 bg-emerald-300/12 px-5 py-3 text-sm font-semibold text-emerald-100">{completed ? 'Completed' : 'Mark complete'}</button> : null}
|
||||||
{saveUrl ? <button type="button" onClick={toggleSave} className="rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100">{saved ? 'Saved' : 'Save prompt'}</button> : null}
|
{saveUrl ? <button type="button" onClick={toggleSave} className="rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100">{saved ? 'Saved' : 'Save prompt'}</button> : null}
|
||||||
@@ -488,7 +881,7 @@ export default function AcademyShow({ pageType, item, relatedLessons = [], seo,
|
|||||||
<StatPill label="Category" value={lessonCategory} />
|
<StatPill label="Category" value={lessonCategory} />
|
||||||
<StatPill label="Reading" value={lessonMinutes} />
|
<StatPill label="Reading" value={lessonMinutes} />
|
||||||
<StatPill label="Updated" value={lessonUpdated} />
|
<StatPill label="Updated" value={lessonUpdated} />
|
||||||
<StatPill label="Access" value={item.access_level || 'free'} />
|
<StatPill label={courseContext?.title ? 'Course progress' : 'Access'} value={courseContext?.progress ? `${courseContext.progress.percent}%` : (item.access_level || 'free')} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -500,7 +893,8 @@ export default function AcademyShow({ pageType, item, relatedLessons = [], seo,
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<LessonInfoRow label="Series" value={lessonCategory} />
|
<LessonInfoRow label="Series" value={lessonSeries} />
|
||||||
|
{item.formatted_lesson_number ? <LessonInfoRow label="Lesson" value={item.formatted_lesson_number} /> : null}
|
||||||
<LessonInfoRow label="Difficulty" value={lessonDifficulty} />
|
<LessonInfoRow label="Difficulty" value={lessonDifficulty} />
|
||||||
<LessonInfoRow label="Reading time" value={lessonMinutes} />
|
<LessonInfoRow label="Reading time" value={lessonMinutes} />
|
||||||
<LessonInfoRow label="Published" value={lessonUpdated} />
|
<LessonInfoRow label="Published" value={lessonUpdated} />
|
||||||
@@ -508,7 +902,7 @@ export default function AcademyShow({ pageType, item, relatedLessons = [], seo,
|
|||||||
|
|
||||||
<div className="rounded-[28px] border border-white/10 bg-black/20 p-5">
|
<div className="rounded-[28px] border border-white/10 bg-black/20 p-5">
|
||||||
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-slate-400">Lesson status</p>
|
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-slate-400">Lesson status</p>
|
||||||
<p className="mt-3 text-sm leading-7 text-slate-300">{item.locked ? 'This lesson is partially locked for your account level.' : 'Full lesson content is available below.'}</p>
|
<p className="mt-3 text-sm leading-7 text-slate-300">{item.locked ? 'This lesson is partially locked for your account level.' : courseContext?.title ? 'This lesson is being tracked inside a course. Completion updates your course progress.' : 'Full lesson content is available below.'}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
@@ -516,7 +910,7 @@ export default function AcademyShow({ pageType, item, relatedLessons = [], seo,
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div className="grid gap-8 lg:grid-cols-[minmax(0,1fr)_360px]">
|
<div className="grid gap-8 lg:grid-cols-[minmax(0,1fr)_360px]">
|
||||||
<article className="rounded-[32px] border border-white/10 bg-white/[0.04] p-6 text-slate-200 md:p-8">
|
<article className="rounded-[32px] border border-white/10 bg-[linear-gradient(180deg,rgba(255,255,255,0.045),rgba(148,163,184,0.03))] p-6 text-slate-200 shadow-[0_24px_70px_rgba(2,6,23,0.2)] md:p-8">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-4 border-b border-white/10 pb-5">
|
<div className="flex flex-wrap items-center justify-between gap-4 border-b border-white/10 pb-5">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-sky-200/80">Article</p>
|
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-sky-200/80">Article</p>
|
||||||
@@ -549,6 +943,12 @@ export default function AcademyShow({ pageType, item, relatedLessons = [], seo,
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
|
{articleCover ? (
|
||||||
|
<div className="mb-8 overflow-hidden rounded-[28px] border border-white/10 bg-black/20">
|
||||||
|
<img src={articleCover} alt={`${item.title} article cover`} className="w-full object-cover" />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{item.content ? (
|
{item.content ? (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<div
|
<div
|
||||||
@@ -566,11 +966,23 @@ export default function AcademyShow({ pageType, item, relatedLessons = [], seo,
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{(previousLesson || nextLesson) ? (
|
||||||
|
<section className="mt-10 border-t border-white/10 pt-8">
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-slate-400">{courseContext?.title ? 'Course navigation' : 'Lesson navigation'}</p>
|
||||||
|
<h3 className="mt-2 text-2xl font-semibold tracking-[-0.04em] text-white">{courseContext?.title ? 'Continue this course' : 'Continue in order'}</h3>
|
||||||
|
|
||||||
|
<div className="mt-5 grid gap-4 md:grid-cols-2">
|
||||||
|
<LessonNavCard direction="previous" lesson={previousLesson} />
|
||||||
|
<LessonNavCard direction="next" lesson={nextLesson} />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<aside className="space-y-6 lg:sticky lg:top-6 lg:self-start">
|
<aside className="space-y-6 lg:sticky lg:top-6 lg:self-start">
|
||||||
{tableOfContents.length ? (
|
{tableOfContents.length ? (
|
||||||
<section className="rounded-[32px] border border-white/10 bg-white/[0.04] p-6 text-slate-200">
|
<section className="rounded-[32px] border border-white/10 bg-[linear-gradient(180deg,rgba(15,23,42,0.92),rgba(15,23,42,0.84))] p-6 text-slate-200 shadow-[0_18px_50px_rgba(2,6,23,0.18)]">
|
||||||
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-slate-400">On this page</p>
|
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-slate-400">On this page</p>
|
||||||
<h3 className="mt-2 text-2xl font-semibold tracking-[-0.04em] text-white">Table of contents</h3>
|
<h3 className="mt-2 text-2xl font-semibold tracking-[-0.04em] text-white">Table of contents</h3>
|
||||||
|
|
||||||
@@ -579,6 +991,10 @@ export default function AcademyShow({ pageType, item, relatedLessons = [], seo,
|
|||||||
<a
|
<a
|
||||||
key={entry.id}
|
key={entry.id}
|
||||||
href={`#${entry.id}`}
|
href={`#${entry.id}`}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
scrollToHeading(entry.id)
|
||||||
|
}}
|
||||||
aria-current={activeHeadingId === entry.id ? 'location' : undefined}
|
aria-current={activeHeadingId === entry.id ? 'location' : undefined}
|
||||||
className={`academy-lesson-toc-link ${entry.level === 'h3' ? 'academy-lesson-toc-link-subtle' : ''} ${activeHeadingId === entry.id ? 'academy-lesson-toc-link-active' : ''}`}
|
className={`academy-lesson-toc-link ${entry.level === 'h3' ? 'academy-lesson-toc-link-subtle' : ''} ${activeHeadingId === entry.id ? 'academy-lesson-toc-link-active' : ''}`}
|
||||||
>
|
>
|
||||||
@@ -590,27 +1006,56 @@ export default function AcademyShow({ pageType, item, relatedLessons = [], seo,
|
|||||||
</section>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<section className="rounded-[32px] border border-white/10 bg-white/[0.04] p-6 text-slate-200">
|
<section className="rounded-[32px] border border-white/10 bg-[linear-gradient(180deg,rgba(15,23,42,0.92),rgba(15,23,42,0.84))] p-6 text-slate-200 shadow-[0_18px_50px_rgba(2,6,23,0.18)]">
|
||||||
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-slate-400">Series info</p>
|
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-slate-400">{courseContext?.title ? 'Course progress' : 'Series info'}</p>
|
||||||
<h3 className="mt-2 text-2xl font-semibold tracking-[-0.04em] text-white">{lessonCategory}</h3>
|
<h3 className="mt-2 text-2xl font-semibold tracking-[-0.04em] text-white">{courseContext?.title ? courseContext.title : lessonSeries}</h3>
|
||||||
<div className="mt-5 space-y-3">
|
<div className="mt-5 space-y-3">
|
||||||
<LessonInfoRow label="Category" value={lessonCategory} />
|
<LessonInfoRow label="Category" value={lessonCategory} />
|
||||||
|
{item.formatted_lesson_number ? <LessonInfoRow label="Lesson" value={item.formatted_lesson_number} /> : null}
|
||||||
<LessonInfoRow label="Difficulty" value={lessonDifficulty} />
|
<LessonInfoRow label="Difficulty" value={lessonDifficulty} />
|
||||||
<LessonInfoRow label="Reading" value={lessonMinutes} />
|
<LessonInfoRow label="Reading" value={lessonMinutes} />
|
||||||
<LessonInfoRow label="Updated" value={lessonUpdated} />
|
<LessonInfoRow label={courseContext?.title ? 'Progress' : 'Updated'} value={courseContext?.progress ? `${courseContext.progress.completedRequired}/${courseContext.progress.totalRequired} completed` : lessonUpdated} />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{courseOutline.length ? (
|
||||||
|
<section className="rounded-[32px] border border-white/10 bg-[linear-gradient(180deg,rgba(15,23,42,0.92),rgba(15,23,42,0.84))] p-6 text-slate-200 shadow-[0_18px_50px_rgba(2,6,23,0.18)]">
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-slate-400">Course outline</p>
|
||||||
|
<div className="mt-4 space-y-2">
|
||||||
|
{courseOutline.map((outlineLesson, index) => (
|
||||||
|
<Link key={outlineLesson.course_lesson_id || outlineLesson.id || index} href={outlineLesson.course_url || `/academy/lessons/${outlineLesson.slug}`} className={`flex items-start gap-3 rounded-[20px] border px-4 py-3 text-sm transition ${outlineLesson.slug === item.slug ? 'border-sky-300/25 bg-sky-300/10 text-sky-100' : 'border-white/10 bg-black/20 text-slate-300 hover:border-sky-300/25 hover:bg-white/[0.06]'}`}>
|
||||||
|
<span className="mt-0.5 inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-full border border-white/10 bg-white/[0.05] text-[10px] font-semibold">{String(index + 1).padStart(2, '0')}</span>
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="block font-semibold">{outlineLesson.title}</span>
|
||||||
|
<span className="mt-1 block text-xs uppercase tracking-[0.16em] text-slate-500">{outlineLesson.is_required ? 'Required' : 'Optional'}</span>
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{lessonTags.length ? (
|
||||||
|
<section className="rounded-[32px] border border-white/10 bg-[linear-gradient(180deg,rgba(15,23,42,0.92),rgba(15,23,42,0.84))] p-6 text-slate-200 shadow-[0_18px_50px_rgba(2,6,23,0.18)]">
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-slate-400">Microtags</p>
|
||||||
|
<div className="mt-4 flex flex-wrap gap-2">
|
||||||
|
{lessonTags.map((tag) => (
|
||||||
|
<span key={tag} className="rounded-full border border-sky-300/20 bg-sky-300/10 px-3 py-1.5 text-xs font-semibold uppercase tracking-[0.18em] text-sky-100">{tag}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{relatedLessonList.length ? (
|
{relatedLessonList.length ? (
|
||||||
<section className="rounded-[32px] border border-white/10 bg-white/[0.04] p-6 text-slate-200">
|
<section className="rounded-[32px] border border-white/10 bg-[linear-gradient(180deg,rgba(15,23,42,0.92),rgba(15,23,42,0.84))] p-6 text-slate-200 shadow-[0_18px_50px_rgba(2,6,23,0.18)]">
|
||||||
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-slate-400">Continue learning</p>
|
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-slate-400">Continue learning</p>
|
||||||
<h3 className="mt-2 text-2xl font-semibold tracking-[-0.04em] text-white">More in {lessonCategory}</h3>
|
<h3 className="mt-2 text-2xl font-semibold tracking-[-0.04em] text-white">More in {lessonSeries}</h3>
|
||||||
|
|
||||||
<div className="mt-5 space-y-3">
|
<div className="mt-5 space-y-3">
|
||||||
{relatedLessonList.map((relatedLesson, index) => (
|
{relatedLessonList.map((relatedLesson, index) => (
|
||||||
<Link
|
<Link
|
||||||
key={relatedLesson.id}
|
key={relatedLesson.id}
|
||||||
href={`/academy/lessons/${relatedLesson.slug}`}
|
href={relatedLesson.course_url || `/academy/lessons/${relatedLesson.slug}`}
|
||||||
className="group flex gap-4 rounded-[22px] border border-white/10 bg-black/20 p-4 transition hover:border-sky-300/25 hover:bg-white/[0.06]"
|
className="group flex gap-4 rounded-[22px] border border-white/10 bg-black/20 p-4 transition hover:border-sky-300/25 hover:bg-white/[0.06]"
|
||||||
>
|
>
|
||||||
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-2xl border border-sky-300/15 bg-sky-300/10 text-sm font-semibold text-sky-100">
|
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-2xl border border-sky-300/15 bg-sky-300/10 text-sm font-semibold text-sky-100">
|
||||||
@@ -619,7 +1064,10 @@ export default function AcademyShow({ pageType, item, relatedLessons = [], seo,
|
|||||||
|
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
{relatedLesson.formatted_lesson_number ? <p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-amber-100">{relatedLesson.formatted_lesson_number}</p> : null}
|
||||||
<h4 className="text-sm font-semibold text-white transition group-hover:text-sky-100">{relatedLesson.title}</h4>
|
<h4 className="text-sm font-semibold text-white transition group-hover:text-sky-100">{relatedLesson.title}</h4>
|
||||||
|
</div>
|
||||||
<span className="shrink-0 rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-400">{formatLessonMinutes(relatedLesson.reading_minutes)}</span>
|
<span className="shrink-0 rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-400">{formatLessonMinutes(relatedLesson.reading_minutes)}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-2 text-xs leading-6 text-slate-400">{relatedLesson.excerpt || relatedLesson.content_preview || 'Continue the series with the next lesson.'}</p>
|
<p className="mt-2 text-xs leading-6 text-slate-400">{relatedLesson.excerpt || relatedLesson.content_preview || 'Continue the series with the next lesson.'}</p>
|
||||||
@@ -629,20 +1077,242 @@ export default function AcademyShow({ pageType, item, relatedLessons = [], seo,
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{relatedCourseList.length ? (
|
||||||
|
<section className="rounded-[32px] border border-white/10 bg-[linear-gradient(180deg,rgba(15,23,42,0.92),rgba(15,23,42,0.84))] p-6 text-slate-200 shadow-[0_18px_50px_rgba(2,6,23,0.18)]">
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-slate-400">Related courses</p>
|
||||||
|
<div className="mt-4 space-y-3">
|
||||||
|
{relatedCourseList.map((course) => (
|
||||||
|
<Link key={course.id} href={course.public_url} className="block rounded-[22px] border border-white/10 bg-black/20 p-4 transition hover:border-sky-300/25 hover:bg-white/[0.06]">
|
||||||
|
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">{course.difficulty} · {course.access_level}</p>
|
||||||
|
<h4 className="mt-2 text-sm font-semibold text-white">{course.title}</h4>
|
||||||
|
<p className="mt-2 text-xs leading-6 text-slate-400">{course.excerpt || course.description || 'Open this course to continue with a guided path.'}</p>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : pageType === 'prompt' ? (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<section className="overflow-hidden rounded-[40px] border border-white/10 bg-[linear-gradient(135deg,rgba(4,10,20,0.98),rgba(15,23,42,0.9))] shadow-[0_24px_90px_rgba(15,23,42,0.34)]">
|
||||||
|
<div className="grid gap-0 lg:grid-cols-[minmax(420px,0.92fr)_minmax(0,1.08fr)]">
|
||||||
|
<div className="relative border-b border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.18),transparent_34%),radial-gradient(circle_at_bottom_right,rgba(255,183,139,0.18),transparent_32%),linear-gradient(180deg,rgba(5,10,20,0.98),rgba(10,17,30,0.94))] p-6 md:p-8 lg:min-h-[760px] lg:border-b-0 lg:border-r lg:border-white/10 lg:p-10">
|
||||||
|
<div className="absolute inset-0 bg-[radial-gradient(circle_at_20%_20%,rgba(125,211,252,0.12),transparent_24%),radial-gradient(circle_at_80%_75%,rgba(255,207,191,0.12),transparent_28%)]" />
|
||||||
|
<div className="relative flex h-full flex-col">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-[#ffd8cd]">Preview artwork</p>
|
||||||
|
{promptPreviewImage ? <span className="rounded-full border border-white/10 bg-white/[0.05] px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-300">Click to zoom</span> : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={openPromptPreviewImage}
|
||||||
|
className="group mt-4 flex-1 overflow-hidden rounded-[32px] border border-white/10 bg-black/30 text-left shadow-[0_24px_80px_rgba(2,6,23,0.26)] transition hover:border-sky-300/25 focus:outline-none focus:ring-2 focus:ring-sky-300/35"
|
||||||
|
disabled={!promptPreviewImage}
|
||||||
|
aria-label={promptPreviewImage ? `Open preview image for ${item.title}` : 'Preview image unavailable'}
|
||||||
|
>
|
||||||
|
{promptPreviewImage ? (
|
||||||
|
<div className="relative h-full min-h-[360px] overflow-hidden lg:min-h-[620px]">
|
||||||
|
<img src={promptPreviewImage} alt={item.title} className="h-full w-full object-cover transition duration-500 group-hover:scale-[1.03]" />
|
||||||
|
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(2,6,23,0.02),rgba(2,6,23,0.28))]" />
|
||||||
|
<div className="absolute bottom-4 left-4 right-4 flex items-end justify-between gap-4 rounded-[24px] border border-white/10 bg-black/25 px-4 py-3 backdrop-blur-md">
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] font-semibold uppercase tracking-[0.22em] text-sky-100/80">Prompt visual</p>
|
||||||
|
<p className="mt-1 text-sm font-semibold text-white">Open full-size preview</p>
|
||||||
|
</div>
|
||||||
|
<span className="inline-flex h-10 w-10 items-center justify-center rounded-full border border-white/10 bg-white/10 text-white">
|
||||||
|
<i className="fa-solid fa-expand" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full min-h-[360px] items-center justify-center bg-[linear-gradient(135deg,rgba(251,146,60,0.14),rgba(17,24,39,0.96))] px-8 text-center lg:min-h-[620px]">
|
||||||
|
<div>
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-slate-400">Visual placeholder</p>
|
||||||
|
<p className="mt-4 text-lg font-semibold text-white">Preview image coming soon</p>
|
||||||
|
<p className="mt-3 text-sm leading-7 text-slate-300">This prompt page will feel much better once the generated cover image is attached.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative overflow-hidden p-8 md:p-10 lg:p-12">
|
||||||
|
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_left,_rgba(255,183,139,0.14),_transparent_28%),radial-gradient(circle_at_bottom_right,_rgba(56,189,248,0.12),_transparent_28%)]" />
|
||||||
|
<div className="relative z-10 max-w-4xl">
|
||||||
|
{academyBreadcrumbs.length ? (
|
||||||
|
<div className="mb-6">
|
||||||
|
<AcademyBreadcrumbs items={academyBreadcrumbs} />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<span className="rounded-full border border-[#ffcfbf]/20 bg-[#ffcfbf]/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.24em] text-[#fff0ea]">Skinbase AI Academy</span>
|
||||||
|
<span className="rounded-full border border-white/10 bg-white/[0.05] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.24em] text-slate-300">{lessonCategory}</span>
|
||||||
|
<span className="rounded-full border border-white/10 bg-white/[0.05] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.24em] text-slate-300">{lessonDifficulty}</span>
|
||||||
|
{item.aspect_ratio ? <span className="rounded-full border border-white/10 bg-white/[0.05] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.24em] text-slate-300">{item.aspect_ratio}</span> : null}
|
||||||
|
{item.prompt_of_week ? <span className="rounded-full border border-amber-300/25 bg-amber-300/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.24em] text-amber-100">Prompt of the week</span> : null}
|
||||||
|
{item.featured ? <span className="rounded-full border border-sky-300/20 bg-sky-300/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.24em] text-sky-100">Featured</span> : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mt-8 text-sm font-semibold uppercase tracking-[0.24em] text-[#ffd8cd]">Prompt template</p>
|
||||||
|
<h1 className="mt-4 max-w-4xl text-4xl font-semibold tracking-[-0.055em] text-white md:text-5xl xl:text-6xl">{item.title}</h1>
|
||||||
|
<p className="mt-5 max-w-3xl text-base leading-8 text-slate-300 md:text-lg">{lessonSummary}</p>
|
||||||
|
|
||||||
|
<div className="mt-7 flex flex-wrap gap-3">
|
||||||
|
{saveUrl ? <button type="button" onClick={toggleSave} className="rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100">{saved ? 'Saved' : 'Save prompt'}</button> : null}
|
||||||
|
{promptBody ? <PromptCopyButton prompt={promptBody} /> : null}
|
||||||
|
{item.negative_prompt ? <PromptCopyButton prompt={item.negative_prompt} label="Copy negative" /> : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-8 grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||||
|
<StatPill label="Category" value={lessonCategory} />
|
||||||
|
<StatPill label="Access" value={item.access_level || 'free'} />
|
||||||
|
<StatPill label="Difficulty" value={lessonDifficulty} />
|
||||||
|
<StatPill label="Updated" value={lessonUpdated} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{lessonTags.length ? (
|
||||||
|
<div className="mt-8 rounded-[28px] border border-white/10 bg-[linear-gradient(180deg,rgba(255,255,255,0.045),rgba(148,163,184,0.03))] p-5">
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-slate-400">Microtags</p>
|
||||||
|
<div className="mt-4 flex flex-wrap gap-2">
|
||||||
|
{lessonTags.map((tag) => (
|
||||||
|
<span key={tag} className="rounded-full border border-sky-300/20 bg-sky-300/10 px-3 py-1.5 text-xs font-semibold uppercase tracking-[0.18em] text-sky-100">{tag}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="mt-8 grid gap-4 xl:grid-cols-[minmax(0,1.05fr)_minmax(280px,0.95fr)]">
|
||||||
|
<div className="rounded-[28px] border border-white/10 bg-black/20 p-5">
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-slate-400">Prompt status</p>
|
||||||
|
<p className="mt-3 text-sm leading-7 text-slate-300">
|
||||||
|
{item.locked
|
||||||
|
? 'This page shows the prompt summary, but the full prompt text and editor notes stay locked until your Academy access level matches the template.'
|
||||||
|
: 'This template includes the main prompt, reuse guidance, and model-specific comparison notes in one place.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{promptModelsCovered.length ? (
|
||||||
|
<div className="rounded-[28px] border border-[#ffcfbf]/12 bg-[linear-gradient(180deg,rgba(255,207,191,0.08),rgba(255,255,255,0.03))] p-5">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-[#ffd8cd]">Compared with</p>
|
||||||
|
<p className="mt-2 text-sm text-slate-300">{promptModelsCovered.length} model{promptModelsCovered.length > 1 ? 's' : ''} documented for this prompt.</p>
|
||||||
|
</div>
|
||||||
|
<span className="rounded-full border border-white/10 bg-white/[0.05] px-3 py-1 text-xs font-semibold text-white">{promptModelsCovered.length}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 flex flex-wrap gap-2">
|
||||||
|
{promptModelsCovered.map((model) => (
|
||||||
|
<span key={model} className="rounded-full border border-[#ffcfbf]/15 bg-[#ffcfbf]/10 px-3 py-1.5 text-xs font-semibold uppercase tracking-[0.18em] text-[#fff0ea]">{model}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="grid gap-8 lg:grid-cols-[minmax(0,1fr)_360px]">
|
||||||
|
<div className="space-y-8">
|
||||||
|
<section className="rounded-[32px] border border-white/10 bg-[linear-gradient(180deg,rgba(255,255,255,0.045),rgba(148,163,184,0.03))] p-6 text-slate-200 shadow-[0_24px_70px_rgba(2,6,23,0.2)] md:p-8">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-4 border-b border-white/10 pb-5">
|
||||||
|
<div>
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-[#ffd8cd]">Prompt body</p>
|
||||||
|
<h2 className="mt-2 text-2xl font-semibold tracking-[-0.04em] text-white">Prompt text and exclusions</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 space-y-6">
|
||||||
|
<div className="rounded-[28px] border border-[#ffcfbf]/15 bg-[linear-gradient(180deg,rgba(255,207,191,0.08),rgba(255,255,255,0.03))] p-5 md:p-6">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-[#fff0ea]">{promptHasFullAccess ? 'Full prompt' : 'Preview prompt'}</p>
|
||||||
|
<p className="mt-2 text-xs uppercase tracking-[0.16em] text-slate-500">{promptHasFullAccess ? 'Ready to paste into your generation workflow.' : 'Upgrade your Academy access to reveal the complete prompt text.'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<pre className="mt-4 whitespace-pre-wrap rounded-[24px] border border-white/10 bg-slate-950/80 p-4 text-sm leading-7 text-slate-100 md:p-5">{promptBody || 'Prompt text is not available yet.'}</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{item.negative_prompt ? (
|
||||||
|
<div className="rounded-[28px] border border-white/10 bg-black/20 p-5 md:p-6">
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Negative prompt</p>
|
||||||
|
<pre className="mt-4 whitespace-pre-wrap rounded-[24px] border border-white/10 bg-slate-950/70 p-4 text-sm leading-7 text-slate-200 md:p-5">{item.negative_prompt}</pre>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{(promptUsageNotes || promptWorkflowNotes) ? (
|
||||||
|
<section className="rounded-[32px] border border-white/10 bg-[linear-gradient(180deg,rgba(15,23,42,0.92),rgba(15,23,42,0.82))] p-6 text-slate-200 shadow-[0_18px_50px_rgba(2,6,23,0.18)] md:p-8">
|
||||||
|
<div className="flex flex-wrap items-end justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-slate-400">Prompt guidance</p>
|
||||||
|
<h2 className="mt-2 text-2xl font-semibold tracking-[-0.04em] text-white">How to use this prompt</h2>
|
||||||
|
</div>
|
||||||
|
{!promptHasFullAccess ? <span className="rounded-full border border-amber-300/20 bg-amber-300/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-amber-100">Full notes visible with access</span> : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 grid gap-5 md:grid-cols-2">
|
||||||
|
{promptUsageNotes ? (
|
||||||
|
<div className="rounded-[26px] border border-white/10 bg-black/20 p-5">
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-sky-200/75">Usage notes</p>
|
||||||
|
<p className="mt-3 whitespace-pre-wrap text-sm leading-7 text-slate-200">{promptUsageNotes}</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{promptWorkflowNotes ? (
|
||||||
|
<div className="rounded-[26px] border border-white/10 bg-black/20 p-5">
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-emerald-200/75">Workflow notes</p>
|
||||||
|
<p className="mt-3 whitespace-pre-wrap text-sm leading-7 text-slate-200">{promptWorkflowNotes}</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{promptComparisons.length ? (
|
||||||
|
<section className="rounded-[32px] border border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(255,183,139,0.12),transparent_30%),linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,0.92))] p-6 text-slate-200 shadow-[0_24px_80px_rgba(2,6,23,0.28)] md:p-8">
|
||||||
|
<div className="max-w-3xl">
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-[#ffcfbf]">AI model comparisons</p>
|
||||||
|
<h2 className="mt-2 text-2xl font-semibold tracking-[-0.04em] text-white md:text-3xl">How different models respond to the same prompt</h2>
|
||||||
|
<p className="mt-3 text-sm leading-7 text-slate-300 md:text-base">Use these notes to decide which provider fits the result you want before you start tuning or post-processing.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 grid gap-5 xl:grid-cols-2">
|
||||||
|
{promptComparisons.map((note, index) => <PromptToolNoteCard key={`${note.provider || 'provider'}-${note.model_name || 'model'}-${index}`} note={note} index={index} galleryIndex={index} onOpenImage={openPromptComparisonGallery} />)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside className="space-y-6 lg:sticky lg:top-6 lg:self-start">
|
||||||
|
{lessonTags.length ? (
|
||||||
|
<section className="rounded-[32px] border border-white/10 bg-[linear-gradient(180deg,rgba(15,23,42,0.92),rgba(15,23,42,0.84))] p-6 text-slate-200 shadow-[0_18px_50px_rgba(2,6,23,0.18)]">
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-slate-400">Microtags</p>
|
||||||
|
<div className="mt-4 flex flex-wrap gap-2">
|
||||||
|
{lessonTags.map((tag) => (
|
||||||
|
<span key={tag} className="rounded-full border border-sky-300/20 bg-sky-300/10 px-3 py-1.5 text-xs font-semibold uppercase tracking-[0.18em] text-sky-100">{tag}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<section className="rounded-[32px] border border-white/10 bg-[linear-gradient(180deg,rgba(15,23,42,0.92),rgba(15,23,42,0.84))] p-6 text-slate-200 shadow-[0_18px_50px_rgba(2,6,23,0.18)]">
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-slate-400">Best use case</p>
|
||||||
|
<p className="mt-3 text-sm leading-7 text-slate-300">{promptComparisons[0]?.best_for || promptUsageNotes || lessonSummary}</p>
|
||||||
|
</section>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<section className="rounded-[30px] border border-white/10 bg-white/[0.04] p-6 text-slate-200">
|
<section className="rounded-[30px] border border-white/10 bg-white/[0.04] p-6 text-slate-200">
|
||||||
{pageType === 'prompt' ? (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div>
|
|
||||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Prompt</p>
|
|
||||||
<pre className="mt-3 whitespace-pre-wrap rounded-2xl border border-white/10 bg-black/20 p-4 text-sm leading-7 text-slate-200">{item.prompt || item.prompt_preview}</pre>
|
|
||||||
</div>
|
|
||||||
{item.negative_prompt ? <div><p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Negative prompt</p><pre className="mt-3 whitespace-pre-wrap rounded-2xl border border-white/10 bg-black/20 p-4 text-sm leading-7 text-slate-200">{item.negative_prompt}</pre></div> : null}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{pageType === 'pack' ? (
|
{pageType === 'pack' ? (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<p className="text-sm leading-8 text-slate-200">{item.description}</p>
|
<p className="text-sm leading-8 text-slate-200">{item.description}</p>
|
||||||
@@ -686,6 +1356,8 @@ export default function AcademyShow({ pageType, item, relatedLessons = [], seo,
|
|||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ImageLightbox gallery={lightboxGallery} onClose={() => setLightboxGallery(null)} onNavigate={navigateLightboxGallery} />
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
540
resources/js/Pages/Admin/Academy/CourseBuilder.jsx
Normal file
540
resources/js/Pages/Admin/Academy/CourseBuilder.jsx
Normal file
@@ -0,0 +1,540 @@
|
|||||||
|
import React, { useEffect, useMemo, useState } from 'react'
|
||||||
|
import { Head, Link, router, useForm, usePage } from '@inertiajs/react'
|
||||||
|
import AdminLayout from '../../../Layouts/AdminLayout'
|
||||||
|
import NovaSelect from '../../../components/ui/NovaSelect'
|
||||||
|
|
||||||
|
function laneKey(sectionId) {
|
||||||
|
return sectionId == null ? 'unsectioned' : `section:${sectionId}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortSections(items = []) {
|
||||||
|
return [...items].sort((left, right) => {
|
||||||
|
const orderDiff = Number(left?.order_num || 0) - Number(right?.order_num || 0)
|
||||||
|
if (orderDiff !== 0) return orderDiff
|
||||||
|
return Number(left?.id || 0) - Number(right?.id || 0)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortLessons(items = []) {
|
||||||
|
return [...items].sort((left, right) => {
|
||||||
|
const leftSection = left?.section_id == null ? -1 : Number(left.section_id)
|
||||||
|
const rightSection = right?.section_id == null ? -1 : Number(right.section_id)
|
||||||
|
|
||||||
|
if (leftSection !== rightSection) return leftSection - rightSection
|
||||||
|
|
||||||
|
const orderDiff = Number(left?.order_num || 0) - Number(right?.order_num || 0)
|
||||||
|
if (orderDiff !== 0) return orderDiff
|
||||||
|
|
||||||
|
return Number(left?.id || 0) - Number(right?.id || 0)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildLessonLanes(sections = [], lessons = []) {
|
||||||
|
const orderedSections = sortSections(sections)
|
||||||
|
const orderedLessons = sortLessons(lessons)
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: 'unsectioned',
|
||||||
|
sectionId: null,
|
||||||
|
title: 'Core lessons',
|
||||||
|
description: 'Lessons shown before the course branches into sections.',
|
||||||
|
isVisible: true,
|
||||||
|
lessons: orderedLessons.filter((lesson) => lesson.section_id == null),
|
||||||
|
},
|
||||||
|
...orderedSections.map((section) => ({
|
||||||
|
key: laneKey(section.id),
|
||||||
|
sectionId: section.id,
|
||||||
|
title: section.title,
|
||||||
|
description: section.description || 'Section lessons appear together in this stage.',
|
||||||
|
isVisible: Boolean(section.is_visible),
|
||||||
|
lessons: orderedLessons.filter((lesson) => Number(lesson.section_id) === Number(section.id)),
|
||||||
|
})),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
function reindexLessonsFromLanes(sections = [], lessons = []) {
|
||||||
|
const lanes = buildLessonLanes(sections, lessons)
|
||||||
|
|
||||||
|
return lanes.flatMap((lane) => lane.lessons.map((lesson, index) => ({
|
||||||
|
...lesson,
|
||||||
|
section_id: lane.sectionId,
|
||||||
|
order_num: index,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveLessonToPosition(sections = [], lessons = [], lessonId, nextSectionId, targetIndex) {
|
||||||
|
const lanes = buildLessonLanes(sections, lessons).map((lane) => ({ ...lane, lessons: [...lane.lessons] }))
|
||||||
|
let draggedLesson = null
|
||||||
|
|
||||||
|
lanes.forEach((lane) => {
|
||||||
|
const lessonIndex = lane.lessons.findIndex((lesson) => Number(lesson.id) === Number(lessonId))
|
||||||
|
if (lessonIndex === -1) return
|
||||||
|
draggedLesson = { ...lane.lessons[lessonIndex], section_id: nextSectionId }
|
||||||
|
lane.lessons.splice(lessonIndex, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!draggedLesson) return lessons
|
||||||
|
|
||||||
|
const destinationLane = lanes.find((lane) => lane.sectionId === nextSectionId)
|
||||||
|
if (!destinationLane) return lessons
|
||||||
|
|
||||||
|
const nextIndex = Math.max(0, Math.min(Number(targetIndex), destinationLane.lessons.length))
|
||||||
|
destinationLane.lessons.splice(nextIndex, 0, draggedLesson)
|
||||||
|
|
||||||
|
return reindexLessonsFromLanes(sections, lanes.flatMap((lane) => lane.lessons))
|
||||||
|
}
|
||||||
|
|
||||||
|
function shiftLesson(sections = [], lessons = [], lessonId, direction) {
|
||||||
|
const lanes = buildLessonLanes(sections, lessons)
|
||||||
|
|
||||||
|
for (const lane of lanes) {
|
||||||
|
const lessonIndex = lane.lessons.findIndex((lesson) => Number(lesson.id) === Number(lessonId))
|
||||||
|
if (lessonIndex === -1) continue
|
||||||
|
|
||||||
|
const nextIndex = lessonIndex + direction
|
||||||
|
if (nextIndex < 0 || nextIndex >= lane.lessons.length) {
|
||||||
|
return lessons
|
||||||
|
}
|
||||||
|
|
||||||
|
return moveLessonToPosition(sections, lessons, lessonId, lane.sectionId, nextIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
return lessons
|
||||||
|
}
|
||||||
|
|
||||||
|
function placementSignature(lessons = []) {
|
||||||
|
return JSON.stringify(sortLessons(lessons).map((lesson) => ({
|
||||||
|
id: Number(lesson.id),
|
||||||
|
section_id: lesson.section_id == null ? null : Number(lesson.section_id),
|
||||||
|
order_num: Number(lesson.order_num || 0),
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatStepLabel(value) {
|
||||||
|
return `Step ${String(value).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveDraggedLessonId(event, fallbackLessonId = null) {
|
||||||
|
const nativeLessonId = event?.dataTransfer?.getData('text/plain') || ''
|
||||||
|
|
||||||
|
if (nativeLessonId !== '') {
|
||||||
|
return Number(nativeLessonId)
|
||||||
|
}
|
||||||
|
|
||||||
|
return fallbackLessonId == null ? null : Number(fallbackLessonId)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FormCard({ title, description, children }) {
|
||||||
|
return (
|
||||||
|
<section className="rounded-[30px] border border-white/[0.08] bg-white/[0.03] p-6">
|
||||||
|
<div className="mb-5">
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-500">Course builder</p>
|
||||||
|
<h2 className="mt-2 text-xl font-semibold tracking-[-0.04em] text-white">{title}</h2>
|
||||||
|
{description ? <p className="mt-2 text-sm leading-7 text-slate-400">{description}</p> : null}
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CheckboxCardField({ label, checked, onChange, description }) {
|
||||||
|
return (
|
||||||
|
<label className={`flex cursor-pointer items-start gap-4 rounded-[28px] border px-5 py-4 transition ${checked ? 'border-[#f39a24]/35 bg-[#f39a24]/10' : 'border-white/10 bg-black/20 hover:border-white/20 hover:bg-white/[0.04]'}`}>
|
||||||
|
<input type="checkbox" checked={Boolean(checked)} onChange={onChange} className="sr-only" />
|
||||||
|
<span className={`mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-xl border text-sm transition ${checked ? 'border-[#f39a24] bg-[#f39a24] text-white' : 'border-white/10 bg-[#151a29] text-transparent'}`}>
|
||||||
|
<i className="fa-solid fa-check" />
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<span className="block font-semibold text-white">{label}</span>
|
||||||
|
{description ? <span className="mt-1 block text-xs leading-5 text-slate-400">{description}</span> : null}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function EditableSectionCard({ section }) {
|
||||||
|
const form = useForm({
|
||||||
|
title: section.title || '',
|
||||||
|
slug: section.slug || '',
|
||||||
|
description: section.description || '',
|
||||||
|
order_num: section.order_num || 0,
|
||||||
|
is_visible: Boolean(section.is_visible),
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={(event) => { event.preventDefault(); form.patch(section.updateUrl) }} className="rounded-[24px] border border-white/10 bg-black/20 p-4">
|
||||||
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
|
<label className="grid gap-2 text-sm text-slate-200">
|
||||||
|
<span>Title</span>
|
||||||
|
<input value={form.data.title} onChange={(event) => form.setData('title', event.target.value)} className="rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" />
|
||||||
|
</label>
|
||||||
|
<label className="grid gap-2 text-sm text-slate-200">
|
||||||
|
<span>Slug</span>
|
||||||
|
<input value={form.data.slug} onChange={(event) => form.setData('slug', event.target.value)} className="rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" />
|
||||||
|
</label>
|
||||||
|
<label className="grid gap-2 text-sm text-slate-200 lg:col-span-2">
|
||||||
|
<span>Description</span>
|
||||||
|
<textarea value={form.data.description} onChange={(event) => form.setData('description', event.target.value)} rows={3} className="rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" />
|
||||||
|
</label>
|
||||||
|
<label className="grid gap-2 text-sm text-slate-200">
|
||||||
|
<span>Order</span>
|
||||||
|
<input type="number" value={form.data.order_num} onChange={(event) => form.setData('order_num', event.target.value)} className="rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" />
|
||||||
|
</label>
|
||||||
|
<CheckboxCardField
|
||||||
|
label="Visible section"
|
||||||
|
checked={Boolean(form.data.is_visible)}
|
||||||
|
onChange={(event) => form.setData('is_visible', event.target.checked)}
|
||||||
|
description="Hide the whole section from the public course outline without deleting its lessons or structure."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 flex flex-wrap gap-3">
|
||||||
|
<button type="submit" disabled={form.processing} className="rounded-full border border-sky-300/25 bg-sky-300/12 px-4 py-2 text-sm font-semibold text-sky-100">{form.processing ? 'Saving...' : 'Save section'}</button>
|
||||||
|
<button type="button" onClick={() => { if (!window.confirm('Delete this section?')) return; router.delete(section.destroyUrl) }} className="rounded-full border border-rose-300/20 bg-rose-300/10 px-4 py-2 text-sm font-semibold text-rose-100">Delete</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function EditableCourseLessonCard({ courseLesson, sectionOptions, stepLabel }) {
|
||||||
|
const form = useForm({
|
||||||
|
section_id: courseLesson.section_id || '',
|
||||||
|
order_num: courseLesson.order_num || 0,
|
||||||
|
is_required: Boolean(courseLesson.is_required),
|
||||||
|
access_override: courseLesson.access_override || '',
|
||||||
|
unlock_after_lesson_id: courseLesson.unlock_after_lesson_id || '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const accessOptions = [
|
||||||
|
{ value: '', label: 'Use lesson access' },
|
||||||
|
{ value: 'free', label: 'Free' },
|
||||||
|
{ value: 'creator', label: 'Creator' },
|
||||||
|
{ value: 'pro', label: 'Pro' },
|
||||||
|
{ value: 'premium', label: 'Premium' },
|
||||||
|
]
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
form.setData('section_id', courseLesson.section_id || '')
|
||||||
|
form.setData('order_num', courseLesson.order_num || 0)
|
||||||
|
}, [courseLesson.order_num, courseLesson.section_id])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={(event) => { event.preventDefault(); form.patch(courseLesson.updateUrl) }} className="rounded-[24px] border border-white/10 bg-black/20 p-4">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{stepLabel ? <span className="rounded-full border border-sky-300/20 bg-sky-300/10 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-sky-100">{stepLabel}</span> : null}
|
||||||
|
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">{courseLesson.category || 'Academy'} · {courseLesson.difficulty || 'lesson'}</p>
|
||||||
|
</div>
|
||||||
|
<h3 className="mt-2 text-lg font-semibold text-white">{courseLesson.title}</h3>
|
||||||
|
<p className="mt-2 text-sm leading-7 text-slate-300">{courseLesson.excerpt || 'This lesson is attached to the course.'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 grid gap-4 lg:grid-cols-3">
|
||||||
|
<NovaSelect label="Section" value={form.data.section_id} onChange={(nextValue) => form.setData('section_id', nextValue || '')} options={sectionOptions} searchable={false} className="rounded-2xl bg-white/[0.04]" />
|
||||||
|
<label className="grid gap-2 text-sm text-slate-200">
|
||||||
|
<span>Order</span>
|
||||||
|
<input type="number" value={form.data.order_num} onChange={(event) => form.setData('order_num', event.target.value)} className="rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" />
|
||||||
|
</label>
|
||||||
|
<NovaSelect label="Access override" value={form.data.access_override} onChange={(nextValue) => form.setData('access_override', nextValue || '')} options={accessOptions} searchable={false} className="rounded-2xl bg-white/[0.04]" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4">
|
||||||
|
<CheckboxCardField
|
||||||
|
label="Required for course completion"
|
||||||
|
checked={Boolean(form.data.is_required)}
|
||||||
|
onChange={(event) => form.setData('is_required', event.target.checked)}
|
||||||
|
description="Only required lessons count toward the course completion percentage."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 flex flex-wrap gap-3">
|
||||||
|
<button type="submit" disabled={form.processing} className="rounded-full border border-sky-300/25 bg-sky-300/12 px-4 py-2 text-sm font-semibold text-sky-100">{form.processing ? 'Saving...' : 'Save lesson settings'}</button>
|
||||||
|
<button type="button" onClick={() => { if (!window.confirm('Remove this lesson from the course?')) return; router.delete(courseLesson.destroyUrl) }} className="rounded-full border border-rose-300/20 bg-rose-300/10 px-4 py-2 text-sm font-semibold text-rose-100">Detach</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReorderLessonCard({
|
||||||
|
lesson,
|
||||||
|
lane,
|
||||||
|
laneIndex,
|
||||||
|
laneCount,
|
||||||
|
globalStepNumber,
|
||||||
|
isDragging,
|
||||||
|
isDropTarget,
|
||||||
|
onDragStart,
|
||||||
|
onDragEnd,
|
||||||
|
onDragOver,
|
||||||
|
onDrop,
|
||||||
|
onMoveUp,
|
||||||
|
onMoveDown,
|
||||||
|
onMoveLeft,
|
||||||
|
onMoveRight,
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
draggable
|
||||||
|
onDragStart={(event) => {
|
||||||
|
event.dataTransfer.effectAllowed = 'move'
|
||||||
|
event.dataTransfer.setData('text/plain', String(lesson.id))
|
||||||
|
onDragStart(lesson.id)
|
||||||
|
}}
|
||||||
|
onDragEnd={onDragEnd}
|
||||||
|
onDragOver={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
event.dataTransfer.dropEffect = 'move'
|
||||||
|
onDragOver(lesson.id)
|
||||||
|
}}
|
||||||
|
onDrop={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
onDrop(event, lane.sectionId, lesson.id)
|
||||||
|
}}
|
||||||
|
className={[
|
||||||
|
'rounded-[24px] border bg-black/20 p-4 transition',
|
||||||
|
isDragging ? 'border-sky-300/40 opacity-60' : 'border-white/10 hover:border-white/20',
|
||||||
|
isDropTarget ? 'ring-2 ring-sky-300/35' : '',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="rounded-full border border-sky-300/20 bg-sky-300/10 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-sky-100">{formatStepLabel(globalStepNumber)}</span>
|
||||||
|
{lesson.is_required ? <span className="rounded-full border border-emerald-300/20 bg-emerald-300/10 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-emerald-100">Required</span> : <span className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-400">Optional</span>}
|
||||||
|
</div>
|
||||||
|
<h3 className="mt-3 text-base font-semibold tracking-[-0.03em] text-white">{lesson.title}</h3>
|
||||||
|
<p className="mt-1 text-[11px] uppercase tracking-[0.16em] text-slate-500">{lesson.category || 'Academy'} · {lesson.difficulty || 'lesson'} · order {Number(lesson.order_num) + 1}</p>
|
||||||
|
<p className="mt-3 text-sm leading-6 text-slate-300">{lesson.excerpt || 'Drag this lesson to reposition it inside the course flow.'}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<button type="button" onClick={onMoveUp} className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-2 text-xs font-semibold text-white disabled:opacity-40" disabled={lane.lessons[0]?.id === lesson.id}>
|
||||||
|
<i className="fa-solid fa-arrow-up" />
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={onMoveDown} className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-2 text-xs font-semibold text-white disabled:opacity-40" disabled={lane.lessons[lane.lessons.length - 1]?.id === lesson.id}>
|
||||||
|
<i className="fa-solid fa-arrow-down" />
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={onMoveLeft} className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-2 text-xs font-semibold text-white disabled:opacity-40" disabled={laneIndex <= 0}>
|
||||||
|
<i className="fa-solid fa-arrow-left" />
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={onMoveRight} className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-2 text-xs font-semibold text-white disabled:opacity-40" disabled={laneIndex >= laneCount - 1}>
|
||||||
|
<i className="fa-solid fa-arrow-right" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AcademyCourseBuilder({ course, sections = [], courseLessons = [], availableLessons = [], routes = {} }) {
|
||||||
|
const flash = usePage().props.flash || {}
|
||||||
|
const sectionForm = useForm({ title: '', slug: '', description: '', order_num: sections.length, is_visible: true })
|
||||||
|
const attachForm = useForm({ lesson_id: '', section_id: '', order_num: courseLessons.length, is_required: true, access_override: '', unlock_after_lesson_id: '' })
|
||||||
|
const [draftLessons, setDraftLessons] = useState(() => reindexLessonsFromLanes(sections, courseLessons))
|
||||||
|
const [draggedLessonId, setDraggedLessonId] = useState(null)
|
||||||
|
const [dropTargetLessonId, setDropTargetLessonId] = useState(null)
|
||||||
|
const [reorderProcessing, setReorderProcessing] = useState(false)
|
||||||
|
|
||||||
|
const sectionOptions = [{ value: '', label: 'Unsectioned' }, ...sections.map((section) => ({ value: section.id, label: section.title }))]
|
||||||
|
const lessonOptions = availableLessons.map((lesson) => ({ value: lesson.id, label: `${lesson.title}${lesson.attached ? ' · attached' : ''}` }))
|
||||||
|
const attachableLessonOptions = availableLessons.filter((lesson) => !lesson.attached).map((lesson) => ({ value: lesson.id, label: lesson.title }))
|
||||||
|
const accessOverrideOptions = [
|
||||||
|
{ value: '', label: 'Use lesson access' },
|
||||||
|
{ value: 'free', label: 'Free' },
|
||||||
|
{ value: 'creator', label: 'Creator' },
|
||||||
|
{ value: 'pro', label: 'Pro' },
|
||||||
|
{ value: 'premium', label: 'Premium' },
|
||||||
|
]
|
||||||
|
const lessonLanes = useMemo(() => buildLessonLanes(sections, draftLessons), [sections, draftLessons])
|
||||||
|
const reorderDirty = useMemo(() => placementSignature(draftLessons) !== placementSignature(reindexLessonsFromLanes(sections, courseLessons)), [courseLessons, draftLessons, sections])
|
||||||
|
const courseLessonMap = useMemo(() => new Map(courseLessons.map((courseLesson) => [Number(courseLesson.id), courseLesson])), [courseLessons])
|
||||||
|
const orderedCourseLessons = useMemo(() => reindexLessonsFromLanes(sections, courseLessons.map((courseLesson) => ({
|
||||||
|
...courseLesson,
|
||||||
|
...(draftLessons.find((draftLesson) => Number(draftLesson.id) === Number(courseLesson.id)) || {}),
|
||||||
|
}))), [courseLessons, draftLessons, sections])
|
||||||
|
const globalStepMap = useMemo(() => new Map(lessonLanes.flatMap((lane) => lane.lessons).map((lesson, index) => [Number(lesson.id), index + 1])), [lessonLanes])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setDraftLessons(reindexLessonsFromLanes(sections, courseLessons))
|
||||||
|
}, [courseLessons, sections])
|
||||||
|
|
||||||
|
const moveLessonAcrossLanes = (lessonId, laneIndexDelta) => {
|
||||||
|
const lanes = buildLessonLanes(sections, draftLessons)
|
||||||
|
const currentLaneIndex = lanes.findIndex((lane) => lane.lessons.some((lesson) => Number(lesson.id) === Number(lessonId)))
|
||||||
|
if (currentLaneIndex === -1) return
|
||||||
|
|
||||||
|
const nextLane = lanes[currentLaneIndex + laneIndexDelta]
|
||||||
|
if (!nextLane) return
|
||||||
|
|
||||||
|
setDraftLessons((current) => moveLessonToPosition(sections, current, lessonId, nextLane.sectionId, nextLane.lessons.length))
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveReorder = () => {
|
||||||
|
setReorderProcessing(true)
|
||||||
|
router.patch(routes.reorder, {
|
||||||
|
sections: sortSections(sections).map((section, index) => ({ id: section.id, order_num: index })),
|
||||||
|
lessons: reindexLessonsFromLanes(sections, draftLessons).map((lesson) => ({
|
||||||
|
id: lesson.id,
|
||||||
|
order_num: lesson.order_num,
|
||||||
|
section_id: lesson.section_id,
|
||||||
|
})),
|
||||||
|
}, {
|
||||||
|
preserveScroll: true,
|
||||||
|
onFinish: () => setReorderProcessing(false),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminLayout title={`${course.title} Builder`} subtitle="Arrange sections, attach lessons, and control course flow.">
|
||||||
|
<Head title={`Admin · ${course.title} Builder`} />
|
||||||
|
|
||||||
|
{flash.success ? <div className="mb-6 rounded-2xl border border-emerald-300/20 bg-emerald-300/10 px-4 py-3 text-sm text-emerald-100">{flash.success}</div> : null}
|
||||||
|
{flash.error ? <div className="mb-6 rounded-2xl border border-rose-300/20 bg-rose-300/10 px-4 py-3 text-sm text-rose-100">{flash.error}</div> : null}
|
||||||
|
|
||||||
|
<div className="mb-6 flex flex-wrap gap-3">
|
||||||
|
<Link href={routes.index} className="rounded-full border border-white/10 bg-white/[0.04] px-5 py-3 text-sm font-semibold text-white">Back to courses</Link>
|
||||||
|
<Link href={routes.edit} className="rounded-full border border-white/10 bg-white/[0.04] px-5 py-3 text-sm font-semibold text-white">Edit course</Link>
|
||||||
|
<Link href={routes.preview} className="rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100">Preview public page</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.2fr)_420px]">
|
||||||
|
<div className="space-y-6">
|
||||||
|
<FormCard title="Structure overview" description="The course builder keeps the lesson content reusable while controlling order, access, and required progress here.">
|
||||||
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
|
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-4"><p className="text-[10px] font-semibold uppercase tracking-[0.22em] text-slate-500">Sections</p><p className="mt-2 text-2xl font-semibold text-white">{sections.length}</p></div>
|
||||||
|
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-4"><p className="text-[10px] font-semibold uppercase tracking-[0.22em] text-slate-500">Attached lessons</p><p className="mt-2 text-2xl font-semibold text-white">{courseLessons.length}</p></div>
|
||||||
|
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-4"><p className="text-[10px] font-semibold uppercase tracking-[0.22em] text-slate-500">Published course lessons</p><p className="mt-2 text-2xl font-semibold text-white">{courseLessons.filter((lesson) => lesson.title).length}</p></div>
|
||||||
|
</div>
|
||||||
|
</FormCard>
|
||||||
|
|
||||||
|
<FormCard title="Sections" description="Chapters are optional, but they help group lessons into cleaner stages.">
|
||||||
|
<div className="space-y-4">
|
||||||
|
{sections.map((section) => (
|
||||||
|
<EditableSectionCard key={section.id} section={section} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</FormCard>
|
||||||
|
|
||||||
|
<FormCard title="Lesson flow" description="Manage the course order as one visual path. Drag lessons between lanes, use arrows for exact moves, then save the sequence in one action.">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3 rounded-[24px] border border-white/10 bg-black/20 px-4 py-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">Flow board</p>
|
||||||
|
<p className="mt-1 text-sm text-slate-300">Core lessons stay first, then each visible section keeps its own ordered lane.</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
{reorderDirty ? <span className="rounded-full border border-amber-300/25 bg-amber-300/10 px-3 py-2 text-xs font-semibold uppercase tracking-[0.16em] text-amber-100">Unsaved order changes</span> : <span className="rounded-full border border-emerald-300/20 bg-emerald-300/10 px-3 py-2 text-xs font-semibold uppercase tracking-[0.16em] text-emerald-100">Order is saved</span>}
|
||||||
|
<button type="button" onClick={() => setDraftLessons(reindexLessonsFromLanes(sections, courseLessons))} disabled={!reorderDirty || reorderProcessing} className="rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-white disabled:opacity-40">Reset</button>
|
||||||
|
<button type="button" onClick={saveReorder} disabled={!reorderDirty || reorderProcessing} className="rounded-full border border-sky-300/25 bg-sky-300/12 px-4 py-2 text-sm font-semibold text-sky-100 disabled:opacity-40">{reorderProcessing ? 'Saving order...' : 'Save order'}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-5 grid gap-4 xl:grid-cols-2">
|
||||||
|
{lessonLanes.map((lane, laneIndex) => (
|
||||||
|
<section
|
||||||
|
key={lane.key}
|
||||||
|
onDragOver={(event) => event.preventDefault()}
|
||||||
|
onDrop={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
const activeLessonId = resolveDraggedLessonId(event, draggedLessonId)
|
||||||
|
if (activeLessonId == null) return
|
||||||
|
setDraftLessons((current) => moveLessonToPosition(sections, current, activeLessonId, lane.sectionId, lane.lessons.length))
|
||||||
|
setDraggedLessonId(null)
|
||||||
|
setDropTargetLessonId(null)
|
||||||
|
}}
|
||||||
|
className={`rounded-[26px] border p-4 ${lane.isVisible ? 'border-white/10 bg-white/[0.03]' : 'border-amber-300/20 bg-amber-300/8'}`}
|
||||||
|
>
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">{lane.sectionId == null ? 'Core lane' : 'Section lane'}</p>
|
||||||
|
{!lane.isVisible ? <span className="rounded-full border border-amber-300/20 bg-amber-300/10 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-amber-100">Hidden on public page</span> : null}
|
||||||
|
</div>
|
||||||
|
<h3 className="mt-2 text-lg font-semibold tracking-[-0.03em] text-white">{lane.title}</h3>
|
||||||
|
<p className="mt-2 text-sm leading-6 text-slate-400">{lane.description}</p>
|
||||||
|
</div>
|
||||||
|
<span className="rounded-full border border-white/10 bg-black/20 px-3 py-1.5 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-300">{lane.lessons.length} lessons</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 space-y-3 min-h-20">
|
||||||
|
{lane.lessons.length ? lane.lessons.map((lesson) => (
|
||||||
|
<ReorderLessonCard
|
||||||
|
key={lesson.id}
|
||||||
|
lesson={lesson}
|
||||||
|
lane={lane}
|
||||||
|
laneIndex={laneIndex}
|
||||||
|
laneCount={lessonLanes.length}
|
||||||
|
globalStepNumber={globalStepMap.get(Number(lesson.id)) || 1}
|
||||||
|
isDragging={Number(draggedLessonId) === Number(lesson.id)}
|
||||||
|
isDropTarget={Number(dropTargetLessonId) === Number(lesson.id)}
|
||||||
|
onDragStart={setDraggedLessonId}
|
||||||
|
onDragEnd={() => {
|
||||||
|
setDraggedLessonId(null)
|
||||||
|
setDropTargetLessonId(null)
|
||||||
|
}}
|
||||||
|
onDragOver={setDropTargetLessonId}
|
||||||
|
onDrop={(event, targetSectionId, targetLessonId) => {
|
||||||
|
const activeLessonId = resolveDraggedLessonId(event, draggedLessonId)
|
||||||
|
if (activeLessonId == null) return
|
||||||
|
if (Number(activeLessonId) === Number(targetLessonId)) return
|
||||||
|
const targetLane = lessonLanes.find((entry) => entry.sectionId === targetSectionId)
|
||||||
|
const targetIndex = Math.max(0, (targetLane?.lessons || []).findIndex((entry) => Number(entry.id) === Number(targetLessonId)))
|
||||||
|
setDraftLessons((current) => moveLessonToPosition(sections, current, activeLessonId, targetSectionId, targetIndex))
|
||||||
|
setDraggedLessonId(null)
|
||||||
|
setDropTargetLessonId(null)
|
||||||
|
}}
|
||||||
|
onMoveUp={() => setDraftLessons((current) => shiftLesson(sections, current, lesson.id, -1))}
|
||||||
|
onMoveDown={() => setDraftLessons((current) => shiftLesson(sections, current, lesson.id, 1))}
|
||||||
|
onMoveLeft={() => moveLessonAcrossLanes(lesson.id, -1)}
|
||||||
|
onMoveRight={() => moveLessonAcrossLanes(lesson.id, 1)}
|
||||||
|
/>
|
||||||
|
)) : <div className="rounded-[22px] border border-dashed border-white/10 bg-black/20 px-4 py-6 text-sm text-slate-500">Drop lessons here to move them into this lane.</div>}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</FormCard>
|
||||||
|
|
||||||
|
<FormCard title="Attached lessons" description="Each lesson stays reusable across courses. Adjust order, requirement status, and access overrides here.">
|
||||||
|
<div className="space-y-4">
|
||||||
|
{orderedCourseLessons.map((courseLesson, index) => (
|
||||||
|
<EditableCourseLessonCard key={courseLesson.id} courseLesson={{ ...courseLessonMap.get(Number(courseLesson.id)), ...courseLesson }} sectionOptions={sectionOptions} stepLabel={formatStepLabel(index + 1)} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</FormCard>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-6 xl:sticky xl:top-6 xl:self-start">
|
||||||
|
<FormCard title="Create section" description="Create a chapter before attaching lessons into it.">
|
||||||
|
<form onSubmit={(event) => { event.preventDefault(); sectionForm.post(routes.sectionStore) }} className="space-y-4">
|
||||||
|
<label className="grid gap-2 text-sm text-slate-200"><span>Title</span><input value={sectionForm.data.title} onChange={(event) => sectionForm.setData('title', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white outline-none" /></label>
|
||||||
|
<label className="grid gap-2 text-sm text-slate-200"><span>Slug</span><input value={sectionForm.data.slug} onChange={(event) => sectionForm.setData('slug', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white outline-none" placeholder="auto-generated if blank" /></label>
|
||||||
|
<label className="grid gap-2 text-sm text-slate-200"><span>Description</span><textarea value={sectionForm.data.description} onChange={(event) => sectionForm.setData('description', event.target.value)} rows={4} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm leading-7 text-white outline-none" /></label>
|
||||||
|
<button type="submit" disabled={sectionForm.processing} className="w-full rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100">{sectionForm.processing ? 'Creating...' : 'Create section'}</button>
|
||||||
|
</form>
|
||||||
|
</FormCard>
|
||||||
|
|
||||||
|
<FormCard title="Attach lesson" description="Attach an existing Academy lesson to this course without duplicating content.">
|
||||||
|
<form onSubmit={(event) => { event.preventDefault(); attachForm.post(routes.attachLesson) }} className="space-y-4">
|
||||||
|
<NovaSelect label="Lesson" value={attachForm.data.lesson_id} onChange={(nextValue) => attachForm.setData('lesson_id', nextValue || '')} options={attachableLessonOptions.length ? attachableLessonOptions : lessonOptions} className="rounded-2xl bg-black/20" />
|
||||||
|
<NovaSelect label="Section" value={attachForm.data.section_id} onChange={(nextValue) => attachForm.setData('section_id', nextValue || '')} options={sectionOptions} searchable={false} className="rounded-2xl bg-black/20" />
|
||||||
|
<label className="grid gap-2 text-sm text-slate-200"><span>Order</span><input type="number" value={attachForm.data.order_num} onChange={(event) => attachForm.setData('order_num', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white outline-none" /></label>
|
||||||
|
<NovaSelect label="Access override" value={attachForm.data.access_override} onChange={(nextValue) => attachForm.setData('access_override', nextValue || '')} options={accessOverrideOptions} searchable={false} className="rounded-2xl bg-black/20" />
|
||||||
|
<CheckboxCardField
|
||||||
|
label="Required for completion"
|
||||||
|
checked={Boolean(attachForm.data.is_required)}
|
||||||
|
onChange={(event) => attachForm.setData('is_required', event.target.checked)}
|
||||||
|
description="Only required lessons count toward course completion."
|
||||||
|
/>
|
||||||
|
<button type="submit" disabled={attachForm.processing || !attachForm.data.lesson_id} className="w-full rounded-full border border-amber-300/25 bg-amber-300/12 px-5 py-3 text-sm font-semibold text-amber-100">{attachForm.processing ? 'Attaching...' : 'Attach lesson'}</button>
|
||||||
|
</form>
|
||||||
|
</FormCard>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AdminLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
960
resources/js/Pages/Admin/Academy/CourseEditor.jsx
Normal file
960
resources/js/Pages/Admin/Academy/CourseEditor.jsx
Normal file
@@ -0,0 +1,960 @@
|
|||||||
|
import React, { useDeferredValue, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import { Head, Link, router, useForm } from '@inertiajs/react'
|
||||||
|
import AdminLayout from '../../../Layouts/AdminLayout'
|
||||||
|
import RichTextEditor from '../../../components/forum/RichTextEditor'
|
||||||
|
import WorldMediaUploadField from '../../../components/worlds/editor/WorldMediaUploadField'
|
||||||
|
import DateTimePicker from '../../../components/ui/DateTimePicker'
|
||||||
|
import NovaSelect from '../../../components/ui/NovaSelect'
|
||||||
|
|
||||||
|
const COURSE_EDITOR_TABS = [
|
||||||
|
{
|
||||||
|
id: 'overview',
|
||||||
|
label: 'Overview',
|
||||||
|
description: 'Title, slug, positioning, and the short summary shown on course cards.',
|
||||||
|
icon: 'fa-compass-drafting',
|
||||||
|
sections: ['course-identity'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'content',
|
||||||
|
label: 'Content',
|
||||||
|
description: 'Use the richer WYSIWYG surface for the main course description and learning pitch.',
|
||||||
|
icon: 'fa-pen-nib',
|
||||||
|
sections: ['course-description'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'media',
|
||||||
|
label: 'Media',
|
||||||
|
description: 'Upload and tune the cover and teaser visuals used across the public course surfaces.',
|
||||||
|
icon: 'fa-images',
|
||||||
|
sections: ['course-media'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'lessons',
|
||||||
|
label: 'Lessons',
|
||||||
|
description: 'Build the lesson sequence, drag to reorder, and add or remove lessons without opening the full builder.',
|
||||||
|
icon: 'fa-list-ol',
|
||||||
|
sections: ['course-lessons-manager'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'publish',
|
||||||
|
label: 'Publish',
|
||||||
|
description: 'Control access, status, ordering, scheduling, and featured placement.',
|
||||||
|
icon: 'fa-rocket-launch',
|
||||||
|
sections: ['course-publishing', 'course-seo'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'preview',
|
||||||
|
label: 'Preview',
|
||||||
|
description: 'Scan the public-facing course card, media, and rendered long description before publishing.',
|
||||||
|
icon: 'fa-eye',
|
||||||
|
sections: ['course-preview'],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const COURSE_FIELD_TAB_MAP = {
|
||||||
|
title: 'overview',
|
||||||
|
slug: 'overview',
|
||||||
|
subtitle: 'overview',
|
||||||
|
excerpt: 'overview',
|
||||||
|
description: 'content',
|
||||||
|
cover_image: 'media',
|
||||||
|
teaser_image: 'media',
|
||||||
|
access_level: 'publish',
|
||||||
|
difficulty: 'publish',
|
||||||
|
status: 'publish',
|
||||||
|
order_num: 'publish',
|
||||||
|
estimated_minutes: 'publish',
|
||||||
|
published_at: 'publish',
|
||||||
|
is_featured: 'publish',
|
||||||
|
seo_title: 'publish',
|
||||||
|
seo_description: 'publish',
|
||||||
|
meta_keywords: 'publish',
|
||||||
|
og_title: 'publish',
|
||||||
|
og_description: 'publish',
|
||||||
|
og_image: 'publish',
|
||||||
|
}
|
||||||
|
|
||||||
|
function getField(fields, name) {
|
||||||
|
return fields.find((field) => field.name === name) || null
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldError({ message }) {
|
||||||
|
if (!message) return null
|
||||||
|
return <p className="text-xs text-rose-300">{message}</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
function SectionCard({ id, eyebrow, title, description, actions, children, tone = 'default', className = '', contentClassName = '' }) {
|
||||||
|
const toneClass = tone === 'feature'
|
||||||
|
? 'bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.16),transparent_38%),linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,0.92))] shadow-[0_24px_70px_rgba(2,6,23,0.28)]'
|
||||||
|
: 'bg-white/[0.03]'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section id={id} className={`min-w-0 scroll-mt-24 rounded-[28px] border border-white/10 p-5 ${toneClass} ${className}`.trim()}>
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||||
|
<div className="max-w-3xl">
|
||||||
|
{eyebrow ? <p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-sky-200/75">{eyebrow}</p> : null}
|
||||||
|
<h2 className="mt-2 text-xl font-semibold tracking-[-0.03em] text-white">{title}</h2>
|
||||||
|
{description ? <p className="mt-2 text-sm leading-6 text-slate-400">{description}</p> : null}
|
||||||
|
</div>
|
||||||
|
{actions ? <div className="flex flex-wrap gap-2">{actions}</div> : null}
|
||||||
|
</div>
|
||||||
|
<div className={`mt-5 ${contentClassName}`.trim()}>{children}</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function EditorWorkspaceTabs({ tabs, activeTab, onChange, errorCounts }) {
|
||||||
|
const activeMeta = tabs.find((tab) => tab.id === activeTab) || tabs[0]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="sticky top-4 z-20 rounded-[24px] border border-white/10 bg-[linear-gradient(180deg,rgba(7,11,18,0.92),rgba(5,8,14,0.88))] px-3 py-3 shadow-[0_18px_50px_rgba(2,6,23,0.18)] backdrop-blur">
|
||||||
|
<div className="flex flex-wrap items-center gap-2" role="tablist" aria-label="Course editor sections">
|
||||||
|
{tabs.map((tab) => {
|
||||||
|
const isActive = tab.id === activeTab
|
||||||
|
const errorCount = Number(errorCounts?.[tab.id] || 0)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={isActive}
|
||||||
|
aria-controls={`course-editor-panel-${tab.id}`}
|
||||||
|
id={`course-editor-tab-${tab.id}`}
|
||||||
|
onClick={() => onChange(tab.id)}
|
||||||
|
className={[
|
||||||
|
'inline-flex items-center gap-2 rounded-2xl border px-4 py-2.5 text-sm font-semibold transition',
|
||||||
|
isActive
|
||||||
|
? 'border-sky-300/25 bg-sky-300/12 text-sky-100 ring-1 ring-sky-300/20'
|
||||||
|
: 'border-white/10 bg-white/[0.03] text-white/80 hover:border-sky-300/30 hover:bg-sky-300/10 hover:text-white',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
<i className={`fa-solid ${tab.icon} text-xs`} />
|
||||||
|
<span>{tab.label}</span>
|
||||||
|
{errorCount > 0 ? <span className="rounded-full border border-rose-300/20 bg-rose-300/10 px-2 py-0.5 text-[10px] font-bold uppercase tracking-[0.14em] text-rose-100">{errorCount}</span> : null}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 flex flex-wrap items-center justify-between gap-3 px-1">
|
||||||
|
<p className="text-sm leading-6 text-slate-400">{activeMeta.description}</p>
|
||||||
|
<div className="flex flex-wrap gap-2 text-[11px] uppercase tracking-[0.16em] text-slate-500">
|
||||||
|
{activeMeta.sections.map((section) => (
|
||||||
|
<span key={section} className="rounded-full border border-white/10 bg-white/[0.03] px-3 py-1.5">{section.replace('course-', '').replace(/-/g, ' ')}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TextField({ label, value, onChange, error, hint, ...rest }) {
|
||||||
|
return (
|
||||||
|
<label className="grid gap-2 text-sm text-slate-300">
|
||||||
|
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">{label}</span>
|
||||||
|
<input value={value ?? ''} onChange={onChange} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" {...rest} />
|
||||||
|
{hint ? <span className="text-xs leading-5 text-slate-500">{hint}</span> : null}
|
||||||
|
<FieldError message={error} />
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TextAreaField({ label, value, onChange, error, rows = 4, hint }) {
|
||||||
|
return (
|
||||||
|
<label className="grid gap-2 text-sm text-slate-300">
|
||||||
|
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">{label}</span>
|
||||||
|
<textarea value={value ?? ''} onChange={onChange} rows={rows} className="rounded-[24px] border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||||
|
{hint ? <span className="text-xs leading-5 text-slate-500">{hint}</span> : null}
|
||||||
|
<FieldError message={error} />
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CheckboxCardField({ label, checked, onChange, description, error }) {
|
||||||
|
return (
|
||||||
|
<label className={`flex cursor-pointer items-start gap-4 rounded-[28px] border px-5 py-4 transition ${checked ? 'border-[#f39a24]/35 bg-[#f39a24]/10' : 'border-white/10 bg-black/20 hover:border-white/20 hover:bg-white/[0.04]'}`}>
|
||||||
|
<input type="checkbox" checked={Boolean(checked)} onChange={onChange} className="sr-only" />
|
||||||
|
<span className={`mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-xl border text-sm transition ${checked ? 'border-[#f39a24] bg-[#f39a24] text-white' : 'border-white/10 bg-[#151a29] text-transparent'}`}>
|
||||||
|
<i className="fa-solid fa-check" />
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="block text-lg font-semibold tracking-[-0.02em] text-white">{label}</span>
|
||||||
|
{description ? <span className="mt-1 block text-sm leading-6 text-slate-300">{description}</span> : null}
|
||||||
|
<FieldError message={error} />
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function OutlineSectionPill({ section }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-[20px] border border-white/10 bg-black/20 px-4 py-3">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate text-sm font-semibold text-white">{section.title}</p>
|
||||||
|
<p className="mt-1 text-[11px] uppercase tracking-[0.16em] text-slate-500">{section.is_visible ? 'Visible section' : 'Hidden section'}</p>
|
||||||
|
</div>
|
||||||
|
<span className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1 text-[11px] font-semibold text-slate-200">{section.lesson_count}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function slugifyCourseTitle(value) {
|
||||||
|
return String(value || '')
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '')
|
||||||
|
.slice(0, 180)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatLessonStep(orderNum) {
|
||||||
|
const numeric = Number(orderNum)
|
||||||
|
if (!Number.isFinite(numeric) || numeric < 0) return null
|
||||||
|
return `Step ${String(numeric + 1).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeLessonManagerLessons(lessons) {
|
||||||
|
return (Array.isArray(lessons) ? [...lessons] : [])
|
||||||
|
.sort((a, b) => {
|
||||||
|
const diff = Number(a?.order_num || 0) - Number(b?.order_num || 0)
|
||||||
|
return diff !== 0 ? diff : Number(a?.id || 0) - Number(b?.id || 0)
|
||||||
|
})
|
||||||
|
.map((lesson, index) => ({ ...lesson, order_num: index, display_order: index + 1 }))
|
||||||
|
}
|
||||||
|
|
||||||
|
function reorderLessonManagerLessons(lessons, draggedId, targetId) {
|
||||||
|
const current = normalizeLessonManagerLessons(lessons)
|
||||||
|
const di = current.findIndex((l) => Number(l.id) === Number(draggedId))
|
||||||
|
const ti = current.findIndex((l) => Number(l.id) === Number(targetId))
|
||||||
|
if (di === -1 || ti === -1 || di === ti) return current
|
||||||
|
const next = [...current]
|
||||||
|
const [moved] = next.splice(di, 1)
|
||||||
|
next.splice(ti, 0, moved)
|
||||||
|
return normalizeLessonManagerLessons(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveLessonManagerLesson(lessons, lessonId, direction) {
|
||||||
|
const current = normalizeLessonManagerLessons(lessons)
|
||||||
|
const idx = current.findIndex((l) => Number(l.id) === Number(lessonId))
|
||||||
|
const nextIdx = idx + direction
|
||||||
|
if (idx === -1 || nextIdx < 0 || nextIdx >= current.length) return current
|
||||||
|
const next = [...current]
|
||||||
|
const [moved] = next.splice(idx, 1)
|
||||||
|
next.splice(nextIdx, 0, moved)
|
||||||
|
return normalizeLessonManagerLessons(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
function lessonManagerSignature(lessons) {
|
||||||
|
return JSON.stringify(normalizeLessonManagerLessons(lessons).map((l) => ({
|
||||||
|
id: Number(l.id),
|
||||||
|
order_num: Number(l.order_num || 0),
|
||||||
|
section_id: l.section_id == null ? null : Number(l.section_id),
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripHtml(value) {
|
||||||
|
return String(value || '')
|
||||||
|
.replace(/<style[\s\S]*?<\/style>/gi, ' ')
|
||||||
|
.replace(/<script[\s\S]*?<\/script>/gi, ' ')
|
||||||
|
.replace(/<[^>]+>/g, ' ')
|
||||||
|
.replace(/ /g, ' ')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function countWords(value) {
|
||||||
|
const text = stripHtml(value)
|
||||||
|
return text ? text.split(/\s+/).length : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAssetPreview(value, cdnBaseUrl) {
|
||||||
|
const trimmed = String(value || '').trim()
|
||||||
|
if (!trimmed) return ''
|
||||||
|
if (trimmed.startsWith('http://') || trimmed.startsWith('https://') || trimmed.startsWith('/')) return trimmed
|
||||||
|
return `${String(cdnBaseUrl || '').replace(/\/$/, '')}/${trimmed.replace(/^\//, '')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstCourseErrorTab(errors) {
|
||||||
|
const firstKey = Object.keys(errors || {})[0]
|
||||||
|
if (!firstKey) return null
|
||||||
|
return COURSE_FIELD_TAB_MAP[firstKey] || null
|
||||||
|
}
|
||||||
|
|
||||||
|
function courseTabErrorCounts(errors) {
|
||||||
|
const counts = {}
|
||||||
|
|
||||||
|
Object.keys(errors || {}).forEach((key) => {
|
||||||
|
const tabId = COURSE_FIELD_TAB_MAP[key]
|
||||||
|
if (!tabId) return
|
||||||
|
counts[tabId] = Number(counts[tabId] || 0) + 1
|
||||||
|
})
|
||||||
|
|
||||||
|
return counts
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMetaKeywords(value) {
|
||||||
|
return String(value || '')
|
||||||
|
.split(/[\n,]/)
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, 6)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CourseEditor({ title, subtitle, fields, record, submitUrl, indexUrl, destroyUrl, method, editorContext = {} }) {
|
||||||
|
const form = useForm({
|
||||||
|
...record,
|
||||||
|
description: String(record.description || ''),
|
||||||
|
cover_image: String(record.cover_image || ''),
|
||||||
|
teaser_image: String(record.teaser_image || ''),
|
||||||
|
})
|
||||||
|
const slugTouchedRef = useRef(Boolean(String(record.slug || '').trim()))
|
||||||
|
const [activeTab, setActiveTab] = useState('overview')
|
||||||
|
const [coverPreviewUrl, setCoverPreviewUrl] = useState(record.cover_image_url || normalizeAssetPreview(record.cover_image, editorContext.coverCdnBaseUrl))
|
||||||
|
const [teaserPreviewUrl, setTeaserPreviewUrl] = useState(record.teaser_image_url || normalizeAssetPreview(record.teaser_image, editorContext.coverCdnBaseUrl))
|
||||||
|
const [stagedCoverPath, setStagedCoverPath] = useState('')
|
||||||
|
const [stagedTeaserPath, setStagedTeaserPath] = useState('')
|
||||||
|
const accessField = useMemo(() => getField(fields, 'access_level'), [fields])
|
||||||
|
const difficultyField = useMemo(() => getField(fields, 'difficulty'), [fields])
|
||||||
|
const statusField = useMemo(() => getField(fields, 'status'), [fields])
|
||||||
|
const wordCount = useMemo(() => countWords(form.data.description), [form.data.description])
|
||||||
|
const excerptLength = String(form.data.excerpt || '').length
|
||||||
|
const tabErrorCounts = useMemo(() => courseTabErrorCounts(form.errors), [form.errors])
|
||||||
|
const deferredDescription = useDeferredValue(form.data.description || '')
|
||||||
|
const visibleSections = useMemo(() => new Set((COURSE_EDITOR_TABS.find((tab) => tab.id === activeTab)?.sections) || []), [activeTab])
|
||||||
|
const activeTabMeta = useMemo(() => COURSE_EDITOR_TABS.find((tab) => tab.id === activeTab) || COURSE_EDITOR_TABS[0], [activeTab])
|
||||||
|
const sectionClassName = (sectionId, className = '') => `${visibleSections.has(sectionId) ? '' : 'hidden'} ${className}`.trim()
|
||||||
|
const editorLinks = editorContext?.links || {}
|
||||||
|
const outlineSummary = editorContext?.outlineSummary || null
|
||||||
|
const coursePathPreview = form.data.slug ? `/academy/courses/${form.data.slug}` : '/academy/courses/course-slug'
|
||||||
|
const metaKeywordItems = renderMetaKeywords(form.data.meta_keywords)
|
||||||
|
const attachLessonUrl = editorContext?.attachLessonUrl || null
|
||||||
|
const reorderUrl = editorContext?.reorderUrl || null
|
||||||
|
const courseLessonsSource = useMemo(() => Array.isArray(editorContext?.courseLessons) ? editorContext.courseLessons : [], [editorContext])
|
||||||
|
const availableLessons = useMemo(() => Array.isArray(editorContext?.availableLessons) ? editorContext.availableLessons : [], [editorContext])
|
||||||
|
const [lessonManagerDraft, setLessonManagerDraft] = useState(() => normalizeLessonManagerLessons(Array.isArray(editorContext?.courseLessons) ? editorContext.courseLessons : []))
|
||||||
|
const [lessonDragActive, setLessonDragActive] = useState(null)
|
||||||
|
const [lessonSaveProcessing, setLessonSaveProcessing] = useState(false)
|
||||||
|
const [lessonSearch, setLessonSearch] = useState('')
|
||||||
|
const lessonManagerIsDirty = useMemo(() => lessonManagerSignature(lessonManagerDraft) !== lessonManagerSignature(courseLessonsSource), [lessonManagerDraft, courseLessonsSource])
|
||||||
|
const filteredAvailableLessons = useMemo(() => {
|
||||||
|
const q = lessonSearch.trim().toLowerCase()
|
||||||
|
const unattached = availableLessons.filter((l) => !l.attached)
|
||||||
|
if (!q) return unattached
|
||||||
|
return unattached.filter((l) => l.title.toLowerCase().includes(q) || l.category.toLowerCase().includes(q))
|
||||||
|
}, [availableLessons, lessonSearch])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLessonManagerDraft(normalizeLessonManagerLessons(courseLessonsSource))
|
||||||
|
}, [courseLessonsSource])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (slugTouchedRef.current) return
|
||||||
|
form.setData('slug', slugifyCourseTitle(form.data.title))
|
||||||
|
}, [form, form.data.title])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const nextTab = firstCourseErrorTab(form.errors)
|
||||||
|
if (!nextTab) return
|
||||||
|
setActiveTab(nextTab)
|
||||||
|
}, [form.errors])
|
||||||
|
|
||||||
|
const handleManualCoverChange = (nextValue) => {
|
||||||
|
setStagedCoverPath('')
|
||||||
|
form.setData('cover_image', nextValue)
|
||||||
|
setCoverPreviewUrl(normalizeAssetPreview(nextValue, editorContext.coverCdnBaseUrl))
|
||||||
|
}
|
||||||
|
|
||||||
|
const attachLesson = (lesson) => {
|
||||||
|
if (!attachLessonUrl) return
|
||||||
|
router.post(attachLessonUrl, {
|
||||||
|
lesson_id: lesson.id,
|
||||||
|
order_num: courseLessonsSource.length,
|
||||||
|
is_required: true,
|
||||||
|
}, { preserveScroll: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
const detachLesson = (courseLesson) => {
|
||||||
|
if (!courseLesson.destroy_url) return
|
||||||
|
if (!window.confirm(`Remove "${courseLesson.title}" from this course?`)) return
|
||||||
|
router.delete(courseLesson.destroy_url, { preserveScroll: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveLessonOrder = () => {
|
||||||
|
if (!reorderUrl) return
|
||||||
|
setLessonSaveProcessing(true)
|
||||||
|
router.patch(reorderUrl, {
|
||||||
|
sections: [],
|
||||||
|
lessons: lessonManagerDraft.map((l) => ({
|
||||||
|
id: l.id,
|
||||||
|
order_num: l.order_num,
|
||||||
|
section_id: l.section_id ?? null,
|
||||||
|
})),
|
||||||
|
}, {
|
||||||
|
preserveScroll: true,
|
||||||
|
onFinish: () => setLessonSaveProcessing(false),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleManualTeaserChange = (nextValue) => {
|
||||||
|
setStagedTeaserPath('')
|
||||||
|
form.setData('teaser_image', nextValue)
|
||||||
|
setTeaserPreviewUrl(normalizeAssetPreview(nextValue, editorContext.coverCdnBaseUrl))
|
||||||
|
}
|
||||||
|
|
||||||
|
const submit = (event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
|
||||||
|
if (method === 'patch') {
|
||||||
|
form.patch(submitUrl)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
form.post(submitUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteCourse = () => {
|
||||||
|
if (!destroyUrl) return
|
||||||
|
if (!window.confirm('Delete this course?')) return
|
||||||
|
router.delete(destroyUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminLayout title={title} subtitle={subtitle}>
|
||||||
|
<Head title={`Admin · ${title}`} />
|
||||||
|
|
||||||
|
<form onSubmit={submit} className="space-y-6 pb-16">
|
||||||
|
<section className="overflow-hidden rounded-[28px] border border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.14),transparent_34%),linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,0.94))] shadow-[0_24px_70px_rgba(2,6,23,0.34)] backdrop-blur">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-4 border-b border-white/10 px-5 py-4">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex flex-wrap items-center gap-2 text-xs font-semibold uppercase tracking-[0.18em] text-slate-400">
|
||||||
|
<Link href={indexUrl} className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 text-white transition hover:bg-white/[0.08]">Back to courses</Link>
|
||||||
|
<span>{destroyUrl ? 'Edit course' : 'New course'}</span>
|
||||||
|
</div>
|
||||||
|
<h1 className="mt-3 text-3xl font-semibold tracking-[-0.05em] text-white">{form.data.title || 'Untitled academy course'}</h1>
|
||||||
|
<p className="mt-2 max-w-3xl text-sm leading-7 text-slate-300">Design the course like a polished editorial landing page: keep the structure clear, use the rich description editor, and upload visuals that look intentional on the public cards and hero.</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
{editorLinks.builder ? <Link href={editorLinks.builder} className="rounded-2xl border border-amber-300/20 bg-amber-300/10 px-4 py-2.5 text-sm font-semibold text-amber-100 transition hover:brightness-110">Open builder</Link> : null}
|
||||||
|
{editorLinks.preview ? <Link href={editorLinks.preview} className="rounded-2xl border border-white/10 bg-white/[0.05] px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-white/[0.08]">Preview public page</Link> : null}
|
||||||
|
<button type="submit" disabled={form.processing} className="rounded-2xl border border-sky-300/25 bg-sky-300/12 px-4 py-2.5 text-sm font-semibold text-sky-100">{form.processing ? 'Saving...' : 'Save course'}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<EditorWorkspaceTabs tabs={COURSE_EDITOR_TABS} activeTab={activeTab} onChange={setActiveTab} errorCounts={tabErrorCounts} />
|
||||||
|
|
||||||
|
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5 shadow-[0_18px_50px_rgba(2,6,23,0.14)]">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Current workspace</p>
|
||||||
|
<h2 className="mt-2 text-2xl font-semibold tracking-[-0.04em] text-white">{activeTabMeta.label}</h2>
|
||||||
|
<p className="mt-2 max-w-2xl text-sm leading-6 text-slate-400">{activeTabMeta.description}</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-3">
|
||||||
|
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3">
|
||||||
|
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Words</div>
|
||||||
|
<div className="mt-1 text-lg font-semibold text-white">{wordCount.toLocaleString()}</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3">
|
||||||
|
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Excerpt</div>
|
||||||
|
<div className="mt-1 text-lg font-semibold text-white">{excerptLength}/800</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3">
|
||||||
|
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Errors</div>
|
||||||
|
<div className="mt-1 text-lg font-semibold text-white">{Object.keys(form.errors || {}).length}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_360px] xl:items-start">
|
||||||
|
<div className="min-w-0 space-y-6" role="tabpanel" id={`course-editor-panel-${activeTab}`} aria-labelledby={`course-editor-tab-${activeTab}`}>
|
||||||
|
<SectionCard id="course-identity" eyebrow="Positioning" title="Identity and summary" description="Start with the public-facing identity shown on the course index, hero, and internal Academy modules." tone="feature" className={sectionClassName('course-identity')}>
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<TextField
|
||||||
|
label="Title"
|
||||||
|
value={form.data.title}
|
||||||
|
onChange={(event) => form.setData('title', event.target.value)}
|
||||||
|
error={form.errors.title}
|
||||||
|
maxLength={180}
|
||||||
|
placeholder="AI-Assisted Digital Art Foundations"
|
||||||
|
/>
|
||||||
|
<label className="grid gap-2 text-sm text-slate-300">
|
||||||
|
<span className="flex items-center justify-between gap-3 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">
|
||||||
|
<span>Slug</span>
|
||||||
|
<button type="button" onClick={() => {
|
||||||
|
slugTouchedRef.current = false
|
||||||
|
form.setData('slug', slugifyCourseTitle(form.data.title))
|
||||||
|
}} className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1 text-[10px] font-semibold text-white">Sync</button>
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
value={form.data.slug}
|
||||||
|
onChange={(event) => {
|
||||||
|
slugTouchedRef.current = String(event.target.value).trim() !== ''
|
||||||
|
form.setData('slug', event.target.value)
|
||||||
|
}}
|
||||||
|
className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none"
|
||||||
|
placeholder="ai-assisted-digital-art-foundations"
|
||||||
|
maxLength={180}
|
||||||
|
/>
|
||||||
|
<span className="text-xs leading-5 text-slate-500">The public course URL updates from the title until you override it.</span>
|
||||||
|
<FieldError message={form.errors.slug} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<TextField
|
||||||
|
label="Subtitle"
|
||||||
|
value={form.data.subtitle}
|
||||||
|
onChange={(event) => form.setData('subtitle', event.target.value)}
|
||||||
|
error={form.errors.subtitle}
|
||||||
|
maxLength={255}
|
||||||
|
placeholder="A guided path for Skinbase creators"
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Estimated minutes"
|
||||||
|
value={form.data.estimated_minutes ?? ''}
|
||||||
|
onChange={(event) => form.setData('estimated_minutes', event.target.value)}
|
||||||
|
error={form.errors.estimated_minutes}
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
placeholder="90"
|
||||||
|
hint="Shown on public course cards and the course hero."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TextAreaField
|
||||||
|
label="Excerpt"
|
||||||
|
value={form.data.excerpt}
|
||||||
|
onChange={(event) => form.setData('excerpt', event.target.value)}
|
||||||
|
error={form.errors.excerpt}
|
||||||
|
rows={5}
|
||||||
|
hint="Keep this tight and outcome-focused. This summary is reused on cards, related modules, and SEO helpers."
|
||||||
|
/>
|
||||||
|
</SectionCard>
|
||||||
|
|
||||||
|
<SectionCard id="course-description" eyebrow="Long-form content" title="Course description" description="Use the same richer WYSIWYG surface as lessons so the course page can carry structured copy, lists, and supporting media." tone="feature" className={sectionClassName('course-description')}>
|
||||||
|
<RichTextEditor
|
||||||
|
content={form.data.description}
|
||||||
|
onChange={(nextHtml) => form.setData('description', nextHtml)}
|
||||||
|
placeholder="Explain what the course covers, who it is for, what workflows it teaches, and why a Skinbase creator should follow this path from start to finish."
|
||||||
|
error={form.errors.description}
|
||||||
|
minHeight={24}
|
||||||
|
maxHeightRem={42}
|
||||||
|
autofocus={false}
|
||||||
|
advancedNews
|
||||||
|
mediaSupport={{
|
||||||
|
uploadUrl: editorContext.bodyMediaUploadUrl,
|
||||||
|
deleteUrl: editorContext.bodyMediaDeleteUrl,
|
||||||
|
assetsUrl: editorContext.bodyMediaAssetsUrl,
|
||||||
|
slot: 'body',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</SectionCard>
|
||||||
|
|
||||||
|
<SectionCard id="course-media" eyebrow="Visual system" title="Cover and teaser media" description="Upload clean landscape images that work across the featured course rail, the course index cards, and the public course hero." className={sectionClassName('course-media')}>
|
||||||
|
<div className="grid gap-6 xl:grid-cols-2">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<WorldMediaUploadField
|
||||||
|
label="Cover image"
|
||||||
|
slot="cover"
|
||||||
|
value={form.data.cover_image}
|
||||||
|
previewUrl={coverPreviewUrl}
|
||||||
|
emptyLabel="Course cover"
|
||||||
|
helperText="Preferred 1600×900 at 16:9. Minimum upload is 1200×630. Use this as the main hero image for the course page and featured cards."
|
||||||
|
uploadUrl={editorContext.coverUploadUrl}
|
||||||
|
deleteUrl={editorContext.coverDeleteUrl}
|
||||||
|
isTemporaryValue={Boolean(stagedCoverPath) && stagedCoverPath === form.data.cover_image}
|
||||||
|
onChange={({ path, url }) => {
|
||||||
|
setStagedCoverPath(path || '')
|
||||||
|
form.setData('cover_image', path || '')
|
||||||
|
setCoverPreviewUrl(url || normalizeAssetPreview(path || '', editorContext.coverCdnBaseUrl))
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Cover image path override"
|
||||||
|
value={form.data.cover_image}
|
||||||
|
onChange={(event) => handleManualCoverChange(event.target.value)}
|
||||||
|
error={form.errors.cover_image}
|
||||||
|
placeholder="academy/lessons/covers/..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<WorldMediaUploadField
|
||||||
|
label="Teaser image"
|
||||||
|
slot="cover"
|
||||||
|
value={form.data.teaser_image}
|
||||||
|
previewUrl={teaserPreviewUrl}
|
||||||
|
emptyLabel="Course teaser"
|
||||||
|
helperText="Preferred 1600×900 at 16:9. Use this as the lighter secondary image for index cards or fallback thumbnail treatment when the main cover is too dense."
|
||||||
|
uploadUrl={editorContext.coverUploadUrl}
|
||||||
|
deleteUrl={editorContext.coverDeleteUrl}
|
||||||
|
isTemporaryValue={Boolean(stagedTeaserPath) && stagedTeaserPath === form.data.teaser_image}
|
||||||
|
onChange={({ path, url }) => {
|
||||||
|
setStagedTeaserPath(path || '')
|
||||||
|
form.setData('teaser_image', path || '')
|
||||||
|
setTeaserPreviewUrl(url || normalizeAssetPreview(path || '', editorContext.coverCdnBaseUrl))
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Teaser image path override"
|
||||||
|
value={form.data.teaser_image}
|
||||||
|
onChange={(event) => handleManualTeaserChange(event.target.value)}
|
||||||
|
error={form.errors.teaser_image}
|
||||||
|
placeholder="academy/lessons/covers/..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-[24px] border border-sky-300/18 bg-sky-300/8 px-4 py-4 text-sm leading-7 text-slate-300">
|
||||||
|
The public course index and the course hero both render landscape imagery first. If you only prepare one asset, prioritize the cover image. If you prepare both, keep them in the same visual family so the course feels consistent across list and detail pages.
|
||||||
|
</div>
|
||||||
|
</SectionCard>
|
||||||
|
|
||||||
|
<SectionCard
|
||||||
|
id="course-lessons-manager"
|
||||||
|
eyebrow="Lesson sequence"
|
||||||
|
title="Manage course lessons"
|
||||||
|
description="Add lessons from the library, drag rows to reorder, use the arrows for precision, and save the updated sequence. Removing a lesson detaches it from this course immediately."
|
||||||
|
tone="feature"
|
||||||
|
className={sectionClassName('course-lessons-manager')}
|
||||||
|
actions={
|
||||||
|
editorLinks.builder
|
||||||
|
? <a href={editorLinks.builder} className="rounded-2xl border border-amber-300/20 bg-amber-300/10 px-4 py-2.5 text-sm font-semibold text-amber-100 transition hover:brightness-110">Open full builder</a>
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{/* Current lesson sequence */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">
|
||||||
|
Lesson sequence
|
||||||
|
{lessonManagerDraft.length > 0 ? <span className="ml-2 rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-0.5 text-[10px] text-slate-300">{lessonManagerDraft.length}</span> : null}
|
||||||
|
{lessonManagerIsDirty ? <span className="ml-2 rounded-full border border-amber-300/20 bg-amber-300/10 px-2.5 py-0.5 text-[10px] text-amber-200">Unsaved order</span> : null}
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setLessonManagerDraft(normalizeLessonManagerLessons(courseLessonsSource))}
|
||||||
|
disabled={!lessonManagerIsDirty || lessonSaveProcessing}
|
||||||
|
className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs font-semibold text-white disabled:opacity-40"
|
||||||
|
>
|
||||||
|
Reset order
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={saveLessonOrder}
|
||||||
|
disabled={!lessonManagerIsDirty || lessonSaveProcessing}
|
||||||
|
className="rounded-full border border-sky-300/25 bg-sky-300/12 px-3 py-1.5 text-xs font-semibold text-sky-100 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{lessonSaveProcessing ? 'Saving…' : 'Save order'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{lessonManagerDraft.length === 0 ? (
|
||||||
|
<div className="rounded-[24px] border border-dashed border-white/10 bg-white/[0.03] px-5 py-6 text-sm text-slate-400">
|
||||||
|
No lessons attached to this course yet. Add lessons from the library below.
|
||||||
|
</div>
|
||||||
|
) : lessonManagerDraft.map((lesson, lessonIndex) => (
|
||||||
|
<div
|
||||||
|
key={lesson.id}
|
||||||
|
draggable
|
||||||
|
onDragStart={() => setLessonDragActive({ id: lesson.id })}
|
||||||
|
onDragEnd={() => setLessonDragActive(null)}
|
||||||
|
onDragOver={(e) => e.preventDefault()}
|
||||||
|
onDrop={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!lessonDragActive) return
|
||||||
|
setLessonManagerDraft(reorderLessonManagerLessons(lessonManagerDraft, lessonDragActive.id, lesson.id))
|
||||||
|
setLessonDragActive(null)
|
||||||
|
}}
|
||||||
|
className={[
|
||||||
|
'flex flex-wrap items-center justify-between gap-3 rounded-2xl border px-4 py-3 transition',
|
||||||
|
'border-white/10 bg-white/[0.03] cursor-grab',
|
||||||
|
lessonDragActive && Number(lessonDragActive.id) === Number(lesson.id) ? 'opacity-50 border-sky-300/30' : '',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
<div className="flex min-w-0 items-center gap-3">
|
||||||
|
<i className="fa-solid fa-grip-vertical text-xs text-slate-600" />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="rounded-full border border-white/10 bg-black/20 px-2.5 py-0.5 text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-300">
|
||||||
|
{formatLessonStep(lesson.order_num) || `#${lesson.display_order}`}
|
||||||
|
</span>
|
||||||
|
{lesson.formatted_lesson_number ? (
|
||||||
|
<span className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-0.5 text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-300">{lesson.formatted_lesson_number}</span>
|
||||||
|
) : null}
|
||||||
|
{lesson.section_title ? (
|
||||||
|
<span className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-0.5 text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-400">{lesson.section_title}</span>
|
||||||
|
) : null}
|
||||||
|
{lesson.difficulty ? (
|
||||||
|
<span className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-0.5 text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-400">{lesson.difficulty}</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<p className="mt-1.5 truncate text-sm font-semibold text-white">{lesson.title}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setLessonManagerDraft(moveLessonManagerLesson(lessonManagerDraft, lesson.id, -1))}
|
||||||
|
disabled={lessonIndex === 0}
|
||||||
|
className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1.5 text-xs font-semibold text-white disabled:opacity-30"
|
||||||
|
title="Move up"
|
||||||
|
>
|
||||||
|
<i className="fa-solid fa-arrow-up" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setLessonManagerDraft(moveLessonManagerLesson(lessonManagerDraft, lesson.id, 1))}
|
||||||
|
disabled={lessonIndex === lessonManagerDraft.length - 1}
|
||||||
|
className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1.5 text-xs font-semibold text-white disabled:opacity-30"
|
||||||
|
title="Move down"
|
||||||
|
>
|
||||||
|
<i className="fa-solid fa-arrow-down" />
|
||||||
|
</button>
|
||||||
|
{lesson.edit_url ? (
|
||||||
|
<a href={lesson.edit_url} className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs font-semibold text-white">
|
||||||
|
Edit
|
||||||
|
</a>
|
||||||
|
) : null}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => detachLesson(lesson)}
|
||||||
|
className="rounded-full border border-rose-300/20 bg-rose-300/10 px-3 py-1.5 text-xs font-semibold text-rose-100"
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Available lessons library */}
|
||||||
|
<div className="mt-6 space-y-3">
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Add from lesson library</p>
|
||||||
|
<div className="relative">
|
||||||
|
<i className="fa-solid fa-magnifying-glass absolute left-4 top-1/2 -translate-y-1/2 text-xs text-slate-500" />
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
value={lessonSearch}
|
||||||
|
onChange={(e) => setLessonSearch(e.target.value)}
|
||||||
|
placeholder="Search lessons by title or category…"
|
||||||
|
className="w-full rounded-2xl border border-white/10 bg-black/20 py-2.5 pl-9 pr-4 text-sm text-white outline-none placeholder:text-slate-600"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{filteredAvailableLessons.length === 0 ? (
|
||||||
|
<div className="rounded-[24px] border border-dashed border-white/10 bg-white/[0.03] px-5 py-4 text-sm text-slate-500">
|
||||||
|
{lessonSearch.trim() ? 'No unattached lessons match your search.' : 'All lessons are already attached to this course.'}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-2">
|
||||||
|
{filteredAvailableLessons.map((lesson) => (
|
||||||
|
<div key={lesson.id} className="flex flex-wrap items-center justify-between gap-3 rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{lesson.difficulty ? <span className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-0.5 text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-400">{lesson.difficulty}</span> : null}
|
||||||
|
{lesson.category ? <span className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-0.5 text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-400">{lesson.category}</span> : null}
|
||||||
|
{!lesson.active ? <span className="rounded-full border border-amber-300/20 bg-amber-300/10 px-2.5 py-0.5 text-[10px] font-semibold uppercase tracking-[0.14em] text-amber-200">Inactive</span> : null}
|
||||||
|
</div>
|
||||||
|
<p className="mt-1.5 truncate text-sm font-semibold text-white">{lesson.title}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => attachLesson(lesson)}
|
||||||
|
className="rounded-full border border-sky-300/25 bg-sky-300/12 px-3 py-1.5 text-xs font-semibold text-sky-100"
|
||||||
|
>
|
||||||
|
Add to course
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SectionCard>
|
||||||
|
|
||||||
|
<SectionCard id="course-publishing" eyebrow="Release controls" title="Access, status, and placement" description="Choose how the course appears in Academy discovery surfaces and when it goes live." className={sectionClassName('course-publishing')}>
|
||||||
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
|
<div className="grid gap-2 text-sm text-slate-300">
|
||||||
|
<NovaSelect label="Access" value={form.data.access_level || ''} onChange={(nextValue) => form.setData('access_level', String(nextValue || ''))} options={accessField?.options || []} searchable={false} className="bg-black/20" error={form.errors.access_level} />
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2 text-sm text-slate-300">
|
||||||
|
<NovaSelect label="Difficulty" value={form.data.difficulty || ''} onChange={(nextValue) => form.setData('difficulty', String(nextValue || ''))} options={difficultyField?.options || []} searchable={false} className="bg-black/20" error={form.errors.difficulty} />
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2 text-sm text-slate-300">
|
||||||
|
<NovaSelect label="Status" value={form.data.status || ''} onChange={(nextValue) => form.setData('status', String(nextValue || ''))} options={statusField?.options || []} searchable={false} className="bg-black/20" error={form.errors.status} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<TextField
|
||||||
|
label="Order number"
|
||||||
|
value={form.data.order_num ?? ''}
|
||||||
|
onChange={(event) => form.setData('order_num', event.target.value)}
|
||||||
|
error={form.errors.order_num}
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
placeholder="10"
|
||||||
|
hint="Lower numbers float higher in featured and published course lists."
|
||||||
|
/>
|
||||||
|
<div className="grid gap-2 text-sm text-slate-300">
|
||||||
|
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Publish at</span>
|
||||||
|
<DateTimePicker value={form.data.published_at || ''} onChange={(nextValue) => form.setData('published_at', nextValue || '')} clearable className="bg-black/20" />
|
||||||
|
<span className="text-xs leading-5 text-slate-500">If the status is set to published and this is empty, the backend will timestamp it automatically.</span>
|
||||||
|
<FieldError message={form.errors.published_at} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CheckboxCardField
|
||||||
|
label="Feature on newsroom surfaces"
|
||||||
|
checked={Boolean(form.data.is_featured)}
|
||||||
|
onChange={(event) => form.setData('is_featured', event.target.checked)}
|
||||||
|
description="Use the featured treatment on Academy homepage rails and the course index. Keep this for courses with strong cover art and a finished outline."
|
||||||
|
error={form.errors.is_featured}
|
||||||
|
/>
|
||||||
|
</SectionCard>
|
||||||
|
|
||||||
|
<SectionCard id="course-seo" eyebrow="Search surfaces" title="SEO and OpenGraph" description="Keep the course crawlable and shareable without overstuffing the main title." className={sectionClassName('course-seo')}>
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<TextField label="SEO title" value={form.data.seo_title} onChange={(event) => form.setData('seo_title', event.target.value)} error={form.errors.seo_title} maxLength={180} placeholder="Optional search title" />
|
||||||
|
<TextField label="OpenGraph title" value={form.data.og_title} onChange={(event) => form.setData('og_title', event.target.value)} error={form.errors.og_title} maxLength={180} placeholder="Optional social title" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<TextAreaField label="SEO description" value={form.data.seo_description} onChange={(event) => form.setData('seo_description', event.target.value)} error={form.errors.seo_description} rows={4} hint="Keep this short and aligned with the course promise." />
|
||||||
|
<TextAreaField label="OpenGraph description" value={form.data.og_description} onChange={(event) => form.setData('og_description', event.target.value)} error={form.errors.og_description} rows={4} hint="Used when the course page is shared into external platforms." />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<TextAreaField label="Meta keywords" value={form.data.meta_keywords} onChange={(event) => form.setData('meta_keywords', event.target.value)} error={form.errors.meta_keywords} rows={3} hint="Comma-separated terms. Keep this focused and editorial, not spammy." />
|
||||||
|
<TextField label="OpenGraph image" value={form.data.og_image} onChange={(event) => form.setData('og_image', event.target.value)} error={form.errors.og_image} placeholder="Leave empty to fall back to the course artwork" />
|
||||||
|
</div>
|
||||||
|
</SectionCard>
|
||||||
|
|
||||||
|
<SectionCard id="course-preview" eyebrow="Public preview" title="Rendered course snapshot" description="Use this tab to scan the media mix, course promise, and rendered long description without the rest of the form competing for attention." tone="feature" className={sectionClassName('course-preview')}>
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div className="overflow-hidden rounded-[28px] border border-white/10 bg-slate-950">
|
||||||
|
{coverPreviewUrl || teaserPreviewUrl ? (
|
||||||
|
<img src={coverPreviewUrl || teaserPreviewUrl} alt="Course hero preview" className="h-64 w-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<div className="flex h-64 items-center justify-center px-6 text-center text-sm text-slate-500">No course artwork selected yet.</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-[28px] border border-white/10 bg-black/20 p-6">
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<span className="rounded-full border border-sky-300/20 bg-sky-300/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-100">{form.data.difficulty || 'beginner'}</span>
|
||||||
|
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-200">{form.data.access_level || 'free'}</span>
|
||||||
|
{form.data.is_featured ? <span className="rounded-full border border-amber-300/20 bg-amber-300/15 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-amber-100">Featured</span> : null}
|
||||||
|
</div>
|
||||||
|
<h3 className="mt-4 text-3xl font-semibold tracking-[-0.05em] text-white">{form.data.title || 'Untitled academy course'}</h3>
|
||||||
|
{form.data.subtitle ? <p className="mt-2 text-sm font-semibold uppercase tracking-[0.18em] text-amber-100">{form.data.subtitle}</p> : null}
|
||||||
|
<p className="mt-4 text-sm leading-7 text-slate-300">{form.data.excerpt || 'Add a short course summary to explain what this path helps creators accomplish.'}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-[28px] border border-white/10 bg-black/20 p-6">
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Description preview</p>
|
||||||
|
{String(deferredDescription || '').trim() ? (
|
||||||
|
<div className="prose prose-invert mt-4 max-w-none prose-headings:tracking-[-0.03em] prose-p:text-slate-300 prose-li:text-slate-300" dangerouslySetInnerHTML={{ __html: deferredDescription }} />
|
||||||
|
) : (
|
||||||
|
<p className="mt-4 text-sm leading-7 text-slate-400">The long description is still empty.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SectionCard>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-6 xl:sticky xl:top-6 xl:self-start">
|
||||||
|
<SectionCard eyebrow="At a glance" title="Course summary" description="A compact view of the public URL, media readiness, and the metadata editors see most often.">
|
||||||
|
<div className="rounded-[24px] border border-sky-300/18 bg-sky-300/8 p-4">
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-100/80">Public path</p>
|
||||||
|
<p className="mt-2 break-all text-sm font-semibold text-white">{coursePathPreview}</p>
|
||||||
|
<p className="mt-2 text-sm leading-6 text-slate-400">Use a concise slug so the course URL stays readable in search results and internal links.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<div className="rounded-[24px] border border-white/10 bg-black/20 px-4 py-4">
|
||||||
|
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">Cover</p>
|
||||||
|
<p className="mt-2 text-sm font-semibold text-white">{coverPreviewUrl ? 'Ready' : 'Missing'}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-[24px] border border-white/10 bg-black/20 px-4 py-4">
|
||||||
|
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">Teaser</p>
|
||||||
|
<p className="mt-2 text-sm font-semibold text-white">{teaserPreviewUrl ? 'Ready' : 'Missing'}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-[24px] border border-white/10 bg-black/20 px-4 py-4">
|
||||||
|
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">Status</p>
|
||||||
|
<p className="mt-2 text-sm font-semibold text-white">{form.data.status || 'draft'}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-[24px] border border-white/10 bg-black/20 px-4 py-4">
|
||||||
|
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">Duration</p>
|
||||||
|
<p className="mt-2 text-sm font-semibold text-white">{form.data.estimated_minutes ? `${form.data.estimated_minutes} min` : 'Flexible'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SectionCard>
|
||||||
|
|
||||||
|
{outlineSummary ? (
|
||||||
|
<SectionCard eyebrow="Builder pulse" title="Course outline" description="A quick summary of what the course builder currently contains so editors do not need to leave this form just to check structure.">
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<div className="rounded-[24px] border border-white/10 bg-black/20 px-4 py-4">
|
||||||
|
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">Sections</p>
|
||||||
|
<p className="mt-2 text-sm font-semibold text-white">{outlineSummary.section_count}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-[24px] border border-white/10 bg-black/20 px-4 py-4">
|
||||||
|
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">Visible sections</p>
|
||||||
|
<p className="mt-2 text-sm font-semibold text-white">{outlineSummary.visible_section_count}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-[24px] border border-white/10 bg-black/20 px-4 py-4">
|
||||||
|
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">Attached lessons</p>
|
||||||
|
<p className="mt-2 text-sm font-semibold text-white">{outlineSummary.lesson_count}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-[24px] border border-white/10 bg-black/20 px-4 py-4">
|
||||||
|
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">Required lessons</p>
|
||||||
|
<p className="mt-2 text-sm font-semibold text-white">{outlineSummary.required_lesson_count}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-[24px] border border-sky-300/18 bg-sky-300/8 px-4 py-4 text-sm leading-7 text-slate-300">
|
||||||
|
{outlineSummary.unsectioned_lesson_count > 0
|
||||||
|
? `${outlineSummary.unsectioned_lesson_count} lesson${outlineSummary.unsectioned_lesson_count === 1 ? '' : 's'} still sit outside sections. Use the builder if you want the outline to read like a guided chapter path.`
|
||||||
|
: 'All attached lessons are currently grouped into sections.'}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{outlineSummary.sections?.length ? (
|
||||||
|
<div className="grid gap-3">
|
||||||
|
{outlineSummary.sections.map((section) => <OutlineSectionPill key={section.id} section={section} />)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-[24px] border border-dashed border-white/10 bg-white/[0.03] px-4 py-5 text-sm text-slate-400">No sections yet. The builder will still allow unsectioned lessons, but adding chapters usually makes the public course easier to scan.</div>
|
||||||
|
)}
|
||||||
|
</SectionCard>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<SectionCard eyebrow="Metadata pulse" title="Search and share" description="A quick scan of the metadata that most often gets missed before publish.">
|
||||||
|
<div className="grid gap-3">
|
||||||
|
<div className="rounded-[24px] border border-white/10 bg-black/20 px-4 py-4">
|
||||||
|
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">SEO title</p>
|
||||||
|
<p className="mt-2 text-sm text-white">{form.data.seo_title || 'Uses course title by default'}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-[24px] border border-white/10 bg-black/20 px-4 py-4">
|
||||||
|
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">Keywords</p>
|
||||||
|
<div className="mt-2 flex flex-wrap gap-2">
|
||||||
|
{metaKeywordItems.length ? metaKeywordItems.map((item) => <span key={item} className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1 text-[11px] font-semibold text-slate-200">{item}</span>) : <span className="text-sm text-slate-400">No meta keywords yet.</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SectionCard>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-3 rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
|
||||||
|
<button type="submit" disabled={form.processing} className="rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100">{form.processing ? 'Saving...' : 'Save course'}</button>
|
||||||
|
<Link href={indexUrl} className="rounded-full border border-white/10 bg-white/[0.04] px-5 py-3 text-sm font-semibold text-white">Back</Link>
|
||||||
|
{destroyUrl ? <button type="button" onClick={deleteCourse} className="rounded-full border border-rose-300/20 bg-rose-300/10 px-5 py-3 text-sm font-semibold text-rose-100">Delete</button> : null}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</AdminLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,400 @@
|
|||||||
import React from 'react'
|
import React, { useEffect, useState } from 'react'
|
||||||
import { Head, Link, router, usePage } from '@inertiajs/react'
|
import { Head, Link, router, usePage } from '@inertiajs/react'
|
||||||
import AdminLayout from '../../../Layouts/AdminLayout'
|
import AdminLayout from '../../../Layouts/AdminLayout'
|
||||||
|
|
||||||
|
const PROMPT_VIEW_STORAGE_KEY = 'skinbase.admin.academy.prompts.view'
|
||||||
|
const PROMPT_VIEW_OPTIONS = [
|
||||||
|
{ value: 'gallery', label: 'Gallery', icon: 'fa-images' },
|
||||||
|
{ value: 'grid', label: 'Grid', icon: 'fa-grid-2' },
|
||||||
|
{ value: 'table', label: 'Table', icon: 'fa-table-list' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function formatDateLabel(value) {
|
||||||
|
if (!value) return 'Recently updated'
|
||||||
|
|
||||||
|
const date = new Date(value)
|
||||||
|
|
||||||
|
if (Number.isNaN(date.getTime())) return 'Recently updated'
|
||||||
|
|
||||||
|
return new Intl.DateTimeFormat('en-GB', { day: 'numeric', month: 'short', year: 'numeric' }).format(date)
|
||||||
|
}
|
||||||
|
|
||||||
|
function paginationLabel(label) {
|
||||||
|
return String(label || '')
|
||||||
|
.replace(/«/g, 'Previous')
|
||||||
|
.replace(/»/g, 'Next')
|
||||||
|
.replace(/<[^>]+>/g, '')
|
||||||
|
.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function promptSummary(items = []) {
|
||||||
|
return items.reduce((summary, item) => ({
|
||||||
|
total: summary.total + 1,
|
||||||
|
active: summary.active + (item.active ? 1 : 0),
|
||||||
|
featured: summary.featured + (item.featured ? 1 : 0),
|
||||||
|
promptOfWeek: summary.promptOfWeek + (item.prompt_of_week ? 1 : 0),
|
||||||
|
comparisons: summary.comparisons + Number(item.comparisons_count || 0),
|
||||||
|
}), { total: 0, active: 0, featured: 0, promptOfWeek: 0, comparisons: 0 })
|
||||||
|
}
|
||||||
|
|
||||||
|
function PromptFlag({ children, tone = 'default' }) {
|
||||||
|
const toneClass = tone === 'warm'
|
||||||
|
? 'border-[#ffcfbf]/20 bg-[#ffcfbf]/10 text-[#fff0ea]'
|
||||||
|
: tone === 'sky'
|
||||||
|
? 'border-sky-300/20 bg-sky-300/10 text-sky-100'
|
||||||
|
: tone === 'emerald'
|
||||||
|
? 'border-emerald-300/20 bg-emerald-300/10 text-emerald-100'
|
||||||
|
: 'border-white/10 bg-white/[0.05] text-slate-200'
|
||||||
|
|
||||||
|
return <span className={`rounded-full border px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] ${toneClass}`}>{children}</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
function PromptActions({ item }) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
{item.preview_url ? <Link href={item.preview_url} className="rounded-full border border-[#ffcfbf]/20 bg-[#ffcfbf]/10 px-4 py-2 text-sm font-semibold text-[#fff0ea]">Preview</Link> : null}
|
||||||
|
<Link href={item.edit_url} className="rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-white">Edit</Link>
|
||||||
|
<button type="button" onClick={() => { if (!window.confirm('Delete this prompt?')) return; router.delete(item.destroy_url, { preserveScroll: true }) }} className="rounded-full border border-rose-300/20 bg-rose-300/10 px-4 py-2 text-sm font-semibold text-rose-100">Delete</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PromptPreview({ item, compact = false }) {
|
||||||
|
if (item.preview_image_url) {
|
||||||
|
return <img src={item.preview_image_url} alt={item.title} className={`h-full w-full object-cover transition duration-500 ${compact ? 'group-hover:scale-[1.04]' : 'group-hover:scale-[1.03]'}`} />
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full items-center justify-center bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.18),transparent_30%),linear-gradient(135deg,rgba(15,23,42,0.98),rgba(30,41,59,0.94))] p-6 text-center text-slate-300">
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] font-semibold uppercase tracking-[0.2em] text-slate-500">Prompt preview</p>
|
||||||
|
<p className="mt-3 text-sm font-semibold text-white">No image attached yet</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PromptMeta({ item }) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{item.category_name ? <PromptFlag tone="warm">{item.category_name}</PromptFlag> : null}
|
||||||
|
{item.difficulty ? <PromptFlag>{item.difficulty}</PromptFlag> : null}
|
||||||
|
{item.access_level ? <PromptFlag>{item.access_level}</PromptFlag> : null}
|
||||||
|
{item.aspect_ratio ? <PromptFlag>{item.aspect_ratio}</PromptFlag> : null}
|
||||||
|
{item.featured ? <PromptFlag tone="sky">Featured</PromptFlag> : null}
|
||||||
|
{item.prompt_of_week ? <PromptFlag tone="emerald">Prompt of week</PromptFlag> : null}
|
||||||
|
<PromptFlag tone={item.active ? 'sky' : 'default'}>{item.active ? 'Active' : 'Draft'}</PromptFlag>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PromptGalleryCard({ item }) {
|
||||||
|
return (
|
||||||
|
<article className="group overflow-hidden rounded-[30px] border border-white/[0.08] bg-[linear-gradient(135deg,rgba(8,15,28,0.98),rgba(15,23,42,0.92))] shadow-[0_24px_80px_rgba(2,6,23,0.24)]">
|
||||||
|
<div className="grid gap-0 xl:grid-cols-[340px_minmax(0,1fr)]">
|
||||||
|
<div className="relative min-h-[250px] overflow-hidden border-b border-white/10 xl:min-h-full xl:border-b-0 xl:border-r xl:border-white/10">
|
||||||
|
<PromptPreview item={item} />
|
||||||
|
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(2,6,23,0.04),rgba(2,6,23,0.32))]" />
|
||||||
|
<div className="absolute left-4 top-4 flex flex-wrap gap-2">
|
||||||
|
<PromptFlag tone="warm">{item.comparisons_count || 0} comparisons</PromptFlag>
|
||||||
|
{item.slug ? <PromptFlag>{item.slug}</PromptFlag> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex h-full flex-col justify-between p-6 lg:p-7">
|
||||||
|
<div>
|
||||||
|
<PromptMeta item={item} />
|
||||||
|
<h2 className="mt-4 text-2xl font-semibold tracking-[-0.04em] text-white">{item.title}</h2>
|
||||||
|
<p className="mt-3 max-w-3xl text-sm leading-7 text-slate-300">{item.excerpt || 'Add an excerpt to make this prompt easier to scan in moderation.'}</p>
|
||||||
|
|
||||||
|
{item.tags?.length ? (
|
||||||
|
<div className="mt-5 flex flex-wrap gap-2">
|
||||||
|
{item.tags.slice(0, 5).map((tag) => (
|
||||||
|
<span key={tag} className="rounded-full border border-white/10 bg-black/20 px-3 py-1 text-xs font-semibold uppercase tracking-[0.18em] text-slate-300">{tag}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 flex flex-wrap items-center justify-between gap-4 border-t border-white/10 pt-5">
|
||||||
|
<div className="grid gap-3 sm:grid-cols-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] font-semibold uppercase tracking-[0.2em] text-slate-500">Updated</p>
|
||||||
|
<p className="mt-1 text-sm font-semibold text-white">{formatDateLabel(item.updated_at)}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] font-semibold uppercase tracking-[0.2em] text-slate-500">Access</p>
|
||||||
|
<p className="mt-1 text-sm font-semibold text-white">{item.access_level || 'free'}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] font-semibold uppercase tracking-[0.2em] text-slate-500">Status</p>
|
||||||
|
<p className="mt-1 text-sm font-semibold text-white">{item.active ? 'Visible' : 'Hidden'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<PromptActions item={item} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PromptGridCard({ item }) {
|
||||||
|
return (
|
||||||
|
<article className="group overflow-hidden rounded-[28px] border border-white/[0.08] bg-[linear-gradient(180deg,rgba(255,255,255,0.04),rgba(15,23,42,0.18))] shadow-[0_18px_60px_rgba(2,6,23,0.18)]">
|
||||||
|
<div className="relative h-52 overflow-hidden border-b border-white/10">
|
||||||
|
<PromptPreview item={item} compact />
|
||||||
|
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(2,6,23,0.02),rgba(2,6,23,0.34))]" />
|
||||||
|
</div>
|
||||||
|
<div className="p-5">
|
||||||
|
<PromptMeta item={item} />
|
||||||
|
<h2 className="mt-4 text-xl font-semibold tracking-[-0.04em] text-white">{item.title}</h2>
|
||||||
|
<p className="mt-3 text-sm leading-7 text-slate-300">{item.excerpt || 'No excerpt added yet.'}</p>
|
||||||
|
<div className="mt-5 flex items-center justify-between gap-3 text-sm text-slate-400">
|
||||||
|
<span>{formatDateLabel(item.updated_at)}</span>
|
||||||
|
<span>{item.comparisons_count || 0} comparisons</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-5">
|
||||||
|
<PromptActions item={item} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PromptTable({ items }) {
|
||||||
|
return (
|
||||||
|
<div className="overflow-hidden rounded-[30px] border border-white/[0.08] bg-[linear-gradient(180deg,rgba(15,23,42,0.92),rgba(2,6,23,0.92))] shadow-[0_24px_80px_rgba(2,6,23,0.22)]">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="min-w-full divide-y divide-white/10 text-left">
|
||||||
|
<thead className="bg-white/[0.04] text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">
|
||||||
|
<tr>
|
||||||
|
<th className="px-5 py-4">Prompt</th>
|
||||||
|
<th className="px-5 py-4">Category</th>
|
||||||
|
<th className="px-5 py-4">Access</th>
|
||||||
|
<th className="px-5 py-4">Signals</th>
|
||||||
|
<th className="px-5 py-4">Updated</th>
|
||||||
|
<th className="px-5 py-4 text-right">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-white/10 text-sm text-slate-200">
|
||||||
|
{items.map((item) => (
|
||||||
|
<tr key={item.id} className="align-top transition hover:bg-white/[0.03]">
|
||||||
|
<td className="px-5 py-4">
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<div className="h-16 w-20 overflow-hidden rounded-2xl border border-white/10 bg-black/30 shrink-0">
|
||||||
|
<PromptPreview item={item} compact />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-white">{item.title}</p>
|
||||||
|
<p className="mt-1 max-w-md text-sm leading-6 text-slate-400">{item.excerpt || 'No excerpt added yet.'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-4">{item.category_name || 'Uncategorized'}</td>
|
||||||
|
<td className="px-5 py-4">{item.access_level || 'free'}</td>
|
||||||
|
<td className="px-5 py-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p>{item.comparisons_count || 0} comparisons</p>
|
||||||
|
<p>{item.difficulty || 'No difficulty'}</p>
|
||||||
|
<p>{item.active ? 'Active' : 'Draft'}</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-4">{formatDateLabel(item.updated_at)}</td>
|
||||||
|
<td className="px-5 py-4">
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
{item.preview_url ? <Link href={item.preview_url} className="rounded-full border border-[#ffcfbf]/20 bg-[#ffcfbf]/10 px-3 py-2 text-xs font-semibold text-[#fff0ea]">Preview</Link> : null}
|
||||||
|
<Link href={item.edit_url} className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-2 text-xs font-semibold text-white">Edit</Link>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PromptHeroCollage({ items = [] }) {
|
||||||
|
const images = items
|
||||||
|
.map((item) => item?.preview_image_url)
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, 4)
|
||||||
|
|
||||||
|
if (!images.length) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-[420px] items-center justify-center rounded-[32px] border border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.14),transparent_24%),radial-gradient(circle_at_bottom_right,rgba(255,207,191,0.18),transparent_26%),linear-gradient(135deg,rgba(12,18,31,0.98),rgba(30,41,59,0.94))] px-8 text-center">
|
||||||
|
<div>
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-slate-500">Prompt preview wall</p>
|
||||||
|
<p className="mt-4 text-lg font-semibold text-white">Preview images will appear here as prompts get covers.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid min-h-[420px] grid-cols-2 gap-3">
|
||||||
|
{images.map((image, index) => (
|
||||||
|
<div
|
||||||
|
key={`${image}-${index}`}
|
||||||
|
className={`overflow-hidden rounded-[28px] border border-white/10 bg-black/20 shadow-[0_18px_45px_rgba(2,6,23,0.2)] ${index === 0 ? 'col-span-2 aspect-[16/9]' : index === 3 ? 'aspect-[4/5]' : 'aspect-square'}`}
|
||||||
|
>
|
||||||
|
<img src={image} alt="" aria-hidden="true" className="h-full w-full object-cover" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PaginationLinks({ links = [] }) {
|
||||||
|
if (!Array.isArray(links) || links.length <= 3) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-8 flex flex-wrap gap-2">
|
||||||
|
{links.map((link, index) => {
|
||||||
|
const label = paginationLabel(link.label)
|
||||||
|
const className = link.active
|
||||||
|
? 'border-sky-300/25 bg-sky-300/12 text-sky-100'
|
||||||
|
: 'border-white/10 bg-white/[0.04] text-slate-200 hover:border-white/20 hover:bg-white/[0.07]'
|
||||||
|
|
||||||
|
return link.url ? (
|
||||||
|
<Link key={`${label}-${index}`} href={link.url} className={`rounded-full border px-4 py-2 text-sm font-semibold transition ${className}`} preserveScroll>
|
||||||
|
{label}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span key={`${label}-${index}`} className="rounded-full border border-white/10 bg-black/20 px-4 py-2 text-sm font-semibold text-slate-500">{label}</span>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PromptIndexContent({ title, subtitle, items, createUrl }) {
|
||||||
|
const promptItems = items?.data || []
|
||||||
|
const summary = promptSummary(promptItems)
|
||||||
|
const [viewMode, setViewMode] = useState('gallery')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === 'undefined') return
|
||||||
|
|
||||||
|
const storedView = window.localStorage.getItem(PROMPT_VIEW_STORAGE_KEY)
|
||||||
|
if (PROMPT_VIEW_OPTIONS.some((option) => option.value === storedView)) {
|
||||||
|
setViewMode(storedView)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === 'undefined') return
|
||||||
|
window.localStorage.setItem(PROMPT_VIEW_STORAGE_KEY, viewMode)
|
||||||
|
}, [viewMode])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<section className="overflow-hidden rounded-[38px] border border-white/[0.08] bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.16),transparent_24%),radial-gradient(circle_at_bottom_right,rgba(255,207,191,0.16),transparent_24%),linear-gradient(135deg,rgba(4,9,18,0.98),rgba(15,23,42,0.92))] shadow-[0_28px_90px_rgba(2,6,23,0.28)]">
|
||||||
|
<div className="grid gap-8 p-6 xl:grid-cols-[minmax(0,1.08fr)_420px] xl:items-end xl:p-10">
|
||||||
|
<div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<span className="rounded-full border border-[#ffcfbf]/20 bg-[#ffcfbf]/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.24em] text-[#fff0ea]">Academy moderation</span>
|
||||||
|
<span className="rounded-full border border-white/10 bg-white/[0.05] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.24em] text-slate-300">Prompt library</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 className="mt-5 max-w-4xl text-4xl font-semibold tracking-[-0.055em] text-white md:text-5xl xl:text-6xl">{title}</h2>
|
||||||
|
<p className="mt-5 max-w-3xl text-base leading-8 text-slate-300 md:text-lg">{subtitle} Review prompts in a visual-first moderation surface, jump into edits quickly, and switch between gallery, grid, or table depending on the task in front of you.</p>
|
||||||
|
|
||||||
|
<div className="mt-7 grid gap-3 sm:grid-cols-3">
|
||||||
|
<div className="rounded-[24px] border border-white/10 bg-black/20 px-5 py-4">
|
||||||
|
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">Visual-first</p>
|
||||||
|
<p className="mt-2 text-sm font-semibold text-white">Curate covers and prompt outputs before opening the form.</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-[24px] border border-white/10 bg-black/20 px-5 py-4">
|
||||||
|
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">Workflow-ready</p>
|
||||||
|
<p className="mt-2 text-sm font-semibold text-white">Switch between gallery, compact cards, and scan-heavy tables.</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-[24px] border border-white/10 bg-black/20 px-5 py-4">
|
||||||
|
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">Comparison-aware</p>
|
||||||
|
<p className="mt-2 text-sm font-semibold text-white">Spot prompts with provider notes and attached result references.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 flex flex-wrap gap-3">
|
||||||
|
{PROMPT_VIEW_OPTIONS.map((option) => {
|
||||||
|
const active = option.value === viewMode
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setViewMode(option.value)}
|
||||||
|
className={`inline-flex items-center gap-2 rounded-full border px-4 py-2 text-sm font-semibold transition ${active ? 'border-sky-300/25 bg-sky-300/12 text-sky-100' : 'border-white/10 bg-white/[0.04] text-slate-200 hover:border-white/20 hover:bg-white/[0.07]'}`}
|
||||||
|
>
|
||||||
|
<i className={`fa-solid ${option.icon}`} />
|
||||||
|
<span>{option.label} view</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-7 flex flex-wrap gap-3">
|
||||||
|
<Link href={createUrl} className="rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100">Create prompt</Link>
|
||||||
|
<Link href="/academy/prompts" className="rounded-full border border-white/10 bg-white/[0.04] px-5 py-3 text-sm font-semibold text-white/85">Open public library</Link>
|
||||||
|
<span className="rounded-full border border-white/10 bg-white/[0.04] px-5 py-3 text-sm font-semibold text-white/85">{summary.total} prompts in view</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-7 grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||||
|
<div className="rounded-[24px] border border-white/10 bg-black/20 px-5 py-4">
|
||||||
|
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">Active</p>
|
||||||
|
<p className="mt-2 text-2xl font-semibold tracking-[-0.04em] text-white">{summary.active}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-[24px] border border-white/10 bg-black/20 px-5 py-4">
|
||||||
|
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">Featured</p>
|
||||||
|
<p className="mt-2 text-2xl font-semibold tracking-[-0.04em] text-white">{summary.featured}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-[24px] border border-white/10 bg-black/20 px-5 py-4">
|
||||||
|
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">Prompt of week</p>
|
||||||
|
<p className="mt-2 text-2xl font-semibold tracking-[-0.04em] text-white">{summary.promptOfWeek}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-[24px] border border-white/10 bg-black/20 px-5 py-4">
|
||||||
|
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">Comparisons</p>
|
||||||
|
<p className="mt-2 text-2xl font-semibold tracking-[-0.04em] text-white">{summary.comparisons}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<PromptHeroCollage items={promptItems} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||||
|
<p className="text-sm text-slate-400">Manage Academy content below. Changes clear Academy cache automatically.</p>
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
<Link href="/academy/prompts" className="rounded-full border border-white/10 bg-white/[0.04] px-5 py-3 text-sm font-semibold text-white/85">View public library</Link>
|
||||||
|
<Link href={createUrl} className="rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100">Create prompt</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{promptItems.length === 0 ? (
|
||||||
|
<div className="rounded-[28px] border border-white/[0.08] bg-white/[0.03] px-6 py-12 text-center text-slate-400">No prompt templates exist yet.</div>
|
||||||
|
) : viewMode === 'table' ? (
|
||||||
|
<PromptTable items={promptItems} />
|
||||||
|
) : viewMode === 'grid' ? (
|
||||||
|
<div className="grid gap-5 md:grid-cols-2 2xl:grid-cols-3">
|
||||||
|
{promptItems.map((item) => <PromptGridCard key={item.id} item={item} />)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-5">
|
||||||
|
{promptItems.map((item) => <PromptGalleryCard key={item.id} item={item} />)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<PaginationLinks links={items?.links} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default function AcademyCrudIndex({ title, subtitle, items, columns, createUrl }) {
|
export default function AcademyCrudIndex({ title, subtitle, items, columns, createUrl }) {
|
||||||
const flash = usePage().props.flash || {}
|
const flash = usePage().props.flash || {}
|
||||||
|
|
||||||
@@ -11,6 +404,10 @@ export default function AcademyCrudIndex({ title, subtitle, items, columns, crea
|
|||||||
|
|
||||||
{flash.success ? <div className="mb-6 rounded-2xl border border-emerald-300/20 bg-emerald-300/10 px-4 py-3 text-sm text-emerald-100">{flash.success}</div> : null}
|
{flash.success ? <div className="mb-6 rounded-2xl border border-emerald-300/20 bg-emerald-300/10 px-4 py-3 text-sm text-emerald-100">{flash.success}</div> : null}
|
||||||
|
|
||||||
|
{usePage().props.resource === 'prompts' ? (
|
||||||
|
<PromptIndexContent title={title} subtitle={subtitle} items={items} createUrl={createUrl} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<div className="mb-6 flex items-center justify-between gap-4">
|
<div className="mb-6 flex items-center justify-between gap-4">
|
||||||
<p className="text-sm text-slate-400">Manage Academy content below. Changes clear Academy cache automatically.</p>
|
<p className="text-sm text-slate-400">Manage Academy content below. Changes clear Academy cache automatically.</p>
|
||||||
<Link href={createUrl} className="rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100">Create record</Link>
|
<Link href={createUrl} className="rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100">Create record</Link>
|
||||||
@@ -33,6 +430,7 @@ export default function AcademyCrudIndex({ title, subtitle, items, columns, crea
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-3 lg:justify-end">
|
<div className="flex flex-wrap gap-3 lg:justify-end">
|
||||||
|
{item.builder_url ? <Link href={item.builder_url} className="rounded-full border border-amber-300/20 bg-amber-300/10 px-4 py-2 text-sm font-semibold text-amber-100">Builder</Link> : null}
|
||||||
<Link href={item.edit_url} className="rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-white">Edit</Link>
|
<Link href={item.edit_url} className="rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-white">Edit</Link>
|
||||||
<button type="button" onClick={() => { if (!window.confirm('Delete this record?')) return; router.delete(item.destroy_url, { preserveScroll: true }) }} className="rounded-full border border-rose-300/20 bg-rose-300/10 px-4 py-2 text-sm font-semibold text-rose-100">Delete</button>
|
<button type="button" onClick={() => { if (!window.confirm('Delete this record?')) return; router.delete(item.destroy_url, { preserveScroll: true }) }} className="rounded-full border border-rose-300/20 bg-rose-300/10 px-4 py-2 text-sm font-semibold text-rose-100">Delete</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -41,6 +439,8 @@ export default function AcademyCrudIndex({ title, subtitle, items, columns, crea
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</AdminLayout>
|
</AdminLayout>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -17,6 +17,7 @@ export default function AcademyDashboard({ stats, links }) {
|
|||||||
<Head title="Admin · Academy Dashboard" />
|
<Head title="Admin · Academy Dashboard" />
|
||||||
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||||
|
<StatCard label="Courses" value={stats.courses} />
|
||||||
<StatCard label="Lessons" value={stats.lessons} />
|
<StatCard label="Lessons" value={stats.lessons} />
|
||||||
<StatCard label="Prompts" value={stats.prompts} />
|
<StatCard label="Prompts" value={stats.prompts} />
|
||||||
<StatCard label="Prompt Packs" value={stats.packs} />
|
<StatCard label="Prompt Packs" value={stats.packs} />
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -45,7 +45,7 @@ export default function ForumSection({ category, boards = [], seo = {} }) {
|
|||||||
) : (
|
) : (
|
||||||
<div className="mt-5 grid gap-4 md:grid-cols-2">
|
<div className="mt-5 grid gap-4 md:grid-cols-2">
|
||||||
{boards.map((board) => (
|
{boards.map((board) => (
|
||||||
<a key={board.id ?? board.slug} href={`/forum/${board.slug}`} className="rounded-2xl border border-white/8 bg-white/[0.02] p-5 transition hover:border-cyan-400/25 hover:bg-white/[0.04]">
|
<a key={board.id ?? board.slug} href={`/forum/${board.slug}`} className="rounded-2xl border border-white/8 bg-white/[0.02] p-5 transition hover:border-cyan-400/25 hover:bg-white/[0.04] block">
|
||||||
<div className="flex items-start justify-between gap-4">
|
<div className="flex items-start justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold text-white">{board.title}</h3>
|
<h3 className="text-lg font-semibold text-white">{board.title}</h3>
|
||||||
|
|||||||
103
resources/js/Pages/News/NewsImagePreview.jsx
Normal file
103
resources/js/Pages/News/NewsImagePreview.jsx
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
let previewOverlay = null
|
||||||
|
let previewImage = null
|
||||||
|
let previewCaption = null
|
||||||
|
let scrollPosition = 0
|
||||||
|
|
||||||
|
function ensurePreviewOverlay() {
|
||||||
|
if (previewOverlay) {
|
||||||
|
return previewOverlay
|
||||||
|
}
|
||||||
|
|
||||||
|
previewOverlay = document.createElement('div')
|
||||||
|
previewOverlay.className = 'fixed inset-0 z-[130] hidden items-center justify-center bg-[#020611e8] p-4 backdrop-blur-md'
|
||||||
|
previewOverlay.setAttribute('role', 'dialog')
|
||||||
|
previewOverlay.setAttribute('aria-modal', 'true')
|
||||||
|
previewOverlay.setAttribute('aria-label', 'Image preview')
|
||||||
|
|
||||||
|
const frame = document.createElement('div')
|
||||||
|
frame.className = 'relative max-h-[92vh] max-w-6xl'
|
||||||
|
|
||||||
|
previewImage = document.createElement('img')
|
||||||
|
previewImage.className = 'max-h-[92vh] max-w-full rounded-[28px] border border-white/10 shadow-[0_28px_90px_rgba(2,6,23,0.6)]'
|
||||||
|
previewImage.alt = 'Image preview'
|
||||||
|
|
||||||
|
previewCaption = document.createElement('div')
|
||||||
|
previewCaption.className = 'absolute inset-x-0 bottom-0 rounded-b-[28px] bg-gradient-to-t from-black/80 to-transparent px-5 py-4 text-sm font-medium text-white/90'
|
||||||
|
|
||||||
|
const closeButton = document.createElement('button')
|
||||||
|
closeButton.type = 'button'
|
||||||
|
closeButton.setAttribute('aria-label', 'Close image preview')
|
||||||
|
closeButton.className = 'absolute right-4 top-4 inline-flex h-11 w-11 items-center justify-center rounded-full border border-white/10 bg-black/35 text-white transition hover:border-white/25 hover:bg-white/10'
|
||||||
|
closeButton.innerHTML = '<i class="fa-solid fa-xmark text-lg"></i>'
|
||||||
|
closeButton.addEventListener('click', hidePreview)
|
||||||
|
|
||||||
|
frame.appendChild(previewImage)
|
||||||
|
frame.appendChild(previewCaption)
|
||||||
|
frame.appendChild(closeButton)
|
||||||
|
previewOverlay.appendChild(frame)
|
||||||
|
|
||||||
|
previewOverlay.addEventListener('click', (event) => {
|
||||||
|
if (event.target === previewOverlay) {
|
||||||
|
hidePreview()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
document.body.appendChild(previewOverlay)
|
||||||
|
return previewOverlay
|
||||||
|
}
|
||||||
|
|
||||||
|
function showPreview(src, alt) {
|
||||||
|
if (!src) return
|
||||||
|
|
||||||
|
ensurePreviewOverlay()
|
||||||
|
scrollPosition = window.scrollY || document.documentElement.scrollTop || 0
|
||||||
|
document.body.style.position = 'fixed'
|
||||||
|
document.body.style.top = `-${scrollPosition}px`
|
||||||
|
document.body.style.left = '0'
|
||||||
|
document.body.style.right = '0'
|
||||||
|
document.body.style.width = '100%'
|
||||||
|
|
||||||
|
previewImage.src = src
|
||||||
|
previewImage.alt = alt || 'Image preview'
|
||||||
|
previewCaption.textContent = alt || 'Image preview'
|
||||||
|
previewOverlay.classList.remove('hidden')
|
||||||
|
previewOverlay.classList.add('flex')
|
||||||
|
}
|
||||||
|
|
||||||
|
function hidePreview() {
|
||||||
|
if (!previewOverlay) return
|
||||||
|
|
||||||
|
previewOverlay.classList.add('hidden')
|
||||||
|
previewOverlay.classList.remove('flex')
|
||||||
|
previewImage.removeAttribute('src')
|
||||||
|
document.body.style.position = ''
|
||||||
|
document.body.style.top = ''
|
||||||
|
document.body.style.left = ''
|
||||||
|
document.body.style.right = ''
|
||||||
|
document.body.style.width = ''
|
||||||
|
window.scrollTo(0, scrollPosition)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleNewsImagePreview(event) {
|
||||||
|
const trigger = event.target?.closest?.('[data-news-image-preview]')
|
||||||
|
if (!trigger) return
|
||||||
|
|
||||||
|
const src = trigger.getAttribute('data-news-image-src') || trigger.getAttribute('href')
|
||||||
|
if (!src) return
|
||||||
|
|
||||||
|
event.preventDefault()
|
||||||
|
showPreview(src, trigger.getAttribute('data-news-image-alt') || trigger.getAttribute('aria-label') || 'Image preview')
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyDown(event) {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
hidePreview()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof document !== 'undefined') {
|
||||||
|
document.addEventListener('click', handleNewsImagePreview)
|
||||||
|
document.addEventListener('keydown', handleKeyDown)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default null
|
||||||
@@ -20,7 +20,6 @@ export default function PostCard({ post, thread, isOp = false, isAuthenticated =
|
|||||||
const isEdited = post?.is_edited
|
const isEdited = post?.is_edited
|
||||||
const postId = post?.id
|
const postId = post?.id
|
||||||
const threadSlug = thread?.slug
|
const threadSlug = thread?.slug
|
||||||
|
|
||||||
const handleReaction = async (reaction) => {
|
const handleReaction = async (reaction) => {
|
||||||
if (reacting || !isAuthenticated) return
|
if (reacting || !isAuthenticated) return
|
||||||
setReacting(true)
|
setReacting(true)
|
||||||
|
|||||||
@@ -46,6 +46,21 @@ function Divider() {
|
|||||||
return <div className="mx-1 h-5 w-px bg-white/10" />
|
return <div className="mx-1 h-5 w-px bg-white/10" />
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getRootFontSizePx() {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return 16
|
||||||
|
}
|
||||||
|
|
||||||
|
return Number.parseFloat(window.getComputedStyle(document.documentElement).fontSize) || 16
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatViewportHeightLabel(value) {
|
||||||
|
const rounded = Number(value || 0)
|
||||||
|
const displayValue = Number.isInteger(rounded) ? rounded : Number(rounded.toFixed(1))
|
||||||
|
|
||||||
|
return `${displayValue}rem`
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeHttpUrl(rawValue) {
|
function normalizeHttpUrl(rawValue) {
|
||||||
const trimmed = String(rawValue || '').trim()
|
const trimmed = String(rawValue || '').trim()
|
||||||
if (trimmed === '') {
|
if (trimmed === '') {
|
||||||
@@ -922,10 +937,16 @@ function AssetPickerDialog({
|
|||||||
function Toolbar({
|
function Toolbar({
|
||||||
editor,
|
editor,
|
||||||
advancedNews = false,
|
advancedNews = false,
|
||||||
sourceMode = false,
|
activeSourceMode = null,
|
||||||
|
sourceModeLabel = 'HTML',
|
||||||
|
sourceModeTitle = 'View or edit source HTML',
|
||||||
|
secondarySourceModeLabel = null,
|
||||||
|
secondarySourceModeTitle = '',
|
||||||
showStructureOutlines = false,
|
showStructureOutlines = false,
|
||||||
showComparisonTool = false,
|
showComparisonTool = false,
|
||||||
|
fullHeightMode = false,
|
||||||
onToggleSourceMode,
|
onToggleSourceMode,
|
||||||
|
onToggleSecondarySourceMode,
|
||||||
onToggleStructureOutlines,
|
onToggleStructureOutlines,
|
||||||
onInsertArtwork,
|
onInsertArtwork,
|
||||||
onInsertImage,
|
onInsertImage,
|
||||||
@@ -937,7 +958,7 @@ function Toolbar({
|
|||||||
editorViewportHeight,
|
editorViewportHeight,
|
||||||
onIncreaseEditorViewportHeight,
|
onIncreaseEditorViewportHeight,
|
||||||
onDecreaseEditorViewportHeight,
|
onDecreaseEditorViewportHeight,
|
||||||
onResetEditorViewportHeight,
|
onToggleFullHeightMode,
|
||||||
}) {
|
}) {
|
||||||
if (!editor) return null
|
if (!editor) return null
|
||||||
|
|
||||||
@@ -1027,9 +1048,14 @@ function Toolbar({
|
|||||||
{advancedNews ? (
|
{advancedNews ? (
|
||||||
<>
|
<>
|
||||||
<Divider />
|
<Divider />
|
||||||
<ToolbarBtn onClick={onToggleSourceMode} active={sourceMode} title="View or edit source HTML" className="w-auto px-2.5">
|
<ToolbarBtn onClick={onToggleSourceMode} active={activeSourceMode === 'primary'} title={sourceModeTitle} className="w-auto px-2.5">
|
||||||
<span className="text-[11px] font-bold uppercase tracking-[0.14em]">HTML</span>
|
<span className="text-[11px] font-bold uppercase tracking-[0.14em]">{sourceModeLabel}</span>
|
||||||
</ToolbarBtn>
|
</ToolbarBtn>
|
||||||
|
{secondarySourceModeLabel ? (
|
||||||
|
<ToolbarBtn onClick={onToggleSecondarySourceMode} active={activeSourceMode === 'secondary'} title={secondarySourceModeTitle} className="w-auto px-2.5">
|
||||||
|
<span className="text-[11px] font-bold uppercase tracking-[0.14em]">{secondarySourceModeLabel}</span>
|
||||||
|
</ToolbarBtn>
|
||||||
|
) : null}
|
||||||
<ToolbarBtn onClick={onToggleStructureOutlines} active={showStructureOutlines} title="Outline blocks (p, div, figure, list)" className="w-auto px-2.5">
|
<ToolbarBtn onClick={onToggleStructureOutlines} active={showStructureOutlines} title="Outline blocks (p, div, figure, list)" className="w-auto px-2.5">
|
||||||
<span className="text-[11px] font-bold uppercase tracking-[0.14em]">DOM</span>
|
<span className="text-[11px] font-bold uppercase tracking-[0.14em]">DOM</span>
|
||||||
</ToolbarBtn>
|
</ToolbarBtn>
|
||||||
@@ -1053,12 +1079,12 @@ function Toolbar({
|
|||||||
<span className="text-[11px] font-bold uppercase tracking-[0.14em]">A-</span>
|
<span className="text-[11px] font-bold uppercase tracking-[0.14em]">A-</span>
|
||||||
</ToolbarBtn>
|
</ToolbarBtn>
|
||||||
<div className="mx-1 flex min-w-[5.25rem] items-center justify-center rounded-lg border border-white/10 bg-white/[0.04] px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400">
|
<div className="mx-1 flex min-w-[5.25rem] items-center justify-center rounded-lg border border-white/10 bg-white/[0.04] px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400">
|
||||||
{editorViewportHeight}rem
|
{formatViewportHeightLabel(editorViewportHeight)}
|
||||||
</div>
|
</div>
|
||||||
<ToolbarBtn onClick={onIncreaseEditorViewportHeight} title="Taller editor" className="w-auto px-2.5">
|
<ToolbarBtn onClick={onIncreaseEditorViewportHeight} title="Taller editor" className="w-auto px-2.5">
|
||||||
<span className="text-[11px] font-bold uppercase tracking-[0.14em]">A+</span>
|
<span className="text-[11px] font-bold uppercase tracking-[0.14em]">A+</span>
|
||||||
</ToolbarBtn>
|
</ToolbarBtn>
|
||||||
<ToolbarBtn onClick={onResetEditorViewportHeight} title="Reset editor height" className="w-auto px-2.5">
|
<ToolbarBtn onClick={onToggleFullHeightMode} active={fullHeightMode} title={fullHeightMode ? 'Exit full height editor' : 'Expand editor to full browser size'} className="w-auto px-2.5">
|
||||||
<span className="text-[11px] font-bold uppercase tracking-[0.14em]">Fit</span>
|
<span className="text-[11px] font-bold uppercase tracking-[0.14em]">Fit</span>
|
||||||
</ToolbarBtn>
|
</ToolbarBtn>
|
||||||
<ToolbarBtn onClick={() => editor.chain().focus().undo().run()} disabled={!editor.can().undo()} title="Undo (Ctrl+Z)">
|
<ToolbarBtn onClick={() => editor.chain().focus().undo().run()} disabled={!editor.can().undo()} title="Undo (Ctrl+Z)">
|
||||||
@@ -1078,16 +1104,26 @@ export default function RichTextEditor({
|
|||||||
placeholder = 'Write something…',
|
placeholder = 'Write something…',
|
||||||
error,
|
error,
|
||||||
minHeight = 12,
|
minHeight = 12,
|
||||||
|
maxHeightRem = 42,
|
||||||
autofocus = false,
|
autofocus = false,
|
||||||
advancedNews = false,
|
advancedNews = false,
|
||||||
|
sourceModeLabel = 'HTML',
|
||||||
|
sourceModeTitle = 'View or edit source HTML',
|
||||||
|
sourceModeDescription = 'Edit the stored article HTML directly. Saving while in this mode keeps the HTML exactly as written here.',
|
||||||
|
secondarySourceModeLabel = null,
|
||||||
|
secondarySourceModeTitle = '',
|
||||||
|
secondarySourceModeDescription = '',
|
||||||
|
secondarySourceModeValue = null,
|
||||||
|
onSecondarySourceModeValueChange = null,
|
||||||
searchEntities = null,
|
searchEntities = null,
|
||||||
mediaSupport = null,
|
mediaSupport = null,
|
||||||
}) {
|
}) {
|
||||||
const viewportStorageKey = 'rich-text-editor.viewport-height'
|
const viewportStorageKey = 'rich-text-editor.viewport-height'
|
||||||
const viewportMinHeight = Math.max(minHeight + 6, 18)
|
const viewportMinHeight = Math.max(minHeight + 6, 18)
|
||||||
const viewportMaxHeight = 42
|
const viewportMaxHeight = Math.max(viewportMinHeight, Number(maxHeightRem) || 42)
|
||||||
const viewportStep = 4
|
const viewportStep = 4
|
||||||
const [sourceMode, setSourceMode] = useState(false)
|
const [activeSourceMode, setActiveSourceMode] = useState(null)
|
||||||
|
const [fullHeightMode, setFullHeightMode] = useState(false)
|
||||||
const [sourceValue, setSourceValue] = useState(String(content || ''))
|
const [sourceValue, setSourceValue] = useState(String(content || ''))
|
||||||
const [showStructureOutlines, setShowStructureOutlines] = useState(false)
|
const [showStructureOutlines, setShowStructureOutlines] = useState(false)
|
||||||
const [helperMessage, setHelperMessage] = useState('')
|
const [helperMessage, setHelperMessage] = useState('')
|
||||||
@@ -1142,6 +1178,8 @@ export default function RichTextEditor({
|
|||||||
return Math.min(viewportMaxHeight, viewportMinHeight)
|
return Math.min(viewportMaxHeight, viewportMinHeight)
|
||||||
})
|
})
|
||||||
const editorRef = useRef(null)
|
const editorRef = useRef(null)
|
||||||
|
const resizeCleanupRef = useRef(null)
|
||||||
|
const usesSecondarySourceMode = typeof onSecondarySourceModeValueChange === 'function' && secondarySourceModeValue != null
|
||||||
const csrfToken = useMemo(() => {
|
const csrfToken = useMemo(() => {
|
||||||
if (typeof document === 'undefined') {
|
if (typeof document === 'undefined') {
|
||||||
return ''
|
return ''
|
||||||
@@ -1491,7 +1529,7 @@ export default function RichTextEditor({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
onUpdate: ({ editor: currentEditor }) => {
|
onUpdate: ({ editor: currentEditor }) => {
|
||||||
if (!sourceMode) {
|
if (!activeSourceMode) {
|
||||||
onChange?.(currentEditor.getHTML())
|
onChange?.(currentEditor.getHTML())
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1520,7 +1558,7 @@ export default function RichTextEditor({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!editor) return
|
if (!editor) return
|
||||||
if (sourceMode) return
|
if (activeSourceMode) return
|
||||||
if ((content || '') === editor.getHTML()) return
|
if ((content || '') === editor.getHTML()) return
|
||||||
|
|
||||||
editor.commands.setContent(content || '', false)
|
editor.commands.setContent(content || '', false)
|
||||||
@@ -1529,13 +1567,13 @@ export default function RichTextEditor({
|
|||||||
if (normalizedHtml !== (content || '')) {
|
if (normalizedHtml !== (content || '')) {
|
||||||
onChange?.(normalizedHtml)
|
onChange?.(normalizedHtml)
|
||||||
}
|
}
|
||||||
}, [content, editor, onChange, sourceMode])
|
}, [activeSourceMode, content, editor, onChange])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (sourceMode) {
|
if (activeSourceMode === 'primary') {
|
||||||
setSourceValue(String(content || editor?.getHTML() || ''))
|
setSourceValue(String(content || editor?.getHTML() || ''))
|
||||||
}
|
}
|
||||||
}, [content, editor, sourceMode])
|
}, [activeSourceMode, content, editor])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
@@ -1544,6 +1582,42 @@ export default function RichTextEditor({
|
|||||||
window.localStorage.setItem(viewportStorageKey, String(editorViewportHeight))
|
window.localStorage.setItem(viewportStorageKey, String(editorViewportHeight))
|
||||||
}, [editorViewportHeight])
|
}, [editorViewportHeight])
|
||||||
|
|
||||||
|
const stopViewportResize = useCallback(() => {
|
||||||
|
if (resizeCleanupRef.current) {
|
||||||
|
resizeCleanupRef.current()
|
||||||
|
resizeCleanupRef.current = null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof document !== 'undefined') {
|
||||||
|
document.body.style.userSelect = ''
|
||||||
|
document.body.style.cursor = ''
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => stopViewportResize, [stopViewportResize])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!fullHeightMode || typeof window === 'undefined') {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousOverflow = document.body.style.overflow
|
||||||
|
|
||||||
|
const handleKeyDown = (event) => {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
setFullHeightMode(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.body.style.overflow = 'hidden'
|
||||||
|
window.addEventListener('keydown', handleKeyDown)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.body.style.overflow = previousOverflow
|
||||||
|
window.removeEventListener('keydown', handleKeyDown)
|
||||||
|
}
|
||||||
|
}, [fullHeightMode])
|
||||||
|
|
||||||
const decreaseEditorViewportHeight = useCallback(() => {
|
const decreaseEditorViewportHeight = useCallback(() => {
|
||||||
setEditorViewportHeight((current) => Math.max(viewportMinHeight, Number((current - viewportStep).toFixed(1))))
|
setEditorViewportHeight((current) => Math.max(viewportMinHeight, Number((current - viewportStep).toFixed(1))))
|
||||||
}, [viewportMinHeight, viewportStep])
|
}, [viewportMinHeight, viewportStep])
|
||||||
@@ -1552,27 +1626,98 @@ export default function RichTextEditor({
|
|||||||
setEditorViewportHeight((current) => Math.min(viewportMaxHeight, Number((current + viewportStep).toFixed(1))))
|
setEditorViewportHeight((current) => Math.min(viewportMaxHeight, Number((current + viewportStep).toFixed(1))))
|
||||||
}, [viewportMaxHeight, viewportStep])
|
}, [viewportMaxHeight, viewportStep])
|
||||||
|
|
||||||
const resetEditorViewportHeight = useCallback(() => {
|
const toggleFullHeightMode = useCallback(() => {
|
||||||
setEditorViewportHeight(Math.min(viewportMaxHeight, viewportMinHeight))
|
setFullHeightMode((current) => !current)
|
||||||
}, [viewportMaxHeight, viewportMinHeight])
|
}, [])
|
||||||
|
|
||||||
|
const startViewportResize = useCallback((event) => {
|
||||||
|
if (fullHeightMode || event.button !== 0 || typeof window === 'undefined') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault()
|
||||||
|
|
||||||
|
const startY = event.clientY
|
||||||
|
const startHeight = editorViewportHeight
|
||||||
|
|
||||||
|
const handlePointerMove = (moveEvent) => {
|
||||||
|
const deltaRem = (moveEvent.clientY - startY) / getRootFontSizePx()
|
||||||
|
const nextHeight = Number((startHeight + deltaRem).toFixed(1))
|
||||||
|
|
||||||
|
setEditorViewportHeight(Math.min(viewportMaxHeight, Math.max(viewportMinHeight, nextHeight)))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePointerUp = () => {
|
||||||
|
stopViewportResize()
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('pointermove', handlePointerMove)
|
||||||
|
window.addEventListener('pointerup', handlePointerUp)
|
||||||
|
window.addEventListener('pointercancel', handlePointerUp)
|
||||||
|
resizeCleanupRef.current = () => {
|
||||||
|
window.removeEventListener('pointermove', handlePointerMove)
|
||||||
|
window.removeEventListener('pointerup', handlePointerUp)
|
||||||
|
window.removeEventListener('pointercancel', handlePointerUp)
|
||||||
|
}
|
||||||
|
|
||||||
|
document.body.style.userSelect = 'none'
|
||||||
|
document.body.style.cursor = 'ns-resize'
|
||||||
|
}, [editorViewportHeight, fullHeightMode, stopViewportResize, viewportMaxHeight, viewportMinHeight])
|
||||||
|
|
||||||
const pushHelperMessage = useCallback((message) => {
|
const pushHelperMessage = useCallback((message) => {
|
||||||
setHelperMessage(message)
|
setHelperMessage(message)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const handleToggleSourceMode = useCallback(() => {
|
const commitPrimarySourceToEditor = useCallback(() => {
|
||||||
if (sourceMode) {
|
|
||||||
setSourceMode(false)
|
|
||||||
if (editor) {
|
if (editor) {
|
||||||
editor.commands.setContent(sourceValue || '', false)
|
editor.commands.setContent(sourceValue || '', false)
|
||||||
}
|
}
|
||||||
|
}, [editor, sourceValue])
|
||||||
|
|
||||||
|
const handleToggleSourceMode = useCallback(() => {
|
||||||
|
if (activeSourceMode === 'primary') {
|
||||||
|
setActiveSourceMode(null)
|
||||||
|
commitPrimarySourceToEditor()
|
||||||
pushHelperMessage('Returned to visual editor.')
|
pushHelperMessage('Returned to visual editor.')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
setSourceValue(editor?.getHTML() || String(content || ''))
|
if (activeSourceMode === 'secondary') {
|
||||||
setSourceMode(true)
|
setActiveSourceMode('primary')
|
||||||
}, [content, editor, pushHelperMessage, sourceMode, sourceValue])
|
setSourceValue(String(content || editor?.getHTML() || ''))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setSourceValue(String(content || editor?.getHTML() || ''))
|
||||||
|
setActiveSourceMode('primary')
|
||||||
|
}, [activeSourceMode, commitPrimarySourceToEditor, content, editor, pushHelperMessage])
|
||||||
|
|
||||||
|
const handleToggleSecondarySourceMode = useCallback(() => {
|
||||||
|
if (!usesSecondarySourceMode) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeSourceMode === 'secondary') {
|
||||||
|
setActiveSourceMode(null)
|
||||||
|
pushHelperMessage('Returned to visual editor.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeSourceMode === 'primary') {
|
||||||
|
commitPrimarySourceToEditor()
|
||||||
|
}
|
||||||
|
|
||||||
|
setActiveSourceMode('secondary')
|
||||||
|
}, [activeSourceMode, commitPrimarySourceToEditor, pushHelperMessage, usesSecondarySourceMode])
|
||||||
|
|
||||||
|
const handleCloseSourceMode = useCallback(() => {
|
||||||
|
if (activeSourceMode === 'primary') {
|
||||||
|
commitPrimarySourceToEditor()
|
||||||
|
}
|
||||||
|
|
||||||
|
setActiveSourceMode(null)
|
||||||
|
pushHelperMessage('Returned to visual editor.')
|
||||||
|
}, [activeSourceMode, commitPrimarySourceToEditor, pushHelperMessage])
|
||||||
|
|
||||||
const insertArtworkEmbed = useCallback((item) => {
|
const insertArtworkEmbed = useCallback((item) => {
|
||||||
if (!editor || !item) return
|
if (!editor || !item) return
|
||||||
@@ -1867,23 +2012,52 @@ export default function RichTextEditor({
|
|||||||
editor.chain().focus().insertContent(`#${value}`).run()
|
editor.chain().focus().insertContent(`#${value}`).run()
|
||||||
}, [editor])
|
}, [editor])
|
||||||
|
|
||||||
return (
|
const shellClassName = fullHeightMode
|
||||||
<div className="flex w-full min-w-0 flex-col gap-1.5">
|
? 'fixed inset-0 z-[980] flex min-h-0 w-screen flex-col bg-[#04070df2] p-3 backdrop-blur-sm md:p-4'
|
||||||
<div
|
: 'flex w-full min-w-0 flex-col gap-1.5'
|
||||||
className={[
|
|
||||||
|
const bodyClassName = fullHeightMode
|
||||||
|
? 'flex min-h-0 w-full flex-1 flex-col gap-1.5'
|
||||||
|
: 'flex w-full min-w-0 flex-col gap-1.5'
|
||||||
|
|
||||||
|
const editorCardClassName = [
|
||||||
'news-rich-text-editor w-full min-w-0 overflow-hidden rounded-xl border bg-white/[0.04] transition-colors',
|
'news-rich-text-editor w-full min-w-0 overflow-hidden rounded-xl border bg-white/[0.04] transition-colors',
|
||||||
|
fullHeightMode ? 'flex min-h-0 flex-1 flex-col rounded-2xl' : '',
|
||||||
error
|
error
|
||||||
? 'border-red-500/60 focus-within:border-red-500/70 focus-within:ring-2 focus-within:ring-red-500/30'
|
? 'border-red-500/60 focus-within:border-red-500/70 focus-within:ring-2 focus-within:ring-red-500/30'
|
||||||
: 'border-white/12 hover:border-white/20 focus-within:border-sky-500/50 focus-within:ring-2 focus-within:ring-sky-500/20',
|
: 'border-white/12 hover:border-white/20 focus-within:border-sky-500/50 focus-within:ring-2 focus-within:ring-sky-500/20',
|
||||||
].join(' ')}
|
].filter(Boolean).join(' ')
|
||||||
|
|
||||||
|
const editorViewportStyle = fullHeightMode
|
||||||
|
? { flex: 1 }
|
||||||
|
: { height: `${editorViewportHeight}rem` }
|
||||||
|
|
||||||
|
const sourceTextareaStyle = fullHeightMode
|
||||||
|
? { flex: 1 }
|
||||||
|
: {
|
||||||
|
height: `${Math.max(minHeight, editorViewportHeight)}rem`,
|
||||||
|
minHeight: `${Math.max(minHeight, 20)}rem`,
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={shellClassName}>
|
||||||
|
<div className={bodyClassName}>
|
||||||
|
<div
|
||||||
|
className={editorCardClassName}
|
||||||
>
|
>
|
||||||
<Toolbar
|
<Toolbar
|
||||||
editor={editor}
|
editor={editor}
|
||||||
advancedNews={advancedNews}
|
advancedNews={advancedNews}
|
||||||
sourceMode={sourceMode}
|
activeSourceMode={activeSourceMode}
|
||||||
|
sourceModeLabel={sourceModeLabel}
|
||||||
|
sourceModeTitle={sourceModeTitle}
|
||||||
|
secondarySourceModeLabel={secondarySourceModeLabel}
|
||||||
|
secondarySourceModeTitle={secondarySourceModeTitle}
|
||||||
showStructureOutlines={showStructureOutlines}
|
showStructureOutlines={showStructureOutlines}
|
||||||
showComparisonTool={Boolean(mediaSupport?.uploadUrl)}
|
showComparisonTool={Boolean(mediaSupport?.uploadUrl)}
|
||||||
|
fullHeightMode={fullHeightMode}
|
||||||
onToggleSourceMode={handleToggleSourceMode}
|
onToggleSourceMode={handleToggleSourceMode}
|
||||||
|
onToggleSecondarySourceMode={handleToggleSecondarySourceMode}
|
||||||
onToggleStructureOutlines={() => setShowStructureOutlines((current) => !current)}
|
onToggleStructureOutlines={() => setShowStructureOutlines((current) => !current)}
|
||||||
onInsertArtwork={handleInsertArtwork}
|
onInsertArtwork={handleInsertArtwork}
|
||||||
onInsertImage={handleInsertImage}
|
onInsertImage={handleInsertImage}
|
||||||
@@ -1895,44 +2069,65 @@ export default function RichTextEditor({
|
|||||||
editorViewportHeight={editorViewportHeight}
|
editorViewportHeight={editorViewportHeight}
|
||||||
onIncreaseEditorViewportHeight={increaseEditorViewportHeight}
|
onIncreaseEditorViewportHeight={increaseEditorViewportHeight}
|
||||||
onDecreaseEditorViewportHeight={decreaseEditorViewportHeight}
|
onDecreaseEditorViewportHeight={decreaseEditorViewportHeight}
|
||||||
onResetEditorViewportHeight={resetEditorViewportHeight}
|
onToggleFullHeightMode={toggleFullHeightMode}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{advancedNews && sourceMode ? (
|
{advancedNews && activeSourceMode ? (
|
||||||
<div className="border-t border-white/[0.04] bg-black/10 px-4 py-3">
|
<div className={[
|
||||||
|
'border-t border-white/[0.04] bg-black/10 px-4 py-3',
|
||||||
|
fullHeightMode ? 'flex min-h-0 flex-1 flex-col' : '',
|
||||||
|
].filter(Boolean).join(' ')}>
|
||||||
<div className="mb-2 flex items-center justify-between gap-3 text-xs text-slate-400">
|
<div className="mb-2 flex items-center justify-between gap-3 text-xs text-slate-400">
|
||||||
<span>Edit the stored article HTML directly. Saving while in this mode keeps the HTML exactly as written here.</span>
|
<span>{activeSourceMode === 'secondary' ? secondarySourceModeDescription : sourceModeDescription}</span>
|
||||||
<button type="button" onClick={handleToggleSourceMode} className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 font-semibold text-white transition hover:bg-white/[0.08]">
|
<button type="button" onClick={handleCloseSourceMode} className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 font-semibold text-white transition hover:bg-white/[0.08]">
|
||||||
Back to visual
|
Back to visual
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<textarea
|
<textarea
|
||||||
value={sourceValue}
|
value={activeSourceMode === 'secondary' ? String(secondarySourceModeValue || '') : sourceValue}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
const nextValue = event.target.value
|
const nextValue = event.target.value
|
||||||
|
if (activeSourceMode === 'secondary') {
|
||||||
|
onSecondarySourceModeValueChange?.(nextValue)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
setSourceValue(nextValue)
|
setSourceValue(nextValue)
|
||||||
onChange?.(nextValue)
|
onChange?.(nextValue)
|
||||||
}}
|
}}
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
className="nova-scrollbar min-h-[20rem] w-full rounded-xl border border-white/10 bg-slate-950/85 px-4 py-3 font-mono text-sm leading-6 text-slate-100 outline-none"
|
className="nova-scrollbar w-full rounded-xl border border-white/10 bg-slate-950/85 px-4 py-3 font-mono text-sm leading-6 text-slate-100 outline-none"
|
||||||
style={{ minHeight: `${Math.max(minHeight, 20)}rem` }}
|
style={sourceTextareaStyle}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className={[
|
<div className={[
|
||||||
'rich-text-editor-viewport nova-scrollbar w-full min-w-0 border-t border-white/[0.04] bg-black/15',
|
'rich-text-editor-viewport nova-scrollbar w-full min-w-0 border-t border-white/[0.04] bg-black/15',
|
||||||
|
fullHeightMode ? 'flex min-h-0 flex-1 flex-col' : '',
|
||||||
advancedNews && showStructureOutlines ? 'news-editor-outline' : '',
|
advancedNews && showStructureOutlines ? 'news-editor-outline' : '',
|
||||||
].filter(Boolean).join(' ')} style={{ maxHeight: `${editorViewportHeight}rem` }}>
|
].filter(Boolean).join(' ')} style={editorViewportStyle}>
|
||||||
<EditorContent editor={editor} />
|
<EditorContent editor={editor} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{!fullHeightMode ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Resize editor height"
|
||||||
|
title="Drag to resize editor height"
|
||||||
|
onPointerDown={startViewportResize}
|
||||||
|
className="group flex h-5 w-full cursor-row-resize items-center justify-center border-t border-white/[0.04] bg-black/10 text-slate-500 transition hover:bg-white/[0.03] hover:text-slate-300"
|
||||||
|
>
|
||||||
|
<span className="h-1 w-16 rounded-full bg-current opacity-70 transition group-hover:opacity-100" />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{advancedNews && helperMessage ? (
|
{advancedNews && helperMessage ? (
|
||||||
<p className="text-xs text-sky-300">{helperMessage}</p>
|
<p className="text-xs text-sky-300">{helperMessage}</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{!sourceMode ? (
|
{!activeSourceMode ? (
|
||||||
<RichTableControls editor={editor} />
|
<RichTableControls editor={editor} />
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@@ -2029,5 +2224,6 @@ export default function RichTextEditor({
|
|||||||
onInsert={handleTableInsert}
|
onInsert={handleTableInsert}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ export default function ThreadRow({ thread, isFirst = false }) {
|
|||||||
const isPinned = thread?.is_pinned ?? false
|
const isPinned = thread?.is_pinned ?? false
|
||||||
|
|
||||||
const href = `/forum/topic/${slug}`
|
const href = `/forum/topic/${slug}`
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<a
|
<a
|
||||||
href={href}
|
href={href}
|
||||||
|
|||||||
@@ -5,5 +5,6 @@
|
|||||||
@endpush
|
@endpush
|
||||||
|
|
||||||
@section('content')
|
@section('content')
|
||||||
|
@include('partials.seo.forum-microdata', ['forumMicrodata' => $forum_microdata ?? null])
|
||||||
@inertia
|
@inertia
|
||||||
@endsection
|
@endsection
|
||||||
|
|||||||
@@ -59,9 +59,76 @@
|
|||||||
'per_page' => $posts->perPage(),
|
'per_page' => $posts->perPage(),
|
||||||
'total' => $posts->total(),
|
'total' => $posts->total(),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
$makeForumAuthor = function ($user) {
|
||||||
|
if (! $user) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$name = trim((string) ($user->name ?? $user->username ?? ''));
|
||||||
|
|
||||||
|
if ($name === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$username = trim((string) ($user->username ?? ''));
|
||||||
|
|
||||||
|
return array_filter([
|
||||||
|
'name' => $name,
|
||||||
|
'url' => $username !== '' ? url('/@' . ltrim($username, '@')) : null,
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Ensure we always provide a top-level author object for structured data.
|
||||||
|
$topAuthor = $makeForumAuthor($author ?? $opPost?->user ?? null);
|
||||||
|
if (! $topAuthor) {
|
||||||
|
$topAuthor = ['name' => (string) ($opPost?->user?->name ?? $thread->user?->name ?? 'Skinbase')];
|
||||||
|
}
|
||||||
|
|
||||||
|
$forumMicrodata = [
|
||||||
|
'kind' => 'topic',
|
||||||
|
'canonical' => route('forum.thread.show', ['thread' => $thread->id, 'slug' => $thread->slug]),
|
||||||
|
'title' => (string) $thread->title,
|
||||||
|
'text' => $threadDescription,
|
||||||
|
'date_published' => $thread->created_at?->toIso8601String(),
|
||||||
|
'date_modified' => ($thread->last_post_at ?? $thread->updated_at)?->toIso8601String(),
|
||||||
|
'comment_count' => (int) ($reply_count ?? 0),
|
||||||
|
'author' => $topAuthor,
|
||||||
|
'comments' => collect([$opPost])
|
||||||
|
->filter()
|
||||||
|
->merge($posts->getCollection())
|
||||||
|
->map(function ($post) use ($makeForumAuthor, $thread) {
|
||||||
|
$rendered = (string) ForumPostContent::render((string) ($post->content ?? ''));
|
||||||
|
$text = trim((string) preg_replace('/\s+/u', ' ', strip_tags(html_entity_decode($rendered, ENT_QUOTES | ENT_HTML5, 'UTF-8'))));
|
||||||
|
|
||||||
|
// If a post has no textual content, provide a short fallback so
|
||||||
|
// the Comment structured data contains one of the required
|
||||||
|
// representations (text/image/video). Prefer a simple label for
|
||||||
|
// attachments or a generic reply placeholder.
|
||||||
|
if ($text === '') {
|
||||||
|
$hasAttachments = ! empty($post->attachments ?? null) && count($post->attachments ?? []) > 0;
|
||||||
|
if ($hasAttachments) {
|
||||||
|
$text = 'Attachment';
|
||||||
|
} else {
|
||||||
|
$text = 'Reply';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_filter([
|
||||||
|
'url' => route('forum.thread.show', ['thread' => $post->thread_id, 'slug' => $thread->slug]) . '#post-' . $post->id,
|
||||||
|
'text' => Str::limit($text, 300),
|
||||||
|
'date_published' => $post->created_at?->toIso8601String(),
|
||||||
|
'date_modified' => ($post->edited_at ?? $post->created_at)?->toIso8601String(),
|
||||||
|
'author' => $makeForumAuthor($post->user ?? null) ?: ['name' => (string) ($post->user?->name ?? 'Skinbase')],
|
||||||
|
], fn ($value) => $value !== null && $value !== '');
|
||||||
|
})
|
||||||
|
->values()
|
||||||
|
->all(),
|
||||||
|
];
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
@section('content')
|
@section('content')
|
||||||
|
@include('partials.seo.forum-microdata', ['forumMicrodata' => $forumMicrodata])
|
||||||
<div id="forum-thread-root"></div>
|
<div id="forum-thread-root"></div>
|
||||||
@php
|
@php
|
||||||
$forumThreadProps = json_encode([
|
$forumThreadProps = json_encode([
|
||||||
@@ -76,7 +143,7 @@
|
|||||||
'created_at' => $thread->created_at?->toIso8601String(),
|
'created_at' => $thread->created_at?->toIso8601String(),
|
||||||
],
|
],
|
||||||
'category' => ['id' => $category->id ?? null, 'name' => $category->name ?? '', 'slug' => $category->slug ?? ''],
|
'category' => ['id' => $category->id ?? null, 'name' => $category->name ?? '', 'slug' => $category->slug ?? ''],
|
||||||
'author' => ['name' => $author->name ?? 'Unknown'],
|
'author' => ['name' => $author->name ?? 'Skinbase'],
|
||||||
'opPost' => $serializedOp,
|
'opPost' => $serializedOp,
|
||||||
'posts' => $serializedPosts,
|
'posts' => $serializedPosts,
|
||||||
'pagination' => $paginationData,
|
'pagination' => $paginationData,
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
<title>{{ $page_title ?? 'Similar Artworks — Skinbase' }}</title>
|
<title>{{ $page_title ?? 'Similar Artworks — Skinbase' }}</title>
|
||||||
<meta name="description" content="{{ $page_meta_description ?? '' }}">
|
<meta name="description" content="{{ $page_meta_description ?? '' }}">
|
||||||
<link rel="canonical" href="{{ $page_canonical ?? url()->current() }}">
|
<link rel="canonical" href="{{ $page_canonical ?? url()->current() }}">
|
||||||
<meta name="robots" content="{{ $page_robots ?? 'noindex,follow' }}">
|
<meta name="robots" content="{{ $page_robots ?? 'index,follow' }}">
|
||||||
|
|
||||||
{{-- OpenGraph --}}
|
{{-- OpenGraph --}}
|
||||||
<meta property="og:type" content="website" />
|
<meta property="og:type" content="website" />
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<span class="sr-only">Skinbase</span>
|
<span class="sr-only">Skinbase</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-wrap gap-x-6 gap-y-2 text-sm text-neutral-400">
|
<div class="flex flex-wrap gap-x-6 gap-y-2 text-sm text-neutral-400" data-nosnippet>
|
||||||
<a class="hover:text-white" href="/contact">Contact / Apply</a>
|
<a class="hover:text-white" href="/contact">Contact / Apply</a>
|
||||||
<a class="hover:text-white" href="/help">Help</a>
|
<a class="hover:text-white" href="/help">Help</a>
|
||||||
<a class="hover:text-white" href="/rss-feeds">RSS Feeds</a>
|
<a class="hover:text-white" href="/rss-feeds">RSS Feeds</a>
|
||||||
@@ -22,6 +22,6 @@
|
|||||||
>Cookie Preferences</button>
|
>Cookie Preferences</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="text-xs text-neutral-400">© 2026 Skinbase.org</div>
|
<div class="text-xs text-neutral-400" data-nosnippet>© 2026 Skinbase.org</div>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
@php
|
@php
|
||||||
$isPreview = (bool) ($previewMode ?? false);
|
$isPreview = (bool) ($previewMode ?? false);
|
||||||
$articleUrl = $isPreview ? ($previewCanonical ?? url()->current()) : route('news.show', $article->slug);
|
$articleUrl = $isPreview ? ($previewCanonical ?? url()->current()) : route('news.show', $article->slug);
|
||||||
|
$articleImageLicenseUrl = route('terms-of-service');
|
||||||
|
$articleImageCreditText = (string) config('seo.site_name', 'Skinbase');
|
||||||
$articleSchemaImage = $article->effective_og_image
|
$articleSchemaImage = $article->effective_og_image
|
||||||
? url($article->effective_og_image)
|
? url($article->effective_og_image)
|
||||||
: url((string) config('seo.fallback_image_path', '/gfx/skinbase_back_001.webp'));
|
: url((string) config('seo.fallback_image_path', '/gfx/skinbase_back_001.webp'));
|
||||||
@@ -24,6 +26,30 @@
|
|||||||
: null,
|
: null,
|
||||||
])->filter()->values(),
|
])->filter()->values(),
|
||||||
])
|
])
|
||||||
|
->addJsonLd($articleSchemaImage
|
||||||
|
? array_filter([
|
||||||
|
'@context' => 'https://schema.org',
|
||||||
|
'@type' => 'ImageObject',
|
||||||
|
'url' => $articleSchemaImage,
|
||||||
|
'contentUrl' => $articleSchemaImage,
|
||||||
|
'thumbnailUrl' => $article->cover_mobile_url,
|
||||||
|
'caption' => $article->title,
|
||||||
|
'creditText' => $articleImageCreditText,
|
||||||
|
'license' => $articleImageLicenseUrl,
|
||||||
|
'acquireLicensePage' => $articleImageLicenseUrl,
|
||||||
|
'creator' => [
|
||||||
|
'@type' => 'Organization',
|
||||||
|
'name' => $articleImageCreditText,
|
||||||
|
'url' => url('/'),
|
||||||
|
],
|
||||||
|
'publisher' => [
|
||||||
|
'@type' => 'Organization',
|
||||||
|
'name' => $articleImageCreditText,
|
||||||
|
'url' => url('/'),
|
||||||
|
],
|
||||||
|
'representativeOfPage' => true,
|
||||||
|
], fn (mixed $value): bool => $value !== null && $value !== '')
|
||||||
|
: null)
|
||||||
->addJsonLd(array_filter([
|
->addJsonLd(array_filter([
|
||||||
'@context' => 'https://schema.org',
|
'@context' => 'https://schema.org',
|
||||||
'@type' => 'NewsArticle',
|
'@type' => 'NewsArticle',
|
||||||
@@ -36,6 +62,9 @@
|
|||||||
'contentUrl' => $articleSchemaImage,
|
'contentUrl' => $articleSchemaImage,
|
||||||
'thumbnailUrl' => $article->cover_mobile_url,
|
'thumbnailUrl' => $article->cover_mobile_url,
|
||||||
'caption' => $article->title,
|
'caption' => $article->title,
|
||||||
|
'creditText' => $articleImageCreditText,
|
||||||
|
'license' => $articleImageLicenseUrl,
|
||||||
|
'acquireLicensePage' => $articleImageLicenseUrl,
|
||||||
], fn (mixed $value): bool => $value !== null && $value !== '')
|
], fn (mixed $value): bool => $value !== null && $value !== '')
|
||||||
: null,
|
: null,
|
||||||
'datePublished' => $article->published_at?->toIso8601String(),
|
'datePublished' => $article->published_at?->toIso8601String(),
|
||||||
@@ -133,7 +162,14 @@
|
|||||||
<article class="min-w-0">
|
<article class="min-w-0">
|
||||||
@if($article->cover_url)
|
@if($article->cover_url)
|
||||||
<div class="overflow-hidden rounded-[32px] border border-white/[0.06] bg-black/20 shadow-[0_24px_60px_rgba(0,0,0,0.24)]">
|
<div class="overflow-hidden rounded-[32px] border border-white/[0.06] bg-black/20 shadow-[0_24px_60px_rgba(0,0,0,0.24)]">
|
||||||
<a href="{{ $articleCoverPreloadHref }}" class="group block focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/70 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-950" aria-label="Open full cover image">
|
<a
|
||||||
|
href="{{ $articleCoverPreloadHref }}"
|
||||||
|
class="group block focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/70 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-950"
|
||||||
|
aria-label="Open full cover image"
|
||||||
|
data-news-image-preview
|
||||||
|
data-news-image-src="{{ $articleCoverPreloadHref }}"
|
||||||
|
data-news-image-alt="{{ $article->title }}"
|
||||||
|
>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<img src="{{ $article->cover_url }}" @if($article->cover_srcset) srcset="{{ $article->cover_srcset }}" sizes="{{ $articleCoverSizes }}" @endif alt="{{ $article->title }}" fetchpriority="high" loading="eager" decoding="async" class="h-auto max-h-[520px] w-full object-cover transition duration-300 group-hover:scale-[1.01]">
|
<img src="{{ $article->cover_url }}" @if($article->cover_srcset) srcset="{{ $article->cover_srcset }}" sizes="{{ $articleCoverSizes }}" @endif alt="{{ $article->title }}" fetchpriority="high" loading="eager" decoding="async" class="h-auto max-h-[520px] w-full object-cover transition duration-300 group-hover:scale-[1.01]">
|
||||||
<div class="pointer-events-none absolute inset-x-4 bottom-4 flex items-center justify-between gap-3 rounded-full border border-white/10 bg-slate-950/72 px-4 py-2 text-xs font-semibold uppercase tracking-[0.18em] text-white/82 backdrop-blur-sm">
|
<div class="pointer-events-none absolute inset-x-4 bottom-4 flex items-center justify-between gap-3 rounded-full border border-white/10 bg-slate-950/72 px-4 py-2 text-xs font-semibold uppercase tracking-[0.18em] text-white/82 backdrop-blur-sm">
|
||||||
@@ -263,6 +299,8 @@
|
|||||||
@endsection
|
@endsection
|
||||||
|
|
||||||
@push('scripts')
|
@push('scripts')
|
||||||
|
@vite(['resources/js/Pages/News/NewsImagePreview.jsx'])
|
||||||
|
|
||||||
@if($needsFacebookEmbeds)
|
@if($needsFacebookEmbeds)
|
||||||
<div id="fb-root"></div>
|
<div id="fb-root"></div>
|
||||||
<script async defer crossorigin="anonymous" src="https://connect.facebook.net/en_US/sdk.js#xfbml=1&version=v19.0"></script>
|
<script async defer crossorigin="anonymous" src="https://connect.facebook.net/en_US/sdk.js#xfbml=1&version=v19.0"></script>
|
||||||
|
|||||||
100
resources/views/partials/seo/forum-microdata.blade.php
Normal file
100
resources/views/partials/seo/forum-microdata.blade.php
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
@php
|
||||||
|
$forumMicrodata = is_array($forumMicrodata ?? null) ? $forumMicrodata : null;
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
@if($forumMicrodata)
|
||||||
|
<div class="hidden" aria-hidden="true">
|
||||||
|
@if(($forumMicrodata['kind'] ?? null) === 'topic')
|
||||||
|
<div itemscope itemtype="https://schema.org/DiscussionForumPosting">
|
||||||
|
@if(!empty($forumMicrodata['canonical']))<meta itemprop="mainEntityOfPage" content="{{ $forumMicrodata['canonical'] }}" />@endif
|
||||||
|
@if(!empty($forumMicrodata['canonical']))<meta itemprop="url" content="{{ $forumMicrodata['canonical'] }}" />@endif
|
||||||
|
@if(!empty($forumMicrodata['title']))<meta itemprop="headline" content="{{ $forumMicrodata['title'] }}" />@endif
|
||||||
|
@if(!empty($forumMicrodata['text']))<meta itemprop="text" content="{{ $forumMicrodata['text'] }}" />@endif
|
||||||
|
@if(!empty($forumMicrodata['date_published']))<meta itemprop="datePublished" content="{{ $forumMicrodata['date_published'] }}" />@endif
|
||||||
|
@if(!empty($forumMicrodata['date_modified']))<meta itemprop="dateModified" content="{{ $forumMicrodata['date_modified'] }}" />@endif
|
||||||
|
@if(isset($forumMicrodata['comment_count']))<meta itemprop="commentCount" content="{{ (int) $forumMicrodata['comment_count'] }}" />@endif
|
||||||
|
|
||||||
|
@if(!empty($forumMicrodata['author']))
|
||||||
|
<div itemprop="author" itemscope itemtype="https://schema.org/Person">
|
||||||
|
@if(!empty($forumMicrodata['author']['url']))<meta itemprop="url" content="{{ $forumMicrodata['author']['url'] }}" />@endif
|
||||||
|
@if(!empty($forumMicrodata['author']['name']))<meta itemprop="name" content="{{ $forumMicrodata['author']['name'] }}" />@endif
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if(!empty($forumMicrodata['board']))
|
||||||
|
<div itemprop="isPartOf" itemscope itemtype="https://schema.org/WebPage">
|
||||||
|
@if(!empty($forumMicrodata['board']['url']))<meta itemprop="url" content="{{ $forumMicrodata['board']['url'] }}" />@endif
|
||||||
|
@if(!empty($forumMicrodata['board']['name']))<meta itemprop="name" content="{{ $forumMicrodata['board']['name'] }}" />@endif
|
||||||
|
@if(!empty($forumMicrodata['board']['category']))
|
||||||
|
<div itemprop="isPartOf" itemscope itemtype="https://schema.org/WebPage">
|
||||||
|
@if(!empty($forumMicrodata['board']['category']['url']))<meta itemprop="url" content="{{ $forumMicrodata['board']['category']['url'] }}" />@endif
|
||||||
|
@if(!empty($forumMicrodata['board']['category']['name']))<meta itemprop="name" content="{{ $forumMicrodata['board']['category']['name'] }}" />@endif
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@foreach(($forumMicrodata['interactions'] ?? []) as $interaction)
|
||||||
|
<div itemprop="interactionStatistic" itemscope itemtype="https://schema.org/InteractionCounter">
|
||||||
|
@if(!empty($interaction['type']))<meta itemprop="interactionType" content="{{ $interaction['type'] }}" />@endif
|
||||||
|
@if(isset($interaction['count']))<meta itemprop="userInteractionCount" content="{{ (int) $interaction['count'] }}" />@endif
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
|
||||||
|
@foreach(($forumMicrodata['comments'] ?? []) as $comment)
|
||||||
|
<div itemprop="comment" itemscope itemtype="https://schema.org/Comment">
|
||||||
|
@if(!empty($comment['url']))<meta itemprop="url" content="{{ $comment['url'] }}" />@endif
|
||||||
|
@if(!empty($comment['text']))<meta itemprop="text" content="{{ $comment['text'] }}" />@endif
|
||||||
|
@if(!empty($comment['date_published']))<meta itemprop="datePublished" content="{{ $comment['date_published'] }}" />@endif
|
||||||
|
@if(!empty($comment['date_modified']))<meta itemprop="dateModified" content="{{ $comment['date_modified'] }}" />@endif
|
||||||
|
|
||||||
|
@if(!empty($comment['author']))
|
||||||
|
<div itemprop="author" itemscope itemtype="https://schema.org/Person">
|
||||||
|
@if(!empty($comment['author']['url']))<meta itemprop="url" content="{{ $comment['author']['url'] }}" />@endif
|
||||||
|
@if(!empty($comment['author']['name']))<meta itemprop="name" content="{{ $comment['author']['name'] }}" />@endif
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@foreach(($comment['interactions'] ?? []) as $interaction)
|
||||||
|
<div itemprop="interactionStatistic" itemscope itemtype="https://schema.org/InteractionCounter">
|
||||||
|
@if(!empty($interaction['type']))<meta itemprop="interactionType" content="{{ $interaction['type'] }}" />@endif
|
||||||
|
@if(isset($interaction['count']))<meta itemprop="userInteractionCount" content="{{ (int) $interaction['count'] }}" />@endif
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
@elseif(($forumMicrodata['kind'] ?? null) === 'collection')
|
||||||
|
<div itemscope itemtype="https://schema.org/CollectionPage">
|
||||||
|
@if(!empty($forumMicrodata['canonical']))<meta itemprop="url" content="{{ $forumMicrodata['canonical'] }}" />@endif
|
||||||
|
@if(!empty($forumMicrodata['name']))<meta itemprop="name" content="{{ $forumMicrodata['name'] }}" />@endif
|
||||||
|
@if(!empty($forumMicrodata['description']))<meta itemprop="description" content="{{ $forumMicrodata['description'] }}" />@endif
|
||||||
|
|
||||||
|
<div itemprop="mainEntity" itemscope itemtype="https://schema.org/ItemList">
|
||||||
|
@if(!empty($forumMicrodata['list_name']))<meta itemprop="name" content="{{ $forumMicrodata['list_name'] }}" />@endif
|
||||||
|
|
||||||
|
@foreach(($forumMicrodata['items'] ?? []) as $index => $item)
|
||||||
|
<div itemprop="itemListElement" itemscope itemtype="https://schema.org/ListItem">
|
||||||
|
<meta itemprop="position" content="{{ $index + 1 }}" />
|
||||||
|
<div itemprop="item" itemscope itemtype="https://schema.org/{{ $item['type'] ?? 'WebPage' }}">
|
||||||
|
@if(!empty($item['url']))<meta itemprop="url" content="{{ $item['url'] }}" />@endif
|
||||||
|
@if(!empty($item['title']))<meta itemprop="{{ ($item['type'] ?? null) === 'DiscussionForumPosting' ? 'headline' : 'name' }}" content="{{ $item['title'] }}" />@endif
|
||||||
|
@if(!empty($item['description']))<meta itemprop="description" content="{{ $item['description'] }}" />@endif
|
||||||
|
@if(!empty($item['text']))<meta itemprop="text" content="{{ $item['text'] }}" />@endif
|
||||||
|
@if(!empty($item['date_published']))<meta itemprop="datePublished" content="{{ $item['date_published'] }}" />@endif
|
||||||
|
@if(isset($item['comment_count']))<meta itemprop="commentCount" content="{{ (int) $item['comment_count'] }}" />@endif
|
||||||
|
@if(!empty($item['date_modified']))<meta itemprop="dateModified" content="{{ $item['date_modified'] }}" />@endif
|
||||||
|
@if(!empty($item['author']))
|
||||||
|
<div itemprop="author" itemscope itemtype="https://schema.org/Person">
|
||||||
|
@if(!empty($item['author']['url']))<meta itemprop="url" content="{{ $item['author']['url'] }}" />@endif
|
||||||
|
@if(!empty($item['author']['name']))<meta itemprop="name" content="{{ $item['author']['name'] }}" />@endif
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
@@ -8,11 +8,14 @@
|
|||||||
<section class="relative flex min-h-[62vh] max-h-[420px] w-full items-end overflow-hidden bg-nova-900 md:min-h-[38vh] md:max-h-[460px]">
|
<section class="relative flex min-h-[62vh] max-h-[420px] w-full items-end overflow-hidden bg-nova-900 md:min-h-[38vh] md:max-h-[460px]">
|
||||||
<div class="pointer-events-none absolute inset-0 bg-gradient-to-t from-nova-900 via-nova-900/60 to-transparent"></div>
|
<div class="pointer-events-none absolute inset-0 bg-gradient-to-t from-nova-900 via-nova-900/60 to-transparent"></div>
|
||||||
<div class="relative z-10 w-full px-6 pb-7 sm:px-10 lg:px-16">
|
<div class="relative z-10 w-full px-6 pb-7 sm:px-10 lg:px-16">
|
||||||
|
<p class="mb-1.5 text-xs font-semibold uppercase tracking-widest text-accent">
|
||||||
|
Skinbase
|
||||||
|
</p>
|
||||||
<h1 class="text-2xl font-bold tracking-tight text-white sm:text-4xl">
|
<h1 class="text-2xl font-bold tracking-tight text-white sm:text-4xl">
|
||||||
Skinbase
|
Skinbase
|
||||||
</h1>
|
</h1>
|
||||||
<p class="mt-2 max-w-xl text-sm text-soft">
|
<p class="mt-2 max-w-2xl text-sm text-soft sm:text-base">
|
||||||
Discover. Create. Inspire.
|
Discover digital art, wallpapers, skins, and photography from a global creator community. Explore trending work, fresh uploads, and long-time classics in one place.
|
||||||
</p>
|
</p>
|
||||||
<div class="mt-4 flex flex-wrap gap-3">
|
<div class="mt-4 flex flex-wrap gap-3">
|
||||||
<a href="/discover/trending" class="btn-accent-solid rounded-xl px-5 py-2 text-sm font-semibold">Explore Trending</a>
|
<a href="/discover/trending" class="btn-accent-solid rounded-xl px-5 py-2 text-sm font-semibold">Explore Trending</a>
|
||||||
@@ -39,15 +42,18 @@
|
|||||||
|
|
||||||
<div class="relative z-10 w-full px-6 pb-7 sm:px-10 lg:px-16">
|
<div class="relative z-10 w-full px-6 pb-7 sm:px-10 lg:px-16">
|
||||||
<p class="mb-1.5 text-xs font-semibold uppercase tracking-widest text-accent">
|
<p class="mb-1.5 text-xs font-semibold uppercase tracking-widest text-accent">
|
||||||
Featured Artwork
|
Skinbase
|
||||||
</p>
|
</p>
|
||||||
<h1 class="text-2xl font-bold tracking-tight text-white drop-shadow sm:text-4xl lg:text-5xl">
|
<h1 class="text-2xl font-bold tracking-tight text-white drop-shadow sm:text-4xl lg:text-5xl">
|
||||||
{{ $heroArtwork['title'] ?? 'Untitled' }}
|
Digital Art & Wallpapers
|
||||||
</h1>
|
</h1>
|
||||||
<p class="mt-1.5 text-sm text-soft">
|
<p class="mt-2 max-w-2xl text-sm text-soft sm:text-base">
|
||||||
by
|
Discover digital art, wallpapers, skins, and photography from a global creator community. Browse trending work, fresh uploads, and timeless favorites.
|
||||||
|
</p>
|
||||||
|
<p class="mt-3 text-sm text-soft">
|
||||||
|
Featured artwork:
|
||||||
<a href="{{ $heroArtwork['url'] ?? '#' }}" class="text-nova-200 transition hover:text-white">
|
<a href="{{ $heroArtwork['url'] ?? '#' }}" class="text-nova-200 transition hover:text-white">
|
||||||
{{ $heroArtwork['author'] ?? 'Artist' }}
|
{{ $heroArtwork['title'] ?? 'Untitled' }} by {{ $heroArtwork['author'] ?? 'Artist' }}
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
<div class="mt-4 flex flex-wrap gap-3">
|
<div class="mt-4 flex flex-wrap gap-3">
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
use App\Models\Artwork;
|
||||||
use App\Http\Controllers\Legacy\AvatarController;
|
use App\Http\Controllers\Legacy\AvatarController;
|
||||||
use App\Http\Controllers\Legacy\LegacyArtworkPhotoController;
|
use App\Http\Controllers\Legacy\LegacyArtworkPhotoController;
|
||||||
use App\Http\Controllers\Legacy\CategoryRedirectController;
|
use App\Http\Controllers\Legacy\CategoryRedirectController;
|
||||||
@@ -32,6 +33,21 @@ Route::get('/photo/{encoded}_{size}.{extension}', LegacyArtworkPhotoController::
|
|||||||
|
|
||||||
// ── ARTWORK (legacy comment URL) ──────────────────────────────────────────────
|
// ── ARTWORK (legacy comment URL) ──────────────────────────────────────────────
|
||||||
//Route::match(['get','post'], '/art/{id}/comment', [ArtController::class, 'show'])->where('id', '\d+');
|
//Route::match(['get','post'], '/art/{id}/comment', [ArtController::class, 'show'])->where('id', '\d+');
|
||||||
|
Route::get('/rate.php', function () {
|
||||||
|
$artworkId = (int) request()->integer('skins');
|
||||||
|
|
||||||
|
abort_if($artworkId <= 0, 404);
|
||||||
|
|
||||||
|
$artwork = Artwork::query()
|
||||||
|
->catalogVisible()
|
||||||
|
->select(['id', 'slug'])
|
||||||
|
->findOrFail($artworkId);
|
||||||
|
|
||||||
|
return redirect()->route('art.show', [
|
||||||
|
'id' => $artwork->id,
|
||||||
|
'slug' => $artwork->slug,
|
||||||
|
], 301);
|
||||||
|
})->name('legacy.rate.php');
|
||||||
|
|
||||||
// ── CATEGORIES / SECTIONS ─────────────────────────────────────────────────────
|
// ── CATEGORIES / SECTIONS ─────────────────────────────────────────────────────
|
||||||
Route::redirect('/sections', '/categories', 301)->name('sections');
|
Route::redirect('/sections', '/categories', 301)->name('sections');
|
||||||
|
|||||||
@@ -45,6 +45,9 @@ use App\Http\Controllers\DashboardController;
|
|||||||
use App\Http\Controllers\Community\LatestController;
|
use App\Http\Controllers\Community\LatestController;
|
||||||
use App\Http\Controllers\Academy\AcademyChallengeController;
|
use App\Http\Controllers\Academy\AcademyChallengeController;
|
||||||
use App\Http\Controllers\Academy\AcademyChallengeSubmissionController;
|
use App\Http\Controllers\Academy\AcademyChallengeSubmissionController;
|
||||||
|
use App\Http\Controllers\Academy\AcademyCourseController;
|
||||||
|
use App\Http\Controllers\Academy\AcademyCourseEnrollmentController;
|
||||||
|
use App\Http\Controllers\Academy\AcademyCourseLessonController;
|
||||||
use App\Http\Controllers\Academy\AcademyHomeController;
|
use App\Http\Controllers\Academy\AcademyHomeController;
|
||||||
use App\Http\Controllers\Academy\AcademyLessonController;
|
use App\Http\Controllers\Academy\AcademyLessonController;
|
||||||
use App\Http\Controllers\Academy\AcademyProgressController;
|
use App\Http\Controllers\Academy\AcademyProgressController;
|
||||||
@@ -55,6 +58,7 @@ use App\Http\Controllers\Academy\AcademyCheckoutController;
|
|||||||
use App\Http\Controllers\Academy\AcademyPricingController;
|
use App\Http\Controllers\Academy\AcademyPricingController;
|
||||||
use App\Http\Controllers\User\MembersController;
|
use App\Http\Controllers\User\MembersController;
|
||||||
use App\Http\Controllers\Settings\AcademyAdminController;
|
use App\Http\Controllers\Settings\AcademyAdminController;
|
||||||
|
use App\Http\Controllers\Settings\AcademyCourseBuilderController;
|
||||||
use App\Http\Controllers\User\TodayDownloadsController;
|
use App\Http\Controllers\User\TodayDownloadsController;
|
||||||
use App\Http\Controllers\User\MonthlyCommentatorsController;
|
use App\Http\Controllers\User\MonthlyCommentatorsController;
|
||||||
use App\Http\Controllers\User\ProfileCollectionController;
|
use App\Http\Controllers\User\ProfileCollectionController;
|
||||||
@@ -144,6 +148,9 @@ Route::get('/academy', [AcademyHomeController::class, 'index'])->name('academy.i
|
|||||||
Route::get('/academy/pricing', [AcademyPricingController::class, 'index'])->name('academy.pricing');
|
Route::get('/academy/pricing', [AcademyPricingController::class, 'index'])->name('academy.pricing');
|
||||||
|
|
||||||
Route::prefix('academy')->name('academy.')->group(function () {
|
Route::prefix('academy')->name('academy.')->group(function () {
|
||||||
|
Route::get('/courses', [AcademyCourseController::class, 'index'])->name('courses.index');
|
||||||
|
Route::get('/courses/{course:slug}', [AcademyCourseController::class, 'show'])->name('courses.show');
|
||||||
|
Route::get('/courses/{course:slug}/lessons/{lesson:slug}', [AcademyCourseLessonController::class, 'show'])->name('courses.lessons.show');
|
||||||
Route::get('/lessons', [AcademyLessonController::class, 'index'])->name('lessons.index');
|
Route::get('/lessons', [AcademyLessonController::class, 'index'])->name('lessons.index');
|
||||||
Route::get('/lessons/{slug}', [AcademyLessonController::class, 'show'])->name('lessons.show');
|
Route::get('/lessons/{slug}', [AcademyLessonController::class, 'show'])->name('lessons.show');
|
||||||
|
|
||||||
@@ -157,6 +164,7 @@ Route::prefix('academy')->name('academy.')->group(function () {
|
|||||||
Route::get('/challenges/{slug}', [AcademyChallengeController::class, 'show'])->name('challenges.show');
|
Route::get('/challenges/{slug}', [AcademyChallengeController::class, 'show'])->name('challenges.show');
|
||||||
|
|
||||||
Route::middleware(['auth'])->group(function () {
|
Route::middleware(['auth'])->group(function () {
|
||||||
|
Route::post('/courses/{course:slug}/start', [AcademyCourseEnrollmentController::class, 'start'])->name('courses.start');
|
||||||
Route::post('/lessons/{lesson}/complete', [AcademyProgressController::class, 'complete'])->name('lessons.complete');
|
Route::post('/lessons/{lesson}/complete', [AcademyProgressController::class, 'complete'])->name('lessons.complete');
|
||||||
Route::post('/prompts/{prompt}/save', [AcademyPromptSaveController::class, 'store'])->name('prompts.save');
|
Route::post('/prompts/{prompt}/save', [AcademyPromptSaveController::class, 'store'])->name('prompts.save');
|
||||||
Route::delete('/prompts/{prompt}/save', [AcademyPromptSaveController::class, 'destroy'])->name('prompts.unsave');
|
Route::delete('/prompts/{prompt}/save', [AcademyPromptSaveController::class, 'destroy'])->name('prompts.unsave');
|
||||||
@@ -1086,6 +1094,23 @@ Route::middleware(['auth', 'admin.access'])
|
|||||||
Route::redirect('/', '/moderation/academy/dashboard')->name('root');
|
Route::redirect('/', '/moderation/academy/dashboard')->name('root');
|
||||||
Route::get('/dashboard', [AcademyAdminController::class, 'dashboard'])->name('dashboard');
|
Route::get('/dashboard', [AcademyAdminController::class, 'dashboard'])->name('dashboard');
|
||||||
|
|
||||||
|
Route::prefix('courses')->name('courses.')->group(function () {
|
||||||
|
Route::get('/', [AcademyAdminController::class, 'coursesIndex'])->name('index');
|
||||||
|
Route::get('/create', [AcademyAdminController::class, 'coursesCreate'])->name('create');
|
||||||
|
Route::post('/', [AcademyAdminController::class, 'coursesStore'])->name('store');
|
||||||
|
Route::get('/{academyCourse}/edit', [AcademyAdminController::class, 'coursesEdit'])->whereNumber('academyCourse')->name('edit');
|
||||||
|
Route::match(['put', 'patch'], '/{academyCourse}', [AcademyAdminController::class, 'coursesUpdate'])->whereNumber('academyCourse')->name('update');
|
||||||
|
Route::delete('/{academyCourse}', [AcademyAdminController::class, 'coursesDestroy'])->whereNumber('academyCourse')->name('destroy');
|
||||||
|
Route::get('/{academyCourse}/builder', [AcademyCourseBuilderController::class, 'edit'])->whereNumber('academyCourse')->name('builder.edit');
|
||||||
|
Route::post('/{academyCourse}/sections', [AcademyCourseBuilderController::class, 'storeSection'])->whereNumber('academyCourse')->name('sections.store');
|
||||||
|
Route::patch('/{academyCourse}/sections/{academyCourseSection}', [AcademyCourseBuilderController::class, 'updateSection'])->whereNumber('academyCourse')->whereNumber('academyCourseSection')->name('sections.update');
|
||||||
|
Route::delete('/{academyCourse}/sections/{academyCourseSection}', [AcademyCourseBuilderController::class, 'destroySection'])->whereNumber('academyCourse')->whereNumber('academyCourseSection')->name('sections.destroy');
|
||||||
|
Route::post('/{academyCourse}/lessons', [AcademyCourseBuilderController::class, 'attachLesson'])->whereNumber('academyCourse')->name('lessons.attach');
|
||||||
|
Route::patch('/{academyCourse}/lessons/{academyCourseLesson}', [AcademyCourseBuilderController::class, 'updateCourseLesson'])->whereNumber('academyCourse')->whereNumber('academyCourseLesson')->name('lessons.update');
|
||||||
|
Route::delete('/{academyCourse}/lessons/{academyCourseLesson}', [AcademyCourseBuilderController::class, 'detachLesson'])->whereNumber('academyCourse')->whereNumber('academyCourseLesson')->name('lessons.destroy');
|
||||||
|
Route::patch('/{academyCourse}/reorder', [AcademyCourseBuilderController::class, 'reorder'])->whereNumber('academyCourse')->name('reorder');
|
||||||
|
});
|
||||||
|
|
||||||
Route::prefix('categories')->name('categories.')->group(function () {
|
Route::prefix('categories')->name('categories.')->group(function () {
|
||||||
Route::get('/', [AcademyAdminController::class, 'categoriesIndex'])->name('index');
|
Route::get('/', [AcademyAdminController::class, 'categoriesIndex'])->name('index');
|
||||||
Route::get('/create', [AcademyAdminController::class, 'categoriesCreate'])->name('create');
|
Route::get('/create', [AcademyAdminController::class, 'categoriesCreate'])->name('create');
|
||||||
@@ -1101,6 +1126,7 @@ Route::middleware(['auth', 'admin.access'])
|
|||||||
Route::post('/', [AcademyAdminController::class, 'lessonsStore'])->name('store');
|
Route::post('/', [AcademyAdminController::class, 'lessonsStore'])->name('store');
|
||||||
Route::get('/{academyLesson}/edit', [AcademyAdminController::class, 'lessonsEdit'])->whereNumber('academyLesson')->name('edit');
|
Route::get('/{academyLesson}/edit', [AcademyAdminController::class, 'lessonsEdit'])->whereNumber('academyLesson')->name('edit');
|
||||||
Route::match(['put', 'patch'], '/{academyLesson}', [AcademyAdminController::class, 'lessonsUpdate'])->whereNumber('academyLesson')->name('update');
|
Route::match(['put', 'patch'], '/{academyLesson}', [AcademyAdminController::class, 'lessonsUpdate'])->whereNumber('academyLesson')->name('update');
|
||||||
|
Route::post('/{academyLesson}/revisions/{academyLessonRevision}/restore', [AcademyAdminController::class, 'lessonsRestoreRevision'])->whereNumber('academyLesson')->whereNumber('academyLessonRevision')->name('revisions.restore');
|
||||||
Route::delete('/{academyLesson}', [AcademyAdminController::class, 'lessonsDestroy'])->whereNumber('academyLesson')->name('destroy');
|
Route::delete('/{academyLesson}', [AcademyAdminController::class, 'lessonsDestroy'])->whereNumber('academyLesson')->name('destroy');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Models\AcademyCourse;
|
||||||
|
use App\Models\AcademyCourseLesson;
|
||||||
|
use App\Models\AcademyCourseSection;
|
||||||
|
use App\Models\AcademyLesson;
|
||||||
|
|
||||||
|
it('syncs the foundations course idempotently and skips missing lessons', function (): void {
|
||||||
|
AcademyLesson::query()->create([
|
||||||
|
'title' => 'What Is AI-Assisted Digital Art',
|
||||||
|
'slug' => 'what-is-ai-assisted-digital-art',
|
||||||
|
'content' => 'Intro lesson body',
|
||||||
|
'difficulty' => 'beginner',
|
||||||
|
'access_level' => 'free',
|
||||||
|
'lesson_type' => 'article',
|
||||||
|
'active' => true,
|
||||||
|
'published_at' => now()->subMinute(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
AcademyLesson::query()->create([
|
||||||
|
'title' => 'Prompting Basics For Skinbase Creators',
|
||||||
|
'slug' => 'prompting-basics-for-skinbase-creators',
|
||||||
|
'content' => 'Prompt basics body',
|
||||||
|
'difficulty' => 'beginner',
|
||||||
|
'access_level' => 'free',
|
||||||
|
'lesson_type' => 'article',
|
||||||
|
'active' => true,
|
||||||
|
'published_at' => now()->subMinute(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->artisan('academy:courses:sync-foundations')
|
||||||
|
->expectsOutputToContain('Skipped missing lesson [ai-ethics-and-skinbase-upload-rules].')
|
||||||
|
->expectsOutput('AI-Assisted Digital Art Foundations course synced.')
|
||||||
|
->assertSuccessful();
|
||||||
|
|
||||||
|
$course = AcademyCourse::query()->where('slug', 'ai-assisted-digital-art-foundations')->first();
|
||||||
|
|
||||||
|
expect($course)->not->toBeNull()
|
||||||
|
->and($course?->status)->toBe('published')
|
||||||
|
->and($course?->is_featured)->toBeTrue();
|
||||||
|
|
||||||
|
expect(AcademyCourseSection::query()->where('course_id', $course->id)->count())->toBe(4);
|
||||||
|
expect(AcademyCourseLesson::query()->where('course_id', $course->id)->count())->toBe(2);
|
||||||
|
expect($course->fresh()->lessons_count_cache)->toBe(2);
|
||||||
|
|
||||||
|
$this->artisan('academy:courses:sync-foundations')->assertSuccessful();
|
||||||
|
|
||||||
|
expect(AcademyCourse::query()->where('slug', 'ai-assisted-digital-art-foundations')->count())->toBe(1);
|
||||||
|
expect(AcademyCourseSection::query()->where('course_id', $course->id)->count())->toBe(4);
|
||||||
|
expect(AcademyCourseLesson::query()->where('course_id', $course->id)->count())->toBe(2);
|
||||||
|
});
|
||||||
@@ -8,13 +8,19 @@ use App\Http\Middleware\ConditionalValidateCsrfToken;
|
|||||||
use App\Http\Middleware\HandleInertiaRequests;
|
use App\Http\Middleware\HandleInertiaRequests;
|
||||||
use App\Models\AcademyAiComparisonResult;
|
use App\Models\AcademyAiComparisonResult;
|
||||||
use App\Models\AcademyChallenge;
|
use App\Models\AcademyChallenge;
|
||||||
|
use App\Models\AcademyCourse;
|
||||||
|
use App\Models\AcademyCourseEnrollment;
|
||||||
|
use App\Models\AcademyCourseLesson;
|
||||||
|
use App\Models\AcademyCourseSection;
|
||||||
use App\Models\AcademyLesson;
|
use App\Models\AcademyLesson;
|
||||||
use App\Models\AcademyLessonBlock;
|
use App\Models\AcademyLessonBlock;
|
||||||
|
use App\Models\AcademyLessonProgress;
|
||||||
use App\Models\AcademyPromptTemplate;
|
use App\Models\AcademyPromptTemplate;
|
||||||
use App\Models\Artwork;
|
use App\Models\Artwork;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Inertia\Testing\AssertableInertia;
|
use Inertia\Testing\AssertableInertia;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
@@ -45,6 +51,367 @@ final class AcademyFeatureTest extends TestCase
|
|||||||
|
|
||||||
$this->get('/academy')->assertNotFound();
|
$this->get('/academy')->assertNotFound();
|
||||||
$this->get('/academy/lessons')->assertNotFound();
|
$this->get('/academy/lessons')->assertNotFound();
|
||||||
|
$this->get('/academy/courses')->assertNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_published_course_index_and_show_render(): void
|
||||||
|
{
|
||||||
|
$course = AcademyCourse::query()->create([
|
||||||
|
'title' => 'AI Foundations',
|
||||||
|
'slug' => 'ai-foundations',
|
||||||
|
'excerpt' => 'A guided course.',
|
||||||
|
'description' => 'Course description',
|
||||||
|
'cover_image' => 'academy/lessons/covers/course-cover.webp',
|
||||||
|
'teaser_image' => 'academy/lessons/covers/course-teaser.webp',
|
||||||
|
'access_level' => 'free',
|
||||||
|
'difficulty' => 'beginner',
|
||||||
|
'status' => 'published',
|
||||||
|
'is_featured' => true,
|
||||||
|
'published_at' => now()->subMinute(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$section = AcademyCourseSection::query()->create([
|
||||||
|
'course_id' => $course->id,
|
||||||
|
'title' => 'Getting started',
|
||||||
|
'slug' => 'getting-started',
|
||||||
|
'order_num' => 0,
|
||||||
|
'is_visible' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$lesson = AcademyLesson::query()->create([
|
||||||
|
'title' => 'Course Lesson',
|
||||||
|
'slug' => 'course-lesson',
|
||||||
|
'excerpt' => 'Lesson in a course.',
|
||||||
|
'content' => 'Course lesson content',
|
||||||
|
'cover_image' => 'academy/lessons/covers/lesson-cover.webp',
|
||||||
|
'difficulty' => 'beginner',
|
||||||
|
'access_level' => 'free',
|
||||||
|
'lesson_type' => 'article',
|
||||||
|
'active' => true,
|
||||||
|
'published_at' => now()->subMinute(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
AcademyCourseLesson::query()->create([
|
||||||
|
'course_id' => $course->id,
|
||||||
|
'section_id' => $section->id,
|
||||||
|
'lesson_id' => $lesson->id,
|
||||||
|
'order_num' => 0,
|
||||||
|
'is_required' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->get(route('academy.courses.index'))
|
||||||
|
->assertOk()
|
||||||
|
->assertInertia(fn (AssertableInertia $page) => $page
|
||||||
|
->component('Academy/CoursesIndex')
|
||||||
|
->where('items.data.0.slug', 'ai-foundations')
|
||||||
|
->where('seo.json_ld.0.@type', 'CollectionPage')
|
||||||
|
->where('seo.json_ld.0.mainEntity.@type', 'ItemList')
|
||||||
|
->where('seo.json_ld.0.mainEntity.itemListElement.0.item.@type', 'Course')
|
||||||
|
->where('seo.json_ld.0.mainEntity.itemListElement.0.item.name', 'AI Foundations')
|
||||||
|
->where('seo.json_ld.1.@type', 'BreadcrumbList'));
|
||||||
|
|
||||||
|
$this->get(route('academy.courses.show', ['course' => $course->slug]))
|
||||||
|
->assertOk()
|
||||||
|
->assertInertia(fn (AssertableInertia $page) => $page
|
||||||
|
->component('Academy/CoursesShow')
|
||||||
|
->where('course.slug', 'ai-foundations')
|
||||||
|
->where('course.cover_image', 'academy/lessons/covers/course-cover.webp')
|
||||||
|
->where('course.teaser_image', 'academy/lessons/covers/course-teaser.webp')
|
||||||
|
->where('seo.json_ld.0.@type', 'ImageObject')
|
||||||
|
->where('seo.json_ld.0.license', route('terms-of-service'))
|
||||||
|
->where('seo.json_ld.1.@type', 'Course')
|
||||||
|
->where('seo.json_ld.1.name', 'AI Foundations — Skinbase Academy')
|
||||||
|
->where('seo.json_ld.1.isAccessibleForFree', true)
|
||||||
|
->where('seo.json_ld.1.educationalLevel', 'Beginner')
|
||||||
|
->where('seo.json_ld.1.hasCourseInstance.0.name', 'Course Lesson')
|
||||||
|
->where('seo.json_ld.2.@type', 'BreadcrumbList')
|
||||||
|
->where('seo.json_ld.2.itemListElement.1.name', 'Academy')
|
||||||
|
->where('seo.json_ld.2.itemListElement.2.name', 'Courses')
|
||||||
|
->where('sections.0.slug', 'getting-started')
|
||||||
|
->where('sections.0.lessons.0.slug', 'course-lesson')
|
||||||
|
->where('sections.0.lessons.0.order_num', 0)
|
||||||
|
->where('sections.0.lessons.0.cover_image', 'academy/lessons/covers/lesson-cover.webp'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_course_start_redirects_to_first_lesson_and_creates_enrollment(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$course = AcademyCourse::query()->create([
|
||||||
|
'title' => 'Workflow Course',
|
||||||
|
'slug' => 'workflow-course',
|
||||||
|
'excerpt' => 'A workflow course.',
|
||||||
|
'access_level' => 'free',
|
||||||
|
'difficulty' => 'beginner',
|
||||||
|
'status' => 'published',
|
||||||
|
'published_at' => now()->subMinute(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$lesson = AcademyLesson::query()->create([
|
||||||
|
'title' => 'First Workflow Lesson',
|
||||||
|
'slug' => 'first-workflow-lesson',
|
||||||
|
'content' => 'Start here',
|
||||||
|
'difficulty' => 'beginner',
|
||||||
|
'access_level' => 'free',
|
||||||
|
'lesson_type' => 'article',
|
||||||
|
'active' => true,
|
||||||
|
'published_at' => now()->subMinute(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
AcademyCourseLesson::query()->create([
|
||||||
|
'course_id' => $course->id,
|
||||||
|
'lesson_id' => $lesson->id,
|
||||||
|
'order_num' => 0,
|
||||||
|
'is_required' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->post(route('academy.courses.start', ['course' => $course->slug]))
|
||||||
|
->assertRedirect(route('academy.courses.lessons.show', ['course' => $course->slug, 'lesson' => $lesson->slug]));
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('academy_course_enrollments', [
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'course_id' => $course->id,
|
||||||
|
'status' => AcademyCourseEnrollment::STATUS_ACTIVE,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->get(route('academy.courses.lessons.show', ['course' => $course->slug, 'lesson' => $lesson->slug]))
|
||||||
|
->assertOk()
|
||||||
|
->assertInertia(fn (AssertableInertia $page) => $page
|
||||||
|
->component('Academy/Show')
|
||||||
|
->where('courseContext.slug', 'workflow-course')
|
||||||
|
->where('courseContext.completePayload.course_id', $course->id));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_course_show_exposes_consistent_global_step_numbers_for_outline(): void
|
||||||
|
{
|
||||||
|
$course = AcademyCourse::query()->create([
|
||||||
|
'title' => 'Ordered Course',
|
||||||
|
'slug' => 'ordered-course',
|
||||||
|
'excerpt' => 'Check course step order.',
|
||||||
|
'access_level' => 'free',
|
||||||
|
'difficulty' => 'beginner',
|
||||||
|
'status' => 'published',
|
||||||
|
'published_at' => now()->subMinute(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$section = AcademyCourseSection::query()->create([
|
||||||
|
'course_id' => $course->id,
|
||||||
|
'title' => 'Module One',
|
||||||
|
'slug' => 'module-one',
|
||||||
|
'order_num' => 0,
|
||||||
|
'is_visible' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$introLesson = AcademyLesson::query()->create([
|
||||||
|
'title' => 'Intro Lesson',
|
||||||
|
'slug' => 'intro-lesson',
|
||||||
|
'content' => 'Intro content',
|
||||||
|
'difficulty' => 'beginner',
|
||||||
|
'access_level' => 'free',
|
||||||
|
'lesson_type' => 'article',
|
||||||
|
'lesson_number' => 3,
|
||||||
|
'active' => true,
|
||||||
|
'published_at' => now()->subMinute(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$sectionLesson = AcademyLesson::query()->create([
|
||||||
|
'title' => 'Section Lesson',
|
||||||
|
'slug' => 'section-lesson',
|
||||||
|
'content' => 'Section content',
|
||||||
|
'difficulty' => 'beginner',
|
||||||
|
'access_level' => 'free',
|
||||||
|
'lesson_type' => 'article',
|
||||||
|
'lesson_number' => 1,
|
||||||
|
'active' => true,
|
||||||
|
'published_at' => now()->subMinute(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
AcademyCourseLesson::query()->create([
|
||||||
|
'course_id' => $course->id,
|
||||||
|
'lesson_id' => $introLesson->id,
|
||||||
|
'order_num' => 0,
|
||||||
|
'is_required' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
AcademyCourseLesson::query()->create([
|
||||||
|
'course_id' => $course->id,
|
||||||
|
'section_id' => $section->id,
|
||||||
|
'lesson_id' => $sectionLesson->id,
|
||||||
|
'order_num' => 0,
|
||||||
|
'is_required' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->get(route('academy.courses.show', ['course' => $course->slug]))
|
||||||
|
->assertOk()
|
||||||
|
->assertInertia(fn (AssertableInertia $page) => $page
|
||||||
|
->component('Academy/CoursesShow')
|
||||||
|
->where('unsectionedLessons.0.title', 'Intro Lesson')
|
||||||
|
->where('unsectionedLessons.0.course_step_number', 1)
|
||||||
|
->where('unsectionedLessons.0.course_step_label', 'Step 01')
|
||||||
|
->where('sections.0.lessons.0.title', 'Section Lesson')
|
||||||
|
->where('sections.0.lessons.0.course_step_number', 2)
|
||||||
|
->where('sections.0.lessons.0.course_step_label', 'Step 02')
|
||||||
|
->where('unsectionedLessons.0.formatted_lesson_number', 'Lesson 03')
|
||||||
|
->where('sections.0.lessons.0.formatted_lesson_number', 'Lesson 01'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_course_show_marks_completed_lessons_for_authenticated_user(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$course = AcademyCourse::query()->create([
|
||||||
|
'title' => 'Guided Course',
|
||||||
|
'slug' => 'guided-course',
|
||||||
|
'excerpt' => 'Track outline completion.',
|
||||||
|
'access_level' => 'free',
|
||||||
|
'difficulty' => 'beginner',
|
||||||
|
'status' => 'published',
|
||||||
|
'published_at' => now()->subMinute(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$section = AcademyCourseSection::query()->create([
|
||||||
|
'course_id' => $course->id,
|
||||||
|
'title' => 'Module One',
|
||||||
|
'slug' => 'module-one',
|
||||||
|
'order_num' => 0,
|
||||||
|
'is_visible' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$lesson = AcademyLesson::query()->create([
|
||||||
|
'title' => 'Completed Lesson',
|
||||||
|
'slug' => 'completed-lesson',
|
||||||
|
'content' => 'Lesson content',
|
||||||
|
'difficulty' => 'beginner',
|
||||||
|
'access_level' => 'free',
|
||||||
|
'lesson_type' => 'article',
|
||||||
|
'active' => true,
|
||||||
|
'published_at' => now()->subMinute(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
AcademyCourseLesson::query()->create([
|
||||||
|
'course_id' => $course->id,
|
||||||
|
'section_id' => $section->id,
|
||||||
|
'lesson_id' => $lesson->id,
|
||||||
|
'order_num' => 0,
|
||||||
|
'is_required' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
AcademyLessonProgress::query()->create([
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'lesson_id' => $lesson->id,
|
||||||
|
'completed_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->get(route('academy.courses.show', ['course' => $course->slug]))
|
||||||
|
->assertOk()
|
||||||
|
->assertInertia(fn (AssertableInertia $page) => $page
|
||||||
|
->component('Academy/CoursesShow')
|
||||||
|
->where('course.progress.completedRequired', 1)
|
||||||
|
->where('sections.0.lessons.0.completed', true));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_standalone_lesson_show_exposes_related_courses(): void
|
||||||
|
{
|
||||||
|
$lesson = AcademyLesson::query()->create([
|
||||||
|
'title' => 'Shared Lesson',
|
||||||
|
'slug' => 'shared-lesson',
|
||||||
|
'content' => 'Shared content',
|
||||||
|
'article_cover_image' => 'academy/lessons/covers/shared-lesson.webp',
|
||||||
|
'difficulty' => 'beginner',
|
||||||
|
'access_level' => 'free',
|
||||||
|
'lesson_type' => 'article',
|
||||||
|
'active' => true,
|
||||||
|
'published_at' => now()->subMinute(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$course = AcademyCourse::query()->create([
|
||||||
|
'title' => 'Related Course',
|
||||||
|
'slug' => 'related-course',
|
||||||
|
'excerpt' => 'Course tied to this lesson.',
|
||||||
|
'access_level' => 'free',
|
||||||
|
'difficulty' => 'beginner',
|
||||||
|
'status' => 'published',
|
||||||
|
'published_at' => now()->subMinute(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
AcademyCourseLesson::query()->create([
|
||||||
|
'course_id' => $course->id,
|
||||||
|
'lesson_id' => $lesson->id,
|
||||||
|
'order_num' => 0,
|
||||||
|
'is_required' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->get(route('academy.lessons.show', ['slug' => $lesson->slug]))
|
||||||
|
->assertOk()
|
||||||
|
->assertInertia(fn (AssertableInertia $page) => $page
|
||||||
|
->component('Academy/Show')
|
||||||
|
->where('seo.json_ld.0.@type', 'ImageObject')
|
||||||
|
->where('seo.json_ld.0.creditText', 'Skinbase')
|
||||||
|
->where('seo.json_ld.1.@type', 'Article')
|
||||||
|
->where('seo.json_ld.1.headline', 'Shared Lesson — Skinbase Academy')
|
||||||
|
->where('seo.json_ld.1.articleSection', 'Academy')
|
||||||
|
->where('seo.json_ld.2.@type', 'BreadcrumbList')
|
||||||
|
->where('relatedCourses.0.slug', 'related-course'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_course_lesson_show_exposes_article_breadcrumb_and_image_license_schema(): void
|
||||||
|
{
|
||||||
|
$course = AcademyCourse::query()->create([
|
||||||
|
'title' => 'AI Art Basics',
|
||||||
|
'slug' => 'ai-art-basics',
|
||||||
|
'excerpt' => 'Course excerpt',
|
||||||
|
'access_level' => 'free',
|
||||||
|
'difficulty' => 'beginner',
|
||||||
|
'status' => 'published',
|
||||||
|
'published_at' => now()->subMinute(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$section = AcademyCourseSection::query()->create([
|
||||||
|
'course_id' => $course->id,
|
||||||
|
'title' => 'Foundations',
|
||||||
|
'slug' => 'foundations',
|
||||||
|
'order_num' => 0,
|
||||||
|
'is_visible' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$lesson = AcademyLesson::query()->create([
|
||||||
|
'title' => 'What Is AI-Assisted Digital Art',
|
||||||
|
'slug' => 'what-is-ai-assisted-digital-art',
|
||||||
|
'excerpt' => 'Structured data test lesson.',
|
||||||
|
'content' => 'Lesson body',
|
||||||
|
'article_cover_image' => 'academy/lessons/covers/ai-assist.webp',
|
||||||
|
'tags' => ['ai-art', 'workflow'],
|
||||||
|
'difficulty' => 'beginner',
|
||||||
|
'access_level' => 'free',
|
||||||
|
'lesson_type' => 'article',
|
||||||
|
'active' => true,
|
||||||
|
'published_at' => now()->subMinute(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
AcademyCourseLesson::query()->create([
|
||||||
|
'course_id' => $course->id,
|
||||||
|
'section_id' => $section->id,
|
||||||
|
'lesson_id' => $lesson->id,
|
||||||
|
'order_num' => 0,
|
||||||
|
'is_required' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->get(route('academy.courses.lessons.show', ['course' => $course->slug, 'lesson' => $lesson->slug]))
|
||||||
|
->assertOk()
|
||||||
|
->assertInertia(fn (AssertableInertia $page) => $page
|
||||||
|
->component('Academy/Show')
|
||||||
|
->where('seo.json_ld.0.@type', 'ImageObject')
|
||||||
|
->where('seo.json_ld.0.license', route('terms-of-service'))
|
||||||
|
->where('seo.json_ld.0.creditText', 'Skinbase')
|
||||||
|
->where('seo.json_ld.1.@type', 'Article')
|
||||||
|
->where('seo.json_ld.1.headline', 'What Is AI-Assisted Digital Art — AI Art Basics')
|
||||||
|
->where('seo.json_ld.1.articleSection', 'AI Art Basics')
|
||||||
|
->where('seo.json_ld.1.keywords.0', 'ai-art')
|
||||||
|
->where('seo.json_ld.2.@type', 'BreadcrumbList')
|
||||||
|
->where('seo.json_ld.2.itemListElement.3.name', 'AI Art Basics')
|
||||||
|
->where('seo.json_ld.2.itemListElement.4.name', 'What Is AI-Assisted Digital Art'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_free_lesson_is_visible_to_guest(): void
|
public function test_free_lesson_is_visible_to_guest(): void
|
||||||
@@ -57,6 +424,8 @@ final class AcademyFeatureTest extends TestCase
|
|||||||
'difficulty' => 'beginner',
|
'difficulty' => 'beginner',
|
||||||
'access_level' => 'free',
|
'access_level' => 'free',
|
||||||
'lesson_type' => 'article',
|
'lesson_type' => 'article',
|
||||||
|
'article_cover_image' => 'academy/lessons/covers/article-cover.webp',
|
||||||
|
'tags' => ['workflow', 'publishing'],
|
||||||
'active' => true,
|
'active' => true,
|
||||||
'published_at' => now()->subMinute(),
|
'published_at' => now()->subMinute(),
|
||||||
]);
|
]);
|
||||||
@@ -66,7 +435,11 @@ final class AcademyFeatureTest extends TestCase
|
|||||||
->assertInertia(fn (AssertableInertia $page) => $page
|
->assertInertia(fn (AssertableInertia $page) => $page
|
||||||
->component('Academy/Show')
|
->component('Academy/Show')
|
||||||
->where('item.locked', false)
|
->where('item.locked', false)
|
||||||
->where('item.content', 'Free lesson content'));
|
->where('item.content', 'Free lesson content')
|
||||||
|
->where('item.article_cover_image', 'academy/lessons/covers/article-cover.webp')
|
||||||
|
->where('item.tags.0', 'workflow')
|
||||||
|
->where('item.tags.1', 'publishing')
|
||||||
|
->where('item.article_cover_image_url', fn (?string $value) => is_string($value) && str_contains($value, 'academy/lessons/covers/article-cover.webp')));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_creator_lesson_is_locked_for_regular_user(): void
|
public function test_creator_lesson_is_locked_for_regular_user(): void
|
||||||
@@ -166,6 +539,15 @@ final class AcademyFeatureTest extends TestCase
|
|||||||
'excerpt' => 'Full prompt visible.',
|
'excerpt' => 'Full prompt visible.',
|
||||||
'prompt' => 'VISIBLE PREMIUM PROMPT',
|
'prompt' => 'VISIBLE PREMIUM PROMPT',
|
||||||
'negative_prompt' => 'VISIBLE NEGATIVE PROMPT',
|
'negative_prompt' => 'VISIBLE NEGATIVE PROMPT',
|
||||||
|
'tool_notes' => [[
|
||||||
|
'provider' => 'ChatGPT',
|
||||||
|
'model_name' => '4o Image',
|
||||||
|
'image_path' => 'academy/lessons/body/cc/dd/chatgpt-comparison.webp',
|
||||||
|
'settings' => 'ChatGPT image generation in 16:9 with cinematic mode.',
|
||||||
|
'best_for' => 'Fast ideation and art direction.',
|
||||||
|
'score' => 8,
|
||||||
|
'active' => true,
|
||||||
|
]],
|
||||||
'difficulty' => 'beginner',
|
'difficulty' => 'beginner',
|
||||||
'access_level' => 'creator',
|
'access_level' => 'creator',
|
||||||
'active' => true,
|
'active' => true,
|
||||||
@@ -180,7 +562,11 @@ final class AcademyFeatureTest extends TestCase
|
|||||||
->assertSee('VISIBLE PREMIUM PROMPT')
|
->assertSee('VISIBLE PREMIUM PROMPT')
|
||||||
->assertInertia(fn (AssertableInertia $page) => $page
|
->assertInertia(fn (AssertableInertia $page) => $page
|
||||||
->where('item.locked', false)
|
->where('item.locked', false)
|
||||||
->where('item.prompt', 'VISIBLE PREMIUM PROMPT'));
|
->where('item.prompt', 'VISIBLE PREMIUM PROMPT')
|
||||||
|
->where('item.tool_notes.0.provider', 'ChatGPT')
|
||||||
|
->where('item.tool_notes.0.model_name', '4o Image')
|
||||||
|
->where('item.tool_notes.0.image_path', 'academy/lessons/body/cc/dd/chatgpt-comparison.webp')
|
||||||
|
->where('item.tool_notes.0.score', 8));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_public_lesson_payload_includes_active_ai_comparison_block_and_hides_inactive_results(): void
|
public function test_public_lesson_payload_includes_active_ai_comparison_block_and_hides_inactive_results(): void
|
||||||
@@ -273,6 +659,184 @@ final class AcademyFeatureTest extends TestCase
|
|||||||
->has('item.blocks.0.comparison_results', 0));
|
->has('item.blocks.0.comparison_results', 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_lessons_index_sorts_by_course_order_and_exposes_lesson_labels(): void
|
||||||
|
{
|
||||||
|
AcademyLesson::query()->create([
|
||||||
|
'title' => 'Later Lesson',
|
||||||
|
'slug' => 'later-lesson',
|
||||||
|
'lesson_number' => 2,
|
||||||
|
'course_order' => 2,
|
||||||
|
'series_name' => 'AI Art Basics',
|
||||||
|
'content' => 'Later content',
|
||||||
|
'difficulty' => 'beginner',
|
||||||
|
'access_level' => 'free',
|
||||||
|
'lesson_type' => 'article',
|
||||||
|
'active' => true,
|
||||||
|
'published_at' => now()->subDays(3),
|
||||||
|
]);
|
||||||
|
|
||||||
|
AcademyLesson::query()->create([
|
||||||
|
'title' => 'First Lesson',
|
||||||
|
'slug' => 'first-lesson',
|
||||||
|
'lesson_number' => 1,
|
||||||
|
'course_order' => 1,
|
||||||
|
'series_name' => 'AI Art Basics',
|
||||||
|
'content' => 'First content',
|
||||||
|
'difficulty' => 'beginner',
|
||||||
|
'access_level' => 'free',
|
||||||
|
'article_cover_image' => 'academy/lessons/covers/shared-lesson.webp',
|
||||||
|
'lesson_type' => 'article',
|
||||||
|
'active' => true,
|
||||||
|
'published_at' => now()->subMinute(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->get(route('academy.lessons.index'))
|
||||||
|
->assertOk()
|
||||||
|
->assertInertia(fn (AssertableInertia $page) => $page
|
||||||
|
->component('Academy/List')
|
||||||
|
->where('items.data.0.slug', 'first-lesson')
|
||||||
|
->where('items.data.0.formatted_lesson_number', 'Lesson 01')
|
||||||
|
->where('items.data.0.lesson_label', 'AI Art Basics · Lesson 01')
|
||||||
|
->where('items.data.1.slug', 'later-lesson'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_lesson_show_exposes_ordered_previous_and_next_navigation_within_series(): void
|
||||||
|
{
|
||||||
|
$publishMoment = Carbon::parse('2026-01-01 10:00:00');
|
||||||
|
|
||||||
|
$previous = AcademyLesson::query()->create([
|
||||||
|
'title' => 'Lesson One',
|
||||||
|
'slug' => 'lesson-one',
|
||||||
|
'lesson_number' => 1,
|
||||||
|
'course_order' => 1,
|
||||||
|
'series_name' => 'AI Art Basics',
|
||||||
|
'content' => 'Lesson one body',
|
||||||
|
'difficulty' => 'beginner',
|
||||||
|
'access_level' => 'free',
|
||||||
|
'lesson_type' => 'article',
|
||||||
|
'active' => true,
|
||||||
|
'published_at' => $publishMoment->copy(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$current = AcademyLesson::query()->create([
|
||||||
|
'title' => 'Lesson Two',
|
||||||
|
'slug' => 'lesson-two',
|
||||||
|
'lesson_number' => 2,
|
||||||
|
'course_order' => 2,
|
||||||
|
'series_name' => 'AI Art Basics',
|
||||||
|
'content' => 'Lesson two body',
|
||||||
|
'difficulty' => 'beginner',
|
||||||
|
'access_level' => 'free',
|
||||||
|
'lesson_type' => 'article',
|
||||||
|
'active' => true,
|
||||||
|
'published_at' => $publishMoment->copy()->addMinute(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$next = AcademyLesson::query()->create([
|
||||||
|
'title' => 'Lesson Three',
|
||||||
|
'slug' => 'lesson-three',
|
||||||
|
'lesson_number' => 3,
|
||||||
|
'course_order' => 3,
|
||||||
|
'series_name' => 'AI Art Basics',
|
||||||
|
'content' => 'Lesson three body',
|
||||||
|
'difficulty' => 'beginner',
|
||||||
|
'access_level' => 'free',
|
||||||
|
'lesson_type' => 'article',
|
||||||
|
'active' => true,
|
||||||
|
'published_at' => $publishMoment->copy()->addMinutes(2),
|
||||||
|
]);
|
||||||
|
|
||||||
|
AcademyLesson::query()->create([
|
||||||
|
'title' => 'Other Series Lesson',
|
||||||
|
'slug' => 'other-series-lesson',
|
||||||
|
'lesson_number' => 99,
|
||||||
|
'course_order' => 99,
|
||||||
|
'series_name' => 'Other Series',
|
||||||
|
'content' => 'Other series body',
|
||||||
|
'difficulty' => 'beginner',
|
||||||
|
'access_level' => 'free',
|
||||||
|
'lesson_type' => 'article',
|
||||||
|
'active' => true,
|
||||||
|
'published_at' => $publishMoment->copy()->addMinutes(3),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->get(route('academy.lessons.show', ['slug' => $current->slug]))
|
||||||
|
->assertOk()
|
||||||
|
->assertInertia(fn (AssertableInertia $page) => $page
|
||||||
|
->component('Academy/Show')
|
||||||
|
->where('item.lesson_label', 'AI Art Basics · Lesson 02')
|
||||||
|
->where('previousLesson.slug', $previous->slug)
|
||||||
|
->where('nextLesson.slug', $next->slug));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_lesson_show_falls_back_to_category_navigation_when_series_name_is_missing(): void
|
||||||
|
{
|
||||||
|
$categoryId = DB::table('academy_categories')->insertGetId([
|
||||||
|
'type' => 'lesson',
|
||||||
|
'name' => 'Prompting Basics',
|
||||||
|
'slug' => 'prompting-basics',
|
||||||
|
'order_num' => 1,
|
||||||
|
'active' => true,
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
AcademyLesson::query()->create([
|
||||||
|
'title' => 'Category Lesson One',
|
||||||
|
'slug' => 'category-lesson-one',
|
||||||
|
'category_id' => $categoryId,
|
||||||
|
'lesson_number' => 1,
|
||||||
|
'course_order' => 1,
|
||||||
|
'content' => 'One',
|
||||||
|
'difficulty' => 'beginner',
|
||||||
|
'access_level' => 'free',
|
||||||
|
'lesson_type' => 'article',
|
||||||
|
'active' => true,
|
||||||
|
'published_at' => now()->subDays(2),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$current = AcademyLesson::query()->create([
|
||||||
|
'title' => 'Category Lesson Two',
|
||||||
|
'slug' => 'category-lesson-two',
|
||||||
|
'category_id' => $categoryId,
|
||||||
|
'lesson_number' => 2,
|
||||||
|
'course_order' => 2,
|
||||||
|
'content' => 'Two',
|
||||||
|
'difficulty' => 'beginner',
|
||||||
|
'access_level' => 'free',
|
||||||
|
'lesson_type' => 'article',
|
||||||
|
'active' => true,
|
||||||
|
'published_at' => now()->subDay(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->get(route('academy.lessons.show', ['slug' => $current->slug]))
|
||||||
|
->assertOk()
|
||||||
|
->assertInertia(fn (AssertableInertia $page) => $page
|
||||||
|
->where('previousLesson.slug', 'category-lesson-one')
|
||||||
|
->where('nextLesson', null));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_lesson_payload_hides_label_when_lesson_number_is_missing(): void
|
||||||
|
{
|
||||||
|
$lesson = AcademyLesson::query()->create([
|
||||||
|
'title' => 'Unnumbered Lesson',
|
||||||
|
'slug' => 'unnumbered-lesson',
|
||||||
|
'series_name' => 'AI Art Basics',
|
||||||
|
'content' => 'Lesson body',
|
||||||
|
'difficulty' => 'beginner',
|
||||||
|
'access_level' => 'free',
|
||||||
|
'lesson_type' => 'article',
|
||||||
|
'active' => true,
|
||||||
|
'published_at' => now()->subMinute(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->get(route('academy.lessons.show', ['slug' => $lesson->slug]))
|
||||||
|
->assertOk()
|
||||||
|
->assertInertia(fn (AssertableInertia $page) => $page
|
||||||
|
->where('item.formatted_lesson_number', null)
|
||||||
|
->where('item.lesson_label', null));
|
||||||
|
}
|
||||||
|
|
||||||
public function test_logged_in_user_can_mark_lesson_completed(): void
|
public function test_logged_in_user_can_mark_lesson_completed(): void
|
||||||
{
|
{
|
||||||
$lesson = AcademyLesson::query()->create([
|
$lesson = AcademyLesson::query()->create([
|
||||||
|
|||||||
@@ -9,8 +9,11 @@ use App\Models\AcademyAiComparisonResult;
|
|||||||
use App\Models\AcademyCategory;
|
use App\Models\AcademyCategory;
|
||||||
use App\Models\AcademyChallenge;
|
use App\Models\AcademyChallenge;
|
||||||
use App\Models\AcademyChallengeSubmission;
|
use App\Models\AcademyChallengeSubmission;
|
||||||
|
use App\Models\AcademyCourse;
|
||||||
|
use App\Models\AcademyCourseLesson;
|
||||||
use App\Models\AcademyLesson;
|
use App\Models\AcademyLesson;
|
||||||
use App\Models\AcademyLessonBlock;
|
use App\Models\AcademyLessonBlock;
|
||||||
|
use App\Models\AcademyPromptTemplate;
|
||||||
use App\Models\Artwork;
|
use App\Models\Artwork;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
@@ -38,6 +41,7 @@ final class AcademyAdminTest extends TestCase
|
|||||||
->assertOk()
|
->assertOk()
|
||||||
->assertInertia(fn (AssertableInertia $page) => $page
|
->assertInertia(fn (AssertableInertia $page) => $page
|
||||||
->component('Admin/Academy/Dashboard')
|
->component('Admin/Academy/Dashboard')
|
||||||
|
->where('stats.courses', 0)
|
||||||
->where('stats.lessons', 0)
|
->where('stats.lessons', 0)
|
||||||
->where('stats.prompts', 0));
|
->where('stats.prompts', 0));
|
||||||
}
|
}
|
||||||
@@ -90,6 +94,7 @@ final class AcademyAdminTest extends TestCase
|
|||||||
|
|
||||||
foreach ([
|
foreach ([
|
||||||
'/moderation/academy/dashboard',
|
'/moderation/academy/dashboard',
|
||||||
|
'/moderation/academy/courses',
|
||||||
'/moderation/academy/categories',
|
'/moderation/academy/categories',
|
||||||
'/moderation/academy/lessons',
|
'/moderation/academy/lessons',
|
||||||
'/moderation/academy/prompts',
|
'/moderation/academy/prompts',
|
||||||
@@ -102,6 +107,373 @@ final class AcademyAdminTest extends TestCase
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_admin_can_open_course_builder(): void
|
||||||
|
{
|
||||||
|
$admin = User::factory()->create(['role' => 'admin']);
|
||||||
|
$course = AcademyCourse::query()->create([
|
||||||
|
'title' => 'Builder Course',
|
||||||
|
'slug' => 'builder-course',
|
||||||
|
'excerpt' => 'Course builder test',
|
||||||
|
'access_level' => 'free',
|
||||||
|
'difficulty' => 'beginner',
|
||||||
|
'status' => 'draft',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($admin)
|
||||||
|
->get(route('admin.academy.courses.builder.edit', ['academyCourse' => $course]))
|
||||||
|
->assertOk()
|
||||||
|
->assertInertia(fn (AssertableInertia $page) => $page
|
||||||
|
->component('Admin/Academy/CourseBuilder')
|
||||||
|
->where('course.slug', 'builder-course')
|
||||||
|
->where('routes.reorder', route('admin.academy.courses.reorder', ['academyCourse' => $course])));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_course_builder_attach_rewrites_lesson_numbers_and_course_order(): void
|
||||||
|
{
|
||||||
|
$admin = User::factory()->create(['role' => 'admin']);
|
||||||
|
$course = AcademyCourse::query()->create([
|
||||||
|
'title' => 'Ordering Course',
|
||||||
|
'slug' => 'ordering-course',
|
||||||
|
'access_level' => 'free',
|
||||||
|
'difficulty' => 'beginner',
|
||||||
|
'status' => 'draft',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$firstLesson = AcademyLesson::query()->create([
|
||||||
|
'title' => 'First Lesson',
|
||||||
|
'slug' => 'first-lesson',
|
||||||
|
'lesson_number' => 9,
|
||||||
|
'course_order' => 9,
|
||||||
|
'content' => '<p>Body</p>',
|
||||||
|
'difficulty' => 'beginner',
|
||||||
|
'access_level' => 'free',
|
||||||
|
'lesson_type' => 'article',
|
||||||
|
'reading_minutes' => 5,
|
||||||
|
'active' => true,
|
||||||
|
'published_at' => now()->subMinute(),
|
||||||
|
]);
|
||||||
|
$secondLesson = AcademyLesson::query()->create([
|
||||||
|
'title' => 'Second Lesson',
|
||||||
|
'slug' => 'second-lesson',
|
||||||
|
'lesson_number' => 8,
|
||||||
|
'course_order' => 8,
|
||||||
|
'content' => '<p>Body</p>',
|
||||||
|
'difficulty' => 'beginner',
|
||||||
|
'access_level' => 'free',
|
||||||
|
'lesson_type' => 'article',
|
||||||
|
'reading_minutes' => 5,
|
||||||
|
'active' => true,
|
||||||
|
'published_at' => now()->subMinute(),
|
||||||
|
]);
|
||||||
|
$newLesson = AcademyLesson::query()->create([
|
||||||
|
'title' => 'New Lesson',
|
||||||
|
'slug' => 'new-lesson',
|
||||||
|
'content' => '<p>Body</p>',
|
||||||
|
'difficulty' => 'beginner',
|
||||||
|
'access_level' => 'free',
|
||||||
|
'lesson_type' => 'article',
|
||||||
|
'reading_minutes' => 5,
|
||||||
|
'active' => true,
|
||||||
|
'published_at' => now()->subMinute(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
AcademyCourseLesson::query()->create([
|
||||||
|
'course_id' => $course->id,
|
||||||
|
'lesson_id' => $firstLesson->id,
|
||||||
|
'order_num' => 4,
|
||||||
|
'is_required' => true,
|
||||||
|
]);
|
||||||
|
AcademyCourseLesson::query()->create([
|
||||||
|
'course_id' => $course->id,
|
||||||
|
'lesson_id' => $secondLesson->id,
|
||||||
|
'order_num' => 7,
|
||||||
|
'is_required' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($admin)
|
||||||
|
->post(route('admin.academy.courses.lessons.attach', ['academyCourse' => $course]), [
|
||||||
|
'lesson_id' => $newLesson->id,
|
||||||
|
'order_num' => 12,
|
||||||
|
'is_required' => true,
|
||||||
|
])
|
||||||
|
->assertRedirect();
|
||||||
|
|
||||||
|
$orderedCourseLessons = AcademyCourseLesson::query()
|
||||||
|
->where('course_id', $course->id)
|
||||||
|
->orderBy('order_num')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$this->assertSame([0, 1, 2], $orderedCourseLessons->pluck('order_num')->map(static fn ($value) => (int) $value)->all());
|
||||||
|
$this->assertSame([1, 2, 3], AcademyLesson::query()->whereIn('id', [$firstLesson->id, $secondLesson->id, $newLesson->id])->orderBy('course_order')->pluck('course_order')->map(static fn ($value) => (int) $value)->all());
|
||||||
|
|
||||||
|
$this->assertSame(1, $firstLesson->fresh()->lesson_number);
|
||||||
|
$this->assertSame(1, $firstLesson->fresh()->course_order);
|
||||||
|
$this->assertSame(2, $secondLesson->fresh()->lesson_number);
|
||||||
|
$this->assertSame(2, $secondLesson->fresh()->course_order);
|
||||||
|
$this->assertSame(3, $newLesson->fresh()->lesson_number);
|
||||||
|
$this->assertSame(3, $newLesson->fresh()->course_order);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_course_builder_reorder_rewrites_lesson_numbers_and_course_order(): void
|
||||||
|
{
|
||||||
|
$admin = User::factory()->create(['role' => 'admin']);
|
||||||
|
$course = AcademyCourse::query()->create([
|
||||||
|
'title' => 'Reorder Course',
|
||||||
|
'slug' => 'reorder-course',
|
||||||
|
'access_level' => 'free',
|
||||||
|
'difficulty' => 'beginner',
|
||||||
|
'status' => 'draft',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$firstLesson = AcademyLesson::query()->create([
|
||||||
|
'title' => 'Alpha Lesson',
|
||||||
|
'slug' => 'alpha-lesson',
|
||||||
|
'lesson_number' => 1,
|
||||||
|
'course_order' => 1,
|
||||||
|
'content' => '<p>Body</p>',
|
||||||
|
'difficulty' => 'beginner',
|
||||||
|
'access_level' => 'free',
|
||||||
|
'lesson_type' => 'article',
|
||||||
|
'reading_minutes' => 5,
|
||||||
|
'active' => true,
|
||||||
|
'published_at' => now()->subMinute(),
|
||||||
|
]);
|
||||||
|
$secondLesson = AcademyLesson::query()->create([
|
||||||
|
'title' => 'Beta Lesson',
|
||||||
|
'slug' => 'beta-lesson',
|
||||||
|
'lesson_number' => 2,
|
||||||
|
'course_order' => 2,
|
||||||
|
'content' => '<p>Body</p>',
|
||||||
|
'difficulty' => 'beginner',
|
||||||
|
'access_level' => 'free',
|
||||||
|
'lesson_type' => 'article',
|
||||||
|
'reading_minutes' => 5,
|
||||||
|
'active' => true,
|
||||||
|
'published_at' => now()->subMinute(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$firstCourseLesson = AcademyCourseLesson::query()->create([
|
||||||
|
'course_id' => $course->id,
|
||||||
|
'lesson_id' => $firstLesson->id,
|
||||||
|
'order_num' => 0,
|
||||||
|
'is_required' => true,
|
||||||
|
]);
|
||||||
|
$secondCourseLesson = AcademyCourseLesson::query()->create([
|
||||||
|
'course_id' => $course->id,
|
||||||
|
'lesson_id' => $secondLesson->id,
|
||||||
|
'order_num' => 1,
|
||||||
|
'is_required' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($admin)
|
||||||
|
->patch(route('admin.academy.courses.reorder', ['academyCourse' => $course]), [
|
||||||
|
'sections' => [],
|
||||||
|
'lessons' => [
|
||||||
|
[
|
||||||
|
'id' => $secondCourseLesson->id,
|
||||||
|
'order_num' => 0,
|
||||||
|
'section_id' => null,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'id' => $firstCourseLesson->id,
|
||||||
|
'order_num' => 1,
|
||||||
|
'section_id' => null,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
])
|
||||||
|
->assertRedirect();
|
||||||
|
|
||||||
|
$this->assertSame(1, $secondLesson->fresh()->lesson_number);
|
||||||
|
$this->assertSame(1, $secondLesson->fresh()->course_order);
|
||||||
|
$this->assertSame(2, $firstLesson->fresh()->lesson_number);
|
||||||
|
$this->assertSame(2, $firstLesson->fresh()->course_order);
|
||||||
|
$this->assertSame([0, 1], AcademyCourseLesson::query()->where('course_id', $course->id)->orderBy('order_num')->pluck('order_num')->map(static fn ($value) => (int) $value)->all());
|
||||||
|
$this->assertSame([$secondLesson->id, $firstLesson->id], AcademyCourseLesson::query()->where('course_id', $course->id)->orderBy('order_num')->pluck('lesson_id')->map(static fn ($value) => (int) $value)->all());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_admin_can_open_course_create_form_with_editor_context(): void
|
||||||
|
{
|
||||||
|
$admin = User::factory()->create(['role' => 'admin']);
|
||||||
|
|
||||||
|
$this->actingAs($admin)
|
||||||
|
->get(route('admin.academy.courses.create'))
|
||||||
|
->assertOk()
|
||||||
|
->assertInertia(fn (AssertableInertia $page) => $page
|
||||||
|
->component('Admin/Academy/CrudForm')
|
||||||
|
->where('resource', 'courses')
|
||||||
|
->where('editorContext.coverUploadUrl', route('api.studio.academy.lessons.media.upload'))
|
||||||
|
->where('editorContext.coverDeleteUrl', route('api.studio.academy.lessons.media.destroy'))
|
||||||
|
->where('editorContext.bodyMediaUploadUrl', route('api.studio.academy.lessons.media.upload'))
|
||||||
|
->where('record.cover_image_url', null)
|
||||||
|
->where('record.teaser_image_url', null));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_admin_can_store_prompt_with_ai_model_comparisons(): void
|
||||||
|
{
|
||||||
|
$admin = User::factory()->create(['role' => 'admin']);
|
||||||
|
|
||||||
|
$response = $this->actingAs($admin)
|
||||||
|
->post(route('admin.academy.prompts.store'), [
|
||||||
|
'title' => 'Prompt Comparison Template',
|
||||||
|
'slug' => 'prompt-comparison-template',
|
||||||
|
'excerpt' => 'Compare the same prompt across different AI models.',
|
||||||
|
'prompt' => 'Create a cinematic sci-fi skyline with reflective rain-soaked streets.',
|
||||||
|
'negative_prompt' => 'blurry, low detail, text, watermark',
|
||||||
|
'usage_notes' => 'Use this when you want reflective city lighting.',
|
||||||
|
'workflow_notes' => 'Best after a composition sketch pass.',
|
||||||
|
'difficulty' => 'beginner',
|
||||||
|
'access_level' => 'free',
|
||||||
|
'aspect_ratio' => '16:9',
|
||||||
|
'tags' => ['cinematic', 'city'],
|
||||||
|
'tool_notes' => [
|
||||||
|
[
|
||||||
|
'provider' => 'Midjourney',
|
||||||
|
'model_name' => 'V7',
|
||||||
|
'notes' => 'Produces the strongest mood and lighting with minimal retries.',
|
||||||
|
'image_path' => 'academy/lessons/body/aa/bb/prompt-midjourney.webp',
|
||||||
|
'thumb_path' => 'academy/lessons/body/aa/bb/prompt-midjourney-thumb.webp',
|
||||||
|
'settings' => 'Midjourney V7 on Discord, stylize 200, 16:9 upscale.',
|
||||||
|
'strengths' => 'Atmosphere, composition, reflective light.',
|
||||||
|
'weaknesses' => 'Can over-stylize signage and crowd details.',
|
||||||
|
'best_for' => 'Quick concept frames and wallpaper-ready hero shots.',
|
||||||
|
'score' => 9,
|
||||||
|
'active' => true,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'preview_image' => '',
|
||||||
|
'featured' => false,
|
||||||
|
'prompt_of_week' => false,
|
||||||
|
'active' => true,
|
||||||
|
'published_at' => now()->subMinute()->toDateTimeString(),
|
||||||
|
'seo_title' => '',
|
||||||
|
'seo_description' => '',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$prompt = AcademyPromptTemplate::query()->where('slug', 'prompt-comparison-template')->firstOrFail();
|
||||||
|
|
||||||
|
$response->assertRedirect(route('admin.academy.prompts.edit', ['academyPromptTemplate' => $prompt]));
|
||||||
|
$this->assertSame('Midjourney', $prompt->tool_notes[0]['provider'] ?? null);
|
||||||
|
$this->assertSame('V7', $prompt->tool_notes[0]['model_name'] ?? null);
|
||||||
|
$this->assertSame('Quick concept frames and wallpaper-ready hero shots.', $prompt->tool_notes[0]['best_for'] ?? null);
|
||||||
|
$this->assertSame('academy/lessons/body/aa/bb/prompt-midjourney.webp', $prompt->tool_notes[0]['image_path'] ?? null);
|
||||||
|
$this->assertSame(9, $prompt->tool_notes[0]['score'] ?? null);
|
||||||
|
|
||||||
|
$this->actingAs($admin)
|
||||||
|
->get(route('admin.academy.prompts.edit', ['academyPromptTemplate' => $prompt]))
|
||||||
|
->assertOk()
|
||||||
|
->assertInertia(fn (AssertableInertia $page) => $page
|
||||||
|
->component('Admin/Academy/CrudForm')
|
||||||
|
->where('editorContext.comparisonMediaUploadUrl', route('api.studio.academy.lessons.media.upload'))
|
||||||
|
->where('record.tool_notes.0.provider', 'Midjourney')
|
||||||
|
->where('record.tool_notes.0.model_name', 'V7')
|
||||||
|
->where('record.tool_notes.0.image_path', 'academy/lessons/body/aa/bb/prompt-midjourney.webp')
|
||||||
|
->where('record.tool_notes.0.score', 9));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_admin_course_edit_form_includes_outline_summary(): void
|
||||||
|
{
|
||||||
|
$admin = User::factory()->create(['role' => 'admin']);
|
||||||
|
$course = AcademyCourse::query()->create([
|
||||||
|
'title' => 'Outline Course',
|
||||||
|
'slug' => 'outline-course',
|
||||||
|
'access_level' => 'free',
|
||||||
|
'difficulty' => 'beginner',
|
||||||
|
'status' => 'draft',
|
||||||
|
]);
|
||||||
|
$section = $course->sections()->create([
|
||||||
|
'title' => 'Introduction',
|
||||||
|
'slug' => 'introduction',
|
||||||
|
'order_num' => 0,
|
||||||
|
'is_visible' => true,
|
||||||
|
]);
|
||||||
|
$requiredLesson = AcademyLesson::query()->create([
|
||||||
|
'title' => 'Required Lesson',
|
||||||
|
'slug' => 'required-lesson',
|
||||||
|
'content' => '<p>Body</p>',
|
||||||
|
'difficulty' => 'beginner',
|
||||||
|
'access_level' => 'free',
|
||||||
|
'lesson_type' => 'article',
|
||||||
|
'reading_minutes' => 5,
|
||||||
|
'active' => true,
|
||||||
|
]);
|
||||||
|
$optionalLesson = AcademyLesson::query()->create([
|
||||||
|
'title' => 'Optional Lesson',
|
||||||
|
'slug' => 'optional-lesson',
|
||||||
|
'content' => '<p>Body</p>',
|
||||||
|
'difficulty' => 'beginner',
|
||||||
|
'access_level' => 'free',
|
||||||
|
'lesson_type' => 'article',
|
||||||
|
'reading_minutes' => 5,
|
||||||
|
'active' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
AcademyCourseLesson::query()->create([
|
||||||
|
'course_id' => $course->id,
|
||||||
|
'section_id' => $section->id,
|
||||||
|
'lesson_id' => $requiredLesson->id,
|
||||||
|
'order_num' => 0,
|
||||||
|
'is_required' => true,
|
||||||
|
]);
|
||||||
|
AcademyCourseLesson::query()->create([
|
||||||
|
'course_id' => $course->id,
|
||||||
|
'section_id' => null,
|
||||||
|
'lesson_id' => $optionalLesson->id,
|
||||||
|
'order_num' => 1,
|
||||||
|
'is_required' => false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($admin)
|
||||||
|
->get(route('admin.academy.courses.edit', ['academyCourse' => $course]))
|
||||||
|
->assertOk()
|
||||||
|
->assertInertia(fn (AssertableInertia $page) => $page
|
||||||
|
->component('Admin/Academy/CrudForm')
|
||||||
|
->where('editorContext.outlineSummary.section_count', 1)
|
||||||
|
->where('editorContext.outlineSummary.visible_section_count', 1)
|
||||||
|
->where('editorContext.outlineSummary.lesson_count', 2)
|
||||||
|
->where('editorContext.outlineSummary.required_lesson_count', 1)
|
||||||
|
->where('editorContext.outlineSummary.unsectioned_lesson_count', 1)
|
||||||
|
->where('editorContext.outlineSummary.sections.0.title', 'Introduction')
|
||||||
|
->where('editorContext.outlineSummary.sections.0.lesson_count', 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_admin_can_store_course_with_rich_description_and_media_fields(): void
|
||||||
|
{
|
||||||
|
$admin = User::factory()->create(['role' => 'admin']);
|
||||||
|
|
||||||
|
$response = $this->actingAs($admin)
|
||||||
|
->post(route('admin.academy.courses.store'), [
|
||||||
|
'title' => 'Foundations Course',
|
||||||
|
'slug' => 'foundations-course',
|
||||||
|
'subtitle' => 'A guided path for Skinbase creators',
|
||||||
|
'excerpt' => 'A strong introduction to AI-assisted digital art workflows.',
|
||||||
|
'description' => '<h2>What you will learn</h2><p>Prompt structure, workflow cleanup, and publication readiness.</p>',
|
||||||
|
'cover_image' => 'academy/lessons/covers/aa/bb/course-cover.webp',
|
||||||
|
'teaser_image' => 'academy/lessons/covers/cc/dd/course-teaser.webp',
|
||||||
|
'access_level' => 'free',
|
||||||
|
'difficulty' => 'beginner',
|
||||||
|
'status' => 'published',
|
||||||
|
'is_featured' => true,
|
||||||
|
'order_num' => 5,
|
||||||
|
'estimated_minutes' => 90,
|
||||||
|
'published_at' => '',
|
||||||
|
'seo_title' => 'Foundations Course',
|
||||||
|
'seo_description' => 'A guided Academy course for Skinbase creators.',
|
||||||
|
'meta_keywords' => 'academy, ai art, workflow',
|
||||||
|
'og_title' => 'Foundations Course',
|
||||||
|
'og_description' => 'Learn the fundamentals of AI-assisted digital art.',
|
||||||
|
'og_image' => 'academy/lessons/covers/ee/ff/course-og.webp',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$course = AcademyCourse::query()->where('slug', 'foundations-course')->firstOrFail();
|
||||||
|
|
||||||
|
$response->assertRedirect(route('admin.academy.courses.edit', ['academyCourse' => $course]));
|
||||||
|
$this->assertSame('academy/lessons/covers/aa/bb/course-cover.webp', $course->cover_image);
|
||||||
|
$this->assertSame('academy/lessons/covers/cc/dd/course-teaser.webp', $course->teaser_image);
|
||||||
|
$this->assertStringContainsString('<h2>What you will learn</h2>', (string) $course->description);
|
||||||
|
$this->assertTrue((bool) $course->is_featured);
|
||||||
|
$this->assertNotNull($course->published_at);
|
||||||
|
}
|
||||||
|
|
||||||
public function test_admin_category_update_clears_academy_cache(): void
|
public function test_admin_category_update_clears_academy_cache(): void
|
||||||
{
|
{
|
||||||
$admin = User::factory()->create(['role' => 'admin']);
|
$admin = User::factory()->create(['role' => 'admin']);
|
||||||
@@ -146,6 +518,8 @@ final class AcademyAdminTest extends TestCase
|
|||||||
'access_level' => 'free',
|
'access_level' => 'free',
|
||||||
'lesson_type' => 'article',
|
'lesson_type' => 'article',
|
||||||
'cover_image' => '',
|
'cover_image' => '',
|
||||||
|
'article_cover_image' => 'academy/lessons/covers/article-cover.webp',
|
||||||
|
'tags' => ['ai comparison', 'models'],
|
||||||
'video_url' => '',
|
'video_url' => '',
|
||||||
'reading_minutes' => 5,
|
'reading_minutes' => 5,
|
||||||
'featured' => false,
|
'featured' => false,
|
||||||
@@ -183,6 +557,287 @@ final class AcademyAdminTest extends TestCase
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_admin_can_create_a_lesson_from_markdown(): void
|
||||||
|
{
|
||||||
|
$admin = User::factory()->create(['role' => 'admin']);
|
||||||
|
$markdown = <<<'MD'
|
||||||
|
# Cleaner scene direction
|
||||||
|
|
||||||
|
Use **specific nouns** and keep the camera angle stable.
|
||||||
|
|
||||||
|
- State the subject
|
||||||
|
- Add lighting
|
||||||
|
MD;
|
||||||
|
|
||||||
|
$response = $this->actingAs($admin)
|
||||||
|
->post(route('admin.academy.lessons.store'), [
|
||||||
|
'title' => 'Markdown Lesson',
|
||||||
|
'slug' => 'markdown-lesson',
|
||||||
|
'excerpt' => 'Testing markdown lesson creation.',
|
||||||
|
'content' => '',
|
||||||
|
'content_markdown' => $markdown,
|
||||||
|
'difficulty' => 'beginner',
|
||||||
|
'access_level' => 'free',
|
||||||
|
'lesson_type' => 'article',
|
||||||
|
'cover_image' => '',
|
||||||
|
'video_url' => '',
|
||||||
|
'reading_minutes' => 6,
|
||||||
|
'featured' => false,
|
||||||
|
'active' => true,
|
||||||
|
'published_at' => now()->subMinute()->toDateTimeString(),
|
||||||
|
'seo_title' => '',
|
||||||
|
'seo_description' => '',
|
||||||
|
'blocks' => [],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$lesson = AcademyLesson::query()->where('slug', 'markdown-lesson')->firstOrFail();
|
||||||
|
|
||||||
|
$response->assertRedirect(route('admin.academy.lessons.edit', ['academyLesson' => $lesson]));
|
||||||
|
$this->assertSame($markdown, $lesson->content_markdown);
|
||||||
|
$this->assertStringContainsString('<h1>Cleaner scene direction</h1>', (string) $lesson->content);
|
||||||
|
$this->assertStringContainsString('<strong>specific nouns</strong>', (string) $lesson->content);
|
||||||
|
$this->assertStringContainsString('<li>State the subject</li>', (string) $lesson->content);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_admin_can_store_lesson_numbering_fields(): void
|
||||||
|
{
|
||||||
|
$admin = User::factory()->create(['role' => 'admin']);
|
||||||
|
|
||||||
|
$response = $this->actingAs($admin)
|
||||||
|
->post(route('admin.academy.lessons.store'), [
|
||||||
|
'title' => 'Ordered Lesson',
|
||||||
|
'slug' => 'ordered-lesson',
|
||||||
|
'lesson_number' => 3,
|
||||||
|
'course_order' => 3,
|
||||||
|
'series_name' => 'AI Art Basics',
|
||||||
|
'excerpt' => 'Testing ordering field persistence.',
|
||||||
|
'content' => '<p>Lesson body.</p>',
|
||||||
|
'tags' => ['workflow', 'academy'],
|
||||||
|
'difficulty' => 'beginner',
|
||||||
|
'access_level' => 'free',
|
||||||
|
'lesson_type' => 'article',
|
||||||
|
'cover_image' => '',
|
||||||
|
'video_url' => '',
|
||||||
|
'reading_minutes' => 5,
|
||||||
|
'featured' => false,
|
||||||
|
'active' => true,
|
||||||
|
'published_at' => now()->subMinute()->toDateTimeString(),
|
||||||
|
'seo_title' => '',
|
||||||
|
'seo_description' => '',
|
||||||
|
'blocks' => [],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$lesson = AcademyLesson::query()->where('slug', 'ordered-lesson')->firstOrFail();
|
||||||
|
|
||||||
|
$response->assertRedirect(route('admin.academy.lessons.edit', ['academyLesson' => $lesson]));
|
||||||
|
$this->assertDatabaseHas('academy_lessons', [
|
||||||
|
'id' => $lesson->id,
|
||||||
|
'lesson_number' => 3,
|
||||||
|
'course_order' => 3,
|
||||||
|
'series_name' => 'AI Art Basics',
|
||||||
|
]);
|
||||||
|
$this->assertSame(['workflow', 'academy'], $lesson->fresh()->tags);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_admin_lesson_reading_time_is_calculated_from_content(): void
|
||||||
|
{
|
||||||
|
$admin = User::factory()->create(['role' => 'admin']);
|
||||||
|
$body = '<p>' . implode(' ', array_fill(0, 420, 'prompt')) . '</p>';
|
||||||
|
|
||||||
|
$response = $this->actingAs($admin)
|
||||||
|
->post(route('admin.academy.lessons.store'), [
|
||||||
|
'title' => 'Auto Reading Lesson',
|
||||||
|
'slug' => 'auto-reading-lesson',
|
||||||
|
'excerpt' => 'Estimate reading time from content.',
|
||||||
|
'content' => $body,
|
||||||
|
'difficulty' => 'beginner',
|
||||||
|
'access_level' => 'free',
|
||||||
|
'lesson_type' => 'article',
|
||||||
|
'cover_image' => '',
|
||||||
|
'video_url' => '',
|
||||||
|
'reading_minutes' => 1,
|
||||||
|
'featured' => false,
|
||||||
|
'active' => true,
|
||||||
|
'published_at' => now()->subMinute()->toDateTimeString(),
|
||||||
|
'seo_title' => '',
|
||||||
|
'seo_description' => '',
|
||||||
|
'blocks' => [],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$lesson = AcademyLesson::query()->where('slug', 'auto-reading-lesson')->firstOrFail();
|
||||||
|
|
||||||
|
$response->assertRedirect(route('admin.academy.lessons.edit', ['academyLesson' => $lesson]));
|
||||||
|
$this->assertSame(3, (int) $lesson->reading_minutes);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_admin_can_attach_lesson_to_courses_from_lesson_form(): void
|
||||||
|
{
|
||||||
|
$admin = User::factory()->create(['role' => 'admin']);
|
||||||
|
$courseA = AcademyCourse::query()->create([
|
||||||
|
'title' => 'Foundations',
|
||||||
|
'slug' => 'foundations',
|
||||||
|
'access_level' => 'free',
|
||||||
|
'difficulty' => 'beginner',
|
||||||
|
'status' => 'draft',
|
||||||
|
]);
|
||||||
|
$courseB = AcademyCourse::query()->create([
|
||||||
|
'title' => 'Prompt Engineering',
|
||||||
|
'slug' => 'prompt-engineering',
|
||||||
|
'access_level' => 'free',
|
||||||
|
'difficulty' => 'beginner',
|
||||||
|
'status' => 'draft',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->actingAs($admin)
|
||||||
|
->post(route('admin.academy.lessons.store'), [
|
||||||
|
'title' => 'Course Attached Lesson',
|
||||||
|
'slug' => 'course-attached-lesson',
|
||||||
|
'excerpt' => 'Attach this lesson to multiple courses.',
|
||||||
|
'content' => '<p>Lesson body.</p>',
|
||||||
|
'course_ids' => [$courseA->id, $courseB->id],
|
||||||
|
'difficulty' => 'beginner',
|
||||||
|
'access_level' => 'free',
|
||||||
|
'lesson_type' => 'article',
|
||||||
|
'cover_image' => '',
|
||||||
|
'video_url' => '',
|
||||||
|
'reading_minutes' => 5,
|
||||||
|
'featured' => false,
|
||||||
|
'active' => true,
|
||||||
|
'published_at' => now()->subMinute()->toDateTimeString(),
|
||||||
|
'seo_title' => '',
|
||||||
|
'seo_description' => '',
|
||||||
|
'blocks' => [],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$lesson = AcademyLesson::query()->where('slug', 'course-attached-lesson')->firstOrFail();
|
||||||
|
|
||||||
|
$response->assertRedirect(route('admin.academy.lessons.edit', ['academyLesson' => $lesson]));
|
||||||
|
$this->assertSame(2, AcademyCourseLesson::query()->where('lesson_id', $lesson->id)->count());
|
||||||
|
$this->assertDatabaseHas('academy_course_lessons', ['course_id' => $courseA->id, 'lesson_id' => $lesson->id]);
|
||||||
|
$this->assertDatabaseHas('academy_course_lessons', ['course_id' => $courseB->id, 'lesson_id' => $lesson->id]);
|
||||||
|
$this->assertSame(1, $courseA->fresh()->lessons_count_cache);
|
||||||
|
$this->assertSame(1, $courseB->fresh()->lessons_count_cache);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_admin_lesson_edit_form_includes_numbering_and_course_outline_context(): void
|
||||||
|
{
|
||||||
|
$admin = User::factory()->create(['role' => 'admin']);
|
||||||
|
$course = AcademyCourse::query()->create([
|
||||||
|
'title' => 'Lesson Mapping Course',
|
||||||
|
'slug' => 'lesson-mapping-course',
|
||||||
|
'access_level' => 'free',
|
||||||
|
'difficulty' => 'beginner',
|
||||||
|
'status' => 'draft',
|
||||||
|
]);
|
||||||
|
|
||||||
|
AcademyLesson::query()->create([
|
||||||
|
'title' => 'Lesson One',
|
||||||
|
'slug' => 'lesson-one',
|
||||||
|
'lesson_number' => 1,
|
||||||
|
'course_order' => 1,
|
||||||
|
'content' => '<p>Body</p>',
|
||||||
|
'difficulty' => 'beginner',
|
||||||
|
'access_level' => 'free',
|
||||||
|
'lesson_type' => 'article',
|
||||||
|
'reading_minutes' => 5,
|
||||||
|
'active' => true,
|
||||||
|
'published_at' => now()->subMinute(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$currentLesson = AcademyLesson::query()->create([
|
||||||
|
'title' => 'Current Lesson',
|
||||||
|
'slug' => 'current-lesson',
|
||||||
|
'content' => '<p>Body</p>',
|
||||||
|
'difficulty' => 'beginner',
|
||||||
|
'access_level' => 'free',
|
||||||
|
'lesson_type' => 'article',
|
||||||
|
'reading_minutes' => 5,
|
||||||
|
'active' => true,
|
||||||
|
'published_at' => now()->subMinute(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$otherCourseLesson = AcademyLesson::query()->create([
|
||||||
|
'title' => 'Lesson Three',
|
||||||
|
'slug' => 'lesson-three',
|
||||||
|
'lesson_number' => 3,
|
||||||
|
'course_order' => 3,
|
||||||
|
'content' => '<p>Body</p>',
|
||||||
|
'difficulty' => 'beginner',
|
||||||
|
'access_level' => 'free',
|
||||||
|
'lesson_type' => 'article',
|
||||||
|
'reading_minutes' => 5,
|
||||||
|
'active' => true,
|
||||||
|
'published_at' => now()->subMinute(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
AcademyCourseLesson::query()->create([
|
||||||
|
'course_id' => $course->id,
|
||||||
|
'lesson_id' => $otherCourseLesson->id,
|
||||||
|
'order_num' => 0,
|
||||||
|
'is_required' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
AcademyCourseLesson::query()->create([
|
||||||
|
'course_id' => $course->id,
|
||||||
|
'lesson_id' => $currentLesson->id,
|
||||||
|
'order_num' => 1,
|
||||||
|
'is_required' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($admin)
|
||||||
|
->get(route('admin.academy.lessons.edit', ['academyLesson' => $currentLesson]))
|
||||||
|
->assertOk()
|
||||||
|
->assertInertia(fn (AssertableInertia $page) => $page
|
||||||
|
->component('Admin/Academy/CrudForm')
|
||||||
|
->where('resource', 'lessons')
|
||||||
|
->where('record.course_ids.0', (string) $course->id)
|
||||||
|
->where('editorContext.currentLessonId', $currentLesson->id)
|
||||||
|
->where('editorContext.numbering.lesson_number.suggested', 2)
|
||||||
|
->where('editorContext.numbering.lesson_number.missing.0', 2)
|
||||||
|
->where('editorContext.numbering.course_order.suggested', 2)
|
||||||
|
->where('editorContext.courses.0.lesson_count', 2)
|
||||||
|
->where('editorContext.courses.0.attach_url', route('admin.academy.courses.lessons.attach', ['academyCourse' => $course]))
|
||||||
|
->where('editorContext.courses.0.reorder_url', route('admin.academy.courses.reorder', ['academyCourse' => $course]))
|
||||||
|
->where('editorContext.courses.0.lessons.0.order_num', 0)
|
||||||
|
->where('editorContext.courses.0.lessons.1.is_current', true)
|
||||||
|
->where('editorContext.courses.0.lessons.1.destroy_url', fn ($value) => is_string($value) && str_contains($value, "/moderation/academy/courses/{$course->id}/lessons/")));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_admin_can_store_article_cover_image(): void
|
||||||
|
{
|
||||||
|
$admin = User::factory()->create(['role' => 'admin']);
|
||||||
|
|
||||||
|
$response = $this->actingAs($admin)
|
||||||
|
->post(route('admin.academy.lessons.store'), [
|
||||||
|
'title' => 'Lesson With Article Cover',
|
||||||
|
'slug' => 'lesson-with-article-cover',
|
||||||
|
'excerpt' => 'Testing article cover persistence.',
|
||||||
|
'content' => '<p>Lesson body.</p>',
|
||||||
|
'tags' => ['cover', 'article'],
|
||||||
|
'difficulty' => 'beginner',
|
||||||
|
'access_level' => 'free',
|
||||||
|
'lesson_type' => 'article',
|
||||||
|
'cover_image' => 'academy/lessons/covers/hero-cover.webp',
|
||||||
|
'article_cover_image' => 'academy/lessons/covers/article-cover.webp',
|
||||||
|
'video_url' => '',
|
||||||
|
'reading_minutes' => 5,
|
||||||
|
'featured' => false,
|
||||||
|
'active' => true,
|
||||||
|
'published_at' => now()->subMinute()->toDateTimeString(),
|
||||||
|
'seo_title' => '',
|
||||||
|
'seo_description' => '',
|
||||||
|
'blocks' => [],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$lesson = AcademyLesson::query()->where('slug', 'lesson-with-article-cover')->firstOrFail();
|
||||||
|
|
||||||
|
$response->assertRedirect(route('admin.academy.lessons.edit', ['academyLesson' => $lesson]));
|
||||||
|
$this->assertDatabaseHas('academy_lessons', [
|
||||||
|
'id' => $lesson->id,
|
||||||
|
'article_cover_image' => 'academy/lessons/covers/article-cover.webp',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
public function test_admin_can_add_ai_comparison_result_to_existing_lesson(): void
|
public function test_admin_can_add_ai_comparison_result_to_existing_lesson(): void
|
||||||
{
|
{
|
||||||
$admin = User::factory()->create(['role' => 'admin']);
|
$admin = User::factory()->create(['role' => 'admin']);
|
||||||
@@ -258,6 +913,107 @@ final class AcademyAdminTest extends TestCase
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_admin_markdown_update_regenerates_lesson_html(): void
|
||||||
|
{
|
||||||
|
$admin = User::factory()->create(['role' => 'admin']);
|
||||||
|
$lesson = AcademyLesson::query()->create([
|
||||||
|
'title' => 'Markdown Update Lesson',
|
||||||
|
'slug' => 'markdown-update-lesson',
|
||||||
|
'content' => '<p>Old body</p>',
|
||||||
|
'difficulty' => 'beginner',
|
||||||
|
'access_level' => 'free',
|
||||||
|
'lesson_type' => 'article',
|
||||||
|
'active' => true,
|
||||||
|
'published_at' => now()->subMinute(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$markdown = <<<'MD'
|
||||||
|
## Prompt checklist
|
||||||
|
|
||||||
|
1. Start with the scene.
|
||||||
|
2. Add the style.
|
||||||
|
|
||||||
|
> Keep one clear subject.
|
||||||
|
MD;
|
||||||
|
|
||||||
|
$this->actingAs($admin)
|
||||||
|
->patch(route('admin.academy.lessons.update', ['academyLesson' => $lesson]), [
|
||||||
|
'title' => $lesson->title,
|
||||||
|
'slug' => $lesson->slug,
|
||||||
|
'excerpt' => '',
|
||||||
|
'content' => '',
|
||||||
|
'content_markdown' => $markdown,
|
||||||
|
'difficulty' => $lesson->difficulty,
|
||||||
|
'access_level' => $lesson->access_level,
|
||||||
|
'lesson_type' => $lesson->lesson_type,
|
||||||
|
'cover_image' => '',
|
||||||
|
'video_url' => '',
|
||||||
|
'reading_minutes' => 5,
|
||||||
|
'featured' => false,
|
||||||
|
'active' => true,
|
||||||
|
'published_at' => now()->subMinute()->toDateTimeString(),
|
||||||
|
'seo_title' => '',
|
||||||
|
'seo_description' => '',
|
||||||
|
'blocks' => [],
|
||||||
|
])
|
||||||
|
->assertRedirect(route('admin.academy.lessons.edit', ['academyLesson' => $lesson]));
|
||||||
|
|
||||||
|
$lesson->refresh();
|
||||||
|
|
||||||
|
$this->assertSame($markdown, $lesson->content_markdown);
|
||||||
|
$this->assertStringContainsString('<h2>Prompt checklist</h2>', (string) $lesson->content);
|
||||||
|
$this->assertStringContainsString('<ol>', (string) $lesson->content);
|
||||||
|
$this->assertStringContainsString('<blockquote>', (string) $lesson->content);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_admin_html_lesson_update_does_not_rewrite_legacy_html_from_generated_markdown(): void
|
||||||
|
{
|
||||||
|
$admin = User::factory()->create(['role' => 'admin']);
|
||||||
|
$legacyHtml = '<h2>Final note</h2><p><strong>Prompting</strong> is a creative skill.</p><ul><li>Keep the best result</li><li>Prepare it</li><li>Present it with care</li></ul>';
|
||||||
|
|
||||||
|
$lesson = AcademyLesson::query()->create([
|
||||||
|
'title' => 'Legacy HTML Lesson',
|
||||||
|
'slug' => 'legacy-html-lesson',
|
||||||
|
'excerpt' => 'Legacy HTML body.',
|
||||||
|
'content' => $legacyHtml,
|
||||||
|
'content_markdown' => null,
|
||||||
|
'difficulty' => 'beginner',
|
||||||
|
'access_level' => 'free',
|
||||||
|
'lesson_type' => 'article',
|
||||||
|
'active' => true,
|
||||||
|
'published_at' => now()->subMinute(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($admin)
|
||||||
|
->patch(route('admin.academy.lessons.update', ['academyLesson' => $lesson]), [
|
||||||
|
'title' => $lesson->title,
|
||||||
|
'slug' => $lesson->slug,
|
||||||
|
'excerpt' => 'Updated excerpt only.',
|
||||||
|
'content' => $legacyHtml,
|
||||||
|
'content_markdown' => '',
|
||||||
|
'content_source' => 'html',
|
||||||
|
'difficulty' => $lesson->difficulty,
|
||||||
|
'access_level' => $lesson->access_level,
|
||||||
|
'lesson_type' => $lesson->lesson_type,
|
||||||
|
'cover_image' => '',
|
||||||
|
'video_url' => '',
|
||||||
|
'reading_minutes' => 5,
|
||||||
|
'featured' => false,
|
||||||
|
'active' => true,
|
||||||
|
'published_at' => now()->subMinute()->toDateTimeString(),
|
||||||
|
'seo_title' => '',
|
||||||
|
'seo_description' => '',
|
||||||
|
'blocks' => [],
|
||||||
|
])
|
||||||
|
->assertRedirect(route('admin.academy.lessons.edit', ['academyLesson' => $lesson]));
|
||||||
|
|
||||||
|
$lesson->refresh();
|
||||||
|
|
||||||
|
$this->assertSame('Updated excerpt only.', $lesson->excerpt);
|
||||||
|
$this->assertSame($legacyHtml, $lesson->content);
|
||||||
|
$this->assertNull($lesson->content_markdown);
|
||||||
|
}
|
||||||
|
|
||||||
public function test_ai_comparison_score_must_stay_in_range(): void
|
public function test_ai_comparison_score_must_stay_in_range(): void
|
||||||
{
|
{
|
||||||
$admin = User::factory()->create(['role' => 'admin']);
|
$admin = User::factory()->create(['role' => 'admin']);
|
||||||
|
|||||||
@@ -23,8 +23,9 @@ use Klevze\ControlPanel\Models\Admin\AdminVerification;
|
|||||||
|
|
||||||
uses(RefreshDatabase::class);
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
function createControlPanelAdmin(): User
|
if (! function_exists('createControlPanelAdmin')) {
|
||||||
{
|
function createControlPanelAdmin(): User
|
||||||
|
{
|
||||||
$admin = User::factory()->create(['role' => 'admin']);
|
$admin = User::factory()->create(['role' => 'admin']);
|
||||||
$admin->forceFill([
|
$admin->forceFill([
|
||||||
'isAdmin' => true,
|
'isAdmin' => true,
|
||||||
@@ -34,6 +35,7 @@ function createControlPanelAdmin(): User
|
|||||||
AdminVerification::createForUser($admin->fresh());
|
AdminVerification::createForUser($admin->fresh());
|
||||||
|
|
||||||
return $admin->fresh();
|
return $admin->fresh();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
it('loads the cpad moderation list and detail screens for admins', function (): void {
|
it('loads the cpad moderation list and detail screens for admins', function (): void {
|
||||||
|
|||||||
@@ -10,8 +10,6 @@ use App\Services\AiBiography\AiBiographyInputBuilder;
|
|||||||
use App\Services\AiBiography\AiBiographyService;
|
use App\Services\AiBiography\AiBiographyService;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Inertia\Testing\AssertableInertia;
|
use Inertia\Testing\AssertableInertia;
|
||||||
use Klevze\ControlPanel\Core\Structs\MenuRootItem;
|
|
||||||
use Klevze\ControlPanel\Framework\Core\Menu as ControlPanelMenu;
|
|
||||||
use Klevze\ControlPanel\Models\Admin\AdminVerification;
|
use Klevze\ControlPanel\Models\Admin\AdminVerification;
|
||||||
|
|
||||||
uses(RefreshDatabase::class);
|
uses(RefreshDatabase::class);
|
||||||
@@ -77,17 +75,17 @@ it('renders the ai biography admin index with records and stats', function (): v
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$this->actingAs($admin)->actingAs($admin, 'controlpanel')
|
$this->actingAs($admin)->actingAs($admin, 'controlpanel')
|
||||||
->get(route('cp.ai-biography.index'))
|
->get(route('admin.cp.ai-biography.index'))
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertInertia(fn (AssertableInertia $page) => $page
|
->assertInertia(fn (AssertableInertia $page) => $page
|
||||||
->component('Moderation/AiBiographyAdmin')
|
->component('Admin/AiBiography')
|
||||||
->where('stats.total_records', 2)
|
->where('stats.total_records', 2)
|
||||||
->where('stats.needs_review', 1)
|
->where('stats.needs_review', 1)
|
||||||
->where('stats.failed', 1)
|
->where('stats.failed', 1)
|
||||||
->where('records.data.0.user.username', 'bioadmin')
|
->where('records.data.0.user.username', 'bioadmin')
|
||||||
->where('records.data.0.status', CreatorAiBiography::STATUS_FAILED)
|
->where('records.data.0.status', CreatorAiBiography::STATUS_FAILED)
|
||||||
->where('records.data.1.status', CreatorAiBiography::STATUS_NEEDS_REVIEW)
|
->where('records.data.1.status', CreatorAiBiography::STATUS_NEEDS_REVIEW)
|
||||||
->where('endpoints.rebuildPattern', route('cp.ai-biography.rebuild', ['user' => '__USER__'])));
|
->where('endpoints.rebuildPattern', route('admin.cp.ai-biography.rebuild', ['user' => '__USER__'])));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('allows controlpanel-only admins to open the ai biography admin page', function (): void {
|
it('allows controlpanel-only admins to open the ai biography admin page', function (): void {
|
||||||
@@ -102,30 +100,11 @@ it('allows controlpanel-only admins to open the ai biography admin page', functi
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('registers the ai biography entry in the cpad artworks menu', function (): void {
|
it('registers the ai biography entry in the cpad artworks menu', function (): void {
|
||||||
$admin = aiBiographyAdminUser();
|
$artworksPlugin = file_get_contents(base_path('packages/klevze/Plugins/Artworks/ServiceProvider.php'));
|
||||||
$creator = User::factory()->create(['username' => 'menubio']);
|
$adminLayout = file_get_contents(base_path('resources/js/Layouts/AdminLayout.jsx'));
|
||||||
biographyRecord($creator, [
|
|
||||||
'needs_review' => true,
|
|
||||||
'status' => CreatorAiBiography::STATUS_NEEDS_REVIEW,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->actingAs($admin)->actingAs($admin, 'controlpanel')
|
expect($artworksPlugin)->toContain('admin.cp.ai-biography.index')
|
||||||
->get(route('cp.ai-biography.index'))
|
->and($adminLayout)->toContain("/moderation/ai-biography");
|
||||||
->assertOk();
|
|
||||||
|
|
||||||
$sidebarMenu = collect(app(ControlPanelMenu::class)->getSidebarMenu());
|
|
||||||
|
|
||||||
$artworksRoot = $sidebarMenu
|
|
||||||
->first(fn ($item): bool => $item instanceof MenuRootItem && $item->getName() === 'Artworks');
|
|
||||||
|
|
||||||
expect($artworksRoot)->toBeInstanceOf(MenuRootItem::class);
|
|
||||||
|
|
||||||
$aiBiographyItem = collect($artworksRoot->getItems())
|
|
||||||
->first(fn ($item): bool => str_starts_with((string) ($item->name ?? ''), 'AI Biographies'));
|
|
||||||
|
|
||||||
expect($aiBiographyItem)->not->toBeNull()
|
|
||||||
->and($aiBiographyItem->mainRoute)->toBe('cp.ai-biography.index')
|
|
||||||
->and($aiBiographyItem->icon)->toBe('fa-solid fa-feather-pointed');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rebuilds an existing active biography through the admin surface', function (): void {
|
it('rebuilds an existing active biography through the admin surface', function (): void {
|
||||||
@@ -158,7 +137,7 @@ it('rebuilds an existing active biography through the admin surface', function (
|
|||||||
app()->instance(AiBiographyService::class, new AiBiographyService(new AiBiographyInputBuilder(), $generator));
|
app()->instance(AiBiographyService::class, new AiBiographyService(new AiBiographyInputBuilder(), $generator));
|
||||||
|
|
||||||
$this->actingAs($admin)->actingAs($admin, 'controlpanel')
|
$this->actingAs($admin)->actingAs($admin, 'controlpanel')
|
||||||
->postJson(route('cp.ai-biography.rebuild', ['user' => $creator->id]))
|
->postJson(route('admin.cp.ai-biography.rebuild', ['user' => $creator->id]))
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertJsonPath('success', true)
|
->assertJsonPath('success', true)
|
||||||
->assertJsonPath('message', 'Biography rebuild completed.');
|
->assertJsonPath('message', 'Biography rebuild completed.');
|
||||||
@@ -176,7 +155,7 @@ it('allows admins to approve flag and toggle visibility on biography records', f
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$this->actingAs($admin)->actingAs($admin, 'controlpanel')
|
$this->actingAs($admin)->actingAs($admin, 'controlpanel')
|
||||||
->postJson(route('cp.ai-biography.approve', ['biography' => $record->id]))
|
->postJson(route('admin.cp.ai-biography.approve', ['biography' => $record->id]))
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertJsonPath('success', true);
|
->assertJsonPath('success', true);
|
||||||
|
|
||||||
@@ -184,21 +163,21 @@ it('allows admins to approve flag and toggle visibility on biography records', f
|
|||||||
->and($record->fresh()->status)->toBe(CreatorAiBiography::STATUS_APPROVED);
|
->and($record->fresh()->status)->toBe(CreatorAiBiography::STATUS_APPROVED);
|
||||||
|
|
||||||
$this->actingAs($admin)->actingAs($admin, 'controlpanel')
|
$this->actingAs($admin)->actingAs($admin, 'controlpanel')
|
||||||
->postJson(route('cp.ai-biography.hide', ['biography' => $record->id]))
|
->postJson(route('admin.cp.ai-biography.hide', ['biography' => $record->id]))
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertJsonPath('success', true);
|
->assertJsonPath('success', true);
|
||||||
|
|
||||||
expect($record->fresh()->is_hidden)->toBeTrue();
|
expect($record->fresh()->is_hidden)->toBeTrue();
|
||||||
|
|
||||||
$this->actingAs($admin)->actingAs($admin, 'controlpanel')
|
$this->actingAs($admin)->actingAs($admin, 'controlpanel')
|
||||||
->postJson(route('cp.ai-biography.show', ['biography' => $record->id]))
|
->postJson(route('admin.cp.ai-biography.show', ['biography' => $record->id]))
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertJsonPath('success', true);
|
->assertJsonPath('success', true);
|
||||||
|
|
||||||
expect($record->fresh()->is_hidden)->toBeFalse();
|
expect($record->fresh()->is_hidden)->toBeFalse();
|
||||||
|
|
||||||
$this->actingAs($admin)->actingAs($admin, 'controlpanel')
|
$this->actingAs($admin)->actingAs($admin, 'controlpanel')
|
||||||
->postJson(route('cp.ai-biography.flag', ['biography' => $record->id]))
|
->postJson(route('admin.cp.ai-biography.flag', ['biography' => $record->id]))
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertJsonPath('success', true);
|
->assertJsonPath('success', true);
|
||||||
|
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ use App\Models\Category;
|
|||||||
use App\Models\ContentType;
|
use App\Models\ContentType;
|
||||||
use App\Models\Tag;
|
use App\Models\Tag;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Support\Seo\SeoFactory;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
use Inertia\Testing\AssertableInertia;
|
||||||
|
|
||||||
uses(RefreshDatabase::class);
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
@@ -50,15 +52,90 @@ it('renders JSON-LD structured data on published artwork page', function () {
|
|||||||
$tagB->id => ['source' => 'user', 'confidence' => 0.8],
|
$tagB->id => ['source' => 'user', 'confidence' => 0.8],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$html = view('artworks.show', ['artwork' => $artwork])->render();
|
$artwork->load(['user', 'tags', 'categories.contentType']);
|
||||||
|
|
||||||
expect($html)
|
expect(json_encode(app(SeoFactory::class)->artwork(
|
||||||
->toContain('application/ld+json')
|
$artwork,
|
||||||
|
[
|
||||||
|
'md' => ['url' => 'https://files.skinbase.org/md/schema-ready.webp', 'width' => 600, 'height' => 400],
|
||||||
|
'lg' => ['url' => 'https://files.skinbase.org/lg/schema-ready.webp', 'width' => 1200, 'height' => 800],
|
||||||
|
'xl' => ['url' => 'https://files.skinbase.org/xl/schema-ready.webp', 'width' => 2400, 'height' => 1600],
|
||||||
|
],
|
||||||
|
route('art.show', ['id' => $artwork->id, 'slug' => $artwork->slug]),
|
||||||
|
)->toArray()['json_ld'], JSON_UNESCAPED_SLASHES))
|
||||||
->toContain('"@type":"ImageObject"')
|
->toContain('"@type":"ImageObject"')
|
||||||
->toContain('"name":"Schema Ready Artwork"')
|
->toContain('"name":"Schema Ready Artwork"')
|
||||||
->toContain('"keywords":["neon","city"]');
|
->toContain('"keywords":["neon","city"]');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('builds artwork seo data with breadcrumb and image-license metadata', function () {
|
||||||
|
$user = User::factory()->create(['name' => 'Schema Breadcrumb Author']);
|
||||||
|
|
||||||
|
$contentType = ContentType::create([
|
||||||
|
'name' => 'Photography',
|
||||||
|
'slug' => 'photography',
|
||||||
|
'description' => 'Photography content',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$parentCategory = Category::create([
|
||||||
|
'content_type_id' => $contentType->id,
|
||||||
|
'parent_id' => null,
|
||||||
|
'name' => 'Nature',
|
||||||
|
'slug' => 'nature',
|
||||||
|
'description' => 'Nature works',
|
||||||
|
'is_active' => true,
|
||||||
|
'sort_order' => 0,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$category = Category::create([
|
||||||
|
'content_type_id' => $contentType->id,
|
||||||
|
'parent_id' => $parentCategory->id,
|
||||||
|
'name' => 'Forest',
|
||||||
|
'slug' => 'forest',
|
||||||
|
'description' => 'Forest works',
|
||||||
|
'is_active' => true,
|
||||||
|
'sort_order' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$artwork = Artwork::factory()->create([
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'title' => 'Licensed Artwork',
|
||||||
|
'slug' => 'licensed-artwork',
|
||||||
|
'description' => 'Artwork description for breadcrumb schema test.',
|
||||||
|
'published_at' => now()->subMinute(),
|
||||||
|
'is_public' => true,
|
||||||
|
'is_approved' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$artwork->categories()->attach($category->id);
|
||||||
|
$artwork->load(['user', 'tags', 'categories.contentType', 'categories.parent.contentType']);
|
||||||
|
$artwork->setAttribute('license_url', 'https://skinbase.org/licenses/custom-license');
|
||||||
|
|
||||||
|
$seo = app(SeoFactory::class)->artwork(
|
||||||
|
$artwork,
|
||||||
|
[
|
||||||
|
'md' => ['url' => 'https://files.skinbase.org/md/licensed.webp', 'width' => 600, 'height' => 400],
|
||||||
|
'lg' => ['url' => 'https://files.skinbase.org/lg/licensed.webp', 'width' => 1200, 'height' => 800],
|
||||||
|
'xl' => ['url' => 'https://files.skinbase.org/xl/licensed.webp', 'width' => 2400, 'height' => 1600],
|
||||||
|
],
|
||||||
|
route('art.show', ['id' => $artwork->id, 'slug' => $artwork->slug]),
|
||||||
|
[
|
||||||
|
['name' => 'Photography', 'url' => url('/photography')],
|
||||||
|
['name' => 'Nature', 'url' => url('/photography/nature')],
|
||||||
|
['name' => 'Forest', 'url' => url('/photography/nature/forest')],
|
||||||
|
['name' => 'Licensed Artwork', 'url' => route('art.show', ['id' => $artwork->id, 'slug' => $artwork->slug])],
|
||||||
|
],
|
||||||
|
)->toArray();
|
||||||
|
|
||||||
|
expect(json_encode($seo['json_ld'], JSON_UNESCAPED_SLASHES))
|
||||||
|
->toContain('"@type":"ImageObject"')
|
||||||
|
->toContain('"creditText":"Schema Breadcrumb Author"')
|
||||||
|
->toContain('"license":"https://skinbase.org/licenses/custom-license"')
|
||||||
|
->toContain('"@type":"BreadcrumbList"')
|
||||||
|
->toContain('"name":"Photography"')
|
||||||
|
->toContain('"name":"Forest"');
|
||||||
|
});
|
||||||
|
|
||||||
it('renders JSON-LD via routed artwork show endpoint', function () {
|
it('renders JSON-LD via routed artwork show endpoint', function () {
|
||||||
$user = User::factory()->create(['name' => 'Schema Route Author']);
|
$user = User::factory()->create(['name' => 'Schema Route Author']);
|
||||||
|
|
||||||
@@ -109,9 +186,16 @@ it('renders JSON-LD via routed artwork show endpoint', function () {
|
|||||||
|
|
||||||
$response = $this->get($url);
|
$response = $this->get($url);
|
||||||
|
|
||||||
$response->assertOk();
|
$response->assertOk()
|
||||||
$response->assertSee('application/ld+json', false);
|
->assertInertia(fn (AssertableInertia $page) => $page
|
||||||
$response->assertSee('"@type":"ImageObject"', false);
|
->component('ArtworkPage')
|
||||||
$response->assertSee('"name":"Schema Route Artwork"', false);
|
->where('seo.title', fn (string $title): bool => str_contains($title, 'Schema Route Artwork'))
|
||||||
$response->assertSee('"keywords":["route-tag"]', false);
|
->where('seo.json_ld', function ($schemas): bool {
|
||||||
|
$json = json_encode($schemas, JSON_UNESCAPED_SLASHES);
|
||||||
|
|
||||||
|
return str_contains($json, '"@type":"ImageObject"')
|
||||||
|
&& str_contains($json, '"@type":"BreadcrumbList"')
|
||||||
|
&& str_contains($json, '"name":"Schema Route Artwork"')
|
||||||
|
&& str_contains($json, '"keywords":["route-tag"]');
|
||||||
|
}));
|
||||||
});
|
});
|
||||||
|
|||||||
83
tests/Feature/BrowseGalleryStructuredDataTest.php
Normal file
83
tests/Feature/BrowseGalleryStructuredDataTest.php
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Models\Category;
|
||||||
|
use App\Models\ContentType;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
it('renders content-type landing pages with collection, image gallery, breadcrumb, and item list structured data', function (): void {
|
||||||
|
$pages = [
|
||||||
|
[
|
||||||
|
'name' => 'Wallpapers',
|
||||||
|
'slug' => 'wallpapers',
|
||||||
|
'description' => 'Discover desktop and mobile wallpapers from the Skinbase creative community.',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'name' => 'Skins',
|
||||||
|
'slug' => 'skins',
|
||||||
|
'description' => 'Browse classic and modern skins from the Skinbase creative community.',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'name' => 'Digital Art',
|
||||||
|
'slug' => 'digital-art',
|
||||||
|
'description' => 'Explore digital art from the Skinbase creative community.',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'name' => 'Other',
|
||||||
|
'slug' => 'other',
|
||||||
|
'description' => 'Discover other creative works shared on Skinbase.',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($pages as $page) {
|
||||||
|
ContentType::query()->create($page);
|
||||||
|
|
||||||
|
$html = $this->get('/' . $page['slug'])
|
||||||
|
->assertOk()
|
||||||
|
->getContent();
|
||||||
|
|
||||||
|
expect($html)
|
||||||
|
->toContain('application/ld+json')
|
||||||
|
->toContain('CollectionPage')
|
||||||
|
->toContain('ImageGallery')
|
||||||
|
->toContain('BreadcrumbList')
|
||||||
|
->toContain('ItemList')
|
||||||
|
->toContain($page['name'])
|
||||||
|
->toContain('/explore');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders category discovery pages with collection, image gallery, breadcrumb, and item list structured data', function (): void {
|
||||||
|
$contentType = ContentType::query()->create([
|
||||||
|
'name' => 'Wallpapers',
|
||||||
|
'slug' => 'wallpapers',
|
||||||
|
'description' => 'Discover desktop and mobile wallpapers from the Skinbase creative community.',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$category = Category::query()->create([
|
||||||
|
'content_type_id' => $contentType->id,
|
||||||
|
'parent_id' => null,
|
||||||
|
'name' => 'Fantasy',
|
||||||
|
'slug' => 'fantasy',
|
||||||
|
'description' => 'Fantasy wallpapers and scenes.',
|
||||||
|
'is_active' => true,
|
||||||
|
'sort_order' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$html = $this->get('/wallpapers/' . $category->slug)
|
||||||
|
->assertOk()
|
||||||
|
->getContent();
|
||||||
|
|
||||||
|
expect($html)
|
||||||
|
->toContain('application/ld+json')
|
||||||
|
->toContain('CollectionPage')
|
||||||
|
->toContain('ImageGallery')
|
||||||
|
->toContain('BreadcrumbList')
|
||||||
|
->toContain('ItemList')
|
||||||
|
->toContain('Fantasy')
|
||||||
|
->toContain('/explore')
|
||||||
|
->toContain('/wallpapers');
|
||||||
|
});
|
||||||
@@ -15,8 +15,9 @@ use Klevze\ControlPanel\Framework\Core\Menu as ControlPanelMenu;
|
|||||||
|
|
||||||
uses(RefreshDatabase::class);
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
function createControlPanelAdmin(): User
|
if (! function_exists('createControlPanelAdmin')) {
|
||||||
{
|
function createControlPanelAdmin(): User
|
||||||
|
{
|
||||||
$admin = User::factory()->create(['role' => 'admin']);
|
$admin = User::factory()->create(['role' => 'admin']);
|
||||||
$admin->forceFill([
|
$admin->forceFill([
|
||||||
'isAdmin' => true,
|
'isAdmin' => true,
|
||||||
@@ -26,6 +27,7 @@ function createControlPanelAdmin(): User
|
|||||||
AdminVerification::createForUser($admin->fresh());
|
AdminVerification::createForUser($admin->fresh());
|
||||||
|
|
||||||
return $admin->fresh();
|
return $admin->fresh();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function adminArtwork(array $attributes = []): Artwork
|
function adminArtwork(array $attributes = []): Artwork
|
||||||
|
|||||||
194
tests/Feature/ForumDiscussionStructuredDataTest.php
Normal file
194
tests/Feature/ForumDiscussionStructuredDataTest.php
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Http\Middleware\HandleInertiaRequests;
|
||||||
|
use App\Models\User;
|
||||||
|
use cPad\Plugins\Forum\Models\ForumBoard;
|
||||||
|
use cPad\Plugins\Forum\Models\ForumCategory;
|
||||||
|
use cPad\Plugins\Forum\Models\ForumPost;
|
||||||
|
use cPad\Plugins\Forum\Models\ForumTopic;
|
||||||
|
|
||||||
|
beforeEach(function (): void {
|
||||||
|
$this->withoutMiddleware(HandleInertiaRequests::class);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders discussion forum structured data on forum topic pages', function (): void {
|
||||||
|
$author = User::query()->create([
|
||||||
|
'username' => 'forumauthor',
|
||||||
|
'username_changed_at' => now()->subDays(120),
|
||||||
|
'last_username_change_at' => now()->subDays(120),
|
||||||
|
'onboarding_step' => 'complete',
|
||||||
|
'name' => 'Forum Author',
|
||||||
|
'email' => 'forumauthor@example.com',
|
||||||
|
'email_verified_at' => now(),
|
||||||
|
'password' => 'password',
|
||||||
|
'is_active' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$replier = User::query()->create([
|
||||||
|
'username' => 'forumreplier',
|
||||||
|
'username_changed_at' => now()->subDays(120),
|
||||||
|
'last_username_change_at' => now()->subDays(120),
|
||||||
|
'onboarding_step' => 'complete',
|
||||||
|
'name' => 'Forum Replier',
|
||||||
|
'email' => 'forumreplier@example.com',
|
||||||
|
'email_verified_at' => now(),
|
||||||
|
'password' => 'password',
|
||||||
|
'is_active' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$category = ForumCategory::query()->create([
|
||||||
|
'name' => 'Forum SEO',
|
||||||
|
'title' => 'Forum SEO',
|
||||||
|
'slug' => 'forum-seo',
|
||||||
|
'description' => 'SEO discussion category',
|
||||||
|
'is_active' => true,
|
||||||
|
'position' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$board = ForumBoard::query()->create([
|
||||||
|
'category_id' => $category->id,
|
||||||
|
'title' => 'Technical SEO',
|
||||||
|
'slug' => 'technical-seo',
|
||||||
|
'description' => 'Technical SEO board',
|
||||||
|
'is_active' => true,
|
||||||
|
'position' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$topic = ForumTopic::query()->create([
|
||||||
|
'board_id' => $board->id,
|
||||||
|
'user_id' => $author->id,
|
||||||
|
'title' => 'Structured data for forums',
|
||||||
|
'slug' => 'structured-data-for-forums',
|
||||||
|
'views' => 42,
|
||||||
|
'replies_count' => 1,
|
||||||
|
'last_post_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
ForumPost::query()->create([
|
||||||
|
'thread_id' => $topic->id,
|
||||||
|
'topic_id' => $topic->id,
|
||||||
|
'user_id' => $author->id,
|
||||||
|
'content' => 'Original post body about Google discussion structured data.',
|
||||||
|
'created_at' => now()->subHour(),
|
||||||
|
'updated_at' => now()->subHour(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$reply = ForumPost::query()->create([
|
||||||
|
'thread_id' => $topic->id,
|
||||||
|
'topic_id' => $topic->id,
|
||||||
|
'user_id' => $replier->id,
|
||||||
|
'content' => 'Reply with implementation details for the forum page.',
|
||||||
|
'created_at' => now()->subMinutes(15),
|
||||||
|
'updated_at' => now()->subMinutes(15),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->get(route('forum.topic.show', ['topic' => $topic->slug]));
|
||||||
|
|
||||||
|
$response
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('application/ld+json', false)
|
||||||
|
->assertSee('DiscussionForumPosting', false)
|
||||||
|
->assertSee('itemtype="https://schema.org/DiscussionForumPosting"', false)
|
||||||
|
->assertSee('itemprop="comment"', false)
|
||||||
|
->assertSee('itemtype="https://schema.org/Comment"', false)
|
||||||
|
->assertSee('itemprop="headline"', false)
|
||||||
|
->assertSee('itemprop="mainEntityOfPage"', false)
|
||||||
|
->assertSee('Structured data for forums', false)
|
||||||
|
->assertSee('Original post body about Google discussion structured data.', false)
|
||||||
|
->assertSee('Reply with implementation details for the forum page.', false)
|
||||||
|
->assertSee(route('forum.topic.show', ['topic' => $topic->slug]) . '#post-' . $reply->id, false)
|
||||||
|
->assertSee(route('profile.show', ['username' => 'forumauthor']), false)
|
||||||
|
->assertSee(route('profile.show', ['username' => 'forumreplier']), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders item list microdata on forum board pages', function (): void {
|
||||||
|
$author = User::query()->create([
|
||||||
|
'username' => 'boardauthor',
|
||||||
|
'username_changed_at' => now()->subDays(120),
|
||||||
|
'last_username_change_at' => now()->subDays(120),
|
||||||
|
'onboarding_step' => 'complete',
|
||||||
|
'name' => 'Board Author',
|
||||||
|
'email' => 'boardauthor@example.com',
|
||||||
|
'email_verified_at' => now(),
|
||||||
|
'password' => 'password',
|
||||||
|
'is_active' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$category = ForumCategory::query()->create([
|
||||||
|
'name' => 'Forum Boards',
|
||||||
|
'title' => 'Forum Boards',
|
||||||
|
'slug' => 'forum-boards',
|
||||||
|
'description' => 'Board category',
|
||||||
|
'is_active' => true,
|
||||||
|
'position' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$board = ForumBoard::query()->create([
|
||||||
|
'category_id' => $category->id,
|
||||||
|
'title' => 'Board Microdata',
|
||||||
|
'slug' => 'board-microdata',
|
||||||
|
'description' => 'Board microdata description',
|
||||||
|
'is_active' => true,
|
||||||
|
'position' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$topic = ForumTopic::query()->create([
|
||||||
|
'board_id' => $board->id,
|
||||||
|
'user_id' => $author->id,
|
||||||
|
'title' => 'Board topic title',
|
||||||
|
'slug' => 'board-topic-title',
|
||||||
|
'replies_count' => 2,
|
||||||
|
'created_at' => now()->subHour(),
|
||||||
|
'last_post_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
ForumPost::query()->create([
|
||||||
|
'thread_id' => $topic->id,
|
||||||
|
'topic_id' => $topic->id,
|
||||||
|
'user_id' => $author->id,
|
||||||
|
'content' => 'Board topic opening post content.',
|
||||||
|
'created_at' => now()->subHour(),
|
||||||
|
'updated_at' => now()->subHour(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->get(route('forum.board.show', ['boardSlug' => $board->slug]))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('itemtype="https://schema.org/CollectionPage"', false)
|
||||||
|
->assertSee('itemtype="https://schema.org/ItemList"', false)
|
||||||
|
->assertSee('itemtype="https://schema.org/ListItem"', false)
|
||||||
|
->assertSee('itemtype="https://schema.org/DiscussionForumPosting"', false)
|
||||||
|
->assertSee('itemprop="datePublished"', false)
|
||||||
|
->assertSee('Board topic title', false)
|
||||||
|
->assertSee(route('forum.topic.show', ['topic' => $topic->slug]), false)
|
||||||
|
->assertSee(route('profile.show', ['username' => 'boardauthor']), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders item list microdata on forum section pages', function (): void {
|
||||||
|
$category = ForumCategory::query()->create([
|
||||||
|
'name' => 'Photography',
|
||||||
|
'title' => 'Photography',
|
||||||
|
'slug' => 'photography-section-microdata',
|
||||||
|
'description' => 'Photography category',
|
||||||
|
'is_active' => true,
|
||||||
|
'position' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$board = ForumBoard::query()->create([
|
||||||
|
'category_id' => $category->id,
|
||||||
|
'title' => 'Photography Board',
|
||||||
|
'slug' => 'photography-board-microdata',
|
||||||
|
'description' => 'Photography board description',
|
||||||
|
'is_active' => true,
|
||||||
|
'position' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->get(route('forum.category.show', ['categorySlug' => $category->slug]))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('itemtype="https://schema.org/CollectionPage"', false)
|
||||||
|
->assertSee('itemtype="https://schema.org/ItemList"', false)
|
||||||
|
->assertSee('itemtype="https://schema.org/ListItem"', false)
|
||||||
|
->assertSee(route('forum.category.show', ['categorySlug' => $category->slug]), false)
|
||||||
|
->assertSee(route('forum.board.show', ['boardSlug' => $board->slug]), false)
|
||||||
|
->assertSee('Photography boards', false)
|
||||||
|
->assertSee('Photography Board', false);
|
||||||
|
});
|
||||||
@@ -313,6 +313,9 @@ it('renders structured data for public news pages', function (): void {
|
|||||||
->assertOk()
|
->assertOk()
|
||||||
->assertSee('NewsArticle', false)
|
->assertSee('NewsArticle', false)
|
||||||
->assertSee('ImageObject', false)
|
->assertSee('ImageObject', false)
|
||||||
|
->assertSee('creditText', false)
|
||||||
|
->assertSee(route('terms-of-service'), false)
|
||||||
|
->assertSee('acquireLicensePage', false)
|
||||||
->assertSee('BreadcrumbList', false);
|
->assertSee('BreadcrumbList', false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
use App\Models\Category;
|
use App\Models\Category;
|
||||||
use App\Models\ContentType;
|
use App\Models\ContentType;
|
||||||
|
use App\Models\Artwork;
|
||||||
|
|
||||||
beforeEach(function () {
|
beforeEach(function () {
|
||||||
foreach (['wallpapers', 'skins', 'photography', 'other'] as $slug) {
|
foreach (['wallpapers', 'skins', 'photography', 'other'] as $slug) {
|
||||||
@@ -168,6 +169,20 @@ it('legacy /category route falls back to /categories and preserves query string
|
|||||||
->assertStatus(301);
|
->assertStatus(301);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('legacy rate.php artwork route redirects to the canonical artwork URL with 301', function () {
|
||||||
|
$artwork = Artwork::factory()->create([
|
||||||
|
'title' => 'Legacy Rated Artwork',
|
||||||
|
'slug' => 'legacy-rated-artwork',
|
||||||
|
'is_public' => true,
|
||||||
|
'is_approved' => true,
|
||||||
|
'published_at' => now()->subMinute(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->get('/rate.php?skins=' . $artwork->id)
|
||||||
|
->assertRedirect(route('art.show', ['id' => $artwork->id, 'slug' => $artwork->slug]))
|
||||||
|
->assertStatus(301);
|
||||||
|
});
|
||||||
|
|
||||||
it('GET /today-in-history redirects to /discover/on-this-day with 301', function () {
|
it('GET /today-in-history redirects to /discover/on-this-day with 301', function () {
|
||||||
$this->get('/today-in-history')->assertRedirect('/discover/on-this-day')->assertStatus(301);
|
$this->get('/today-in-history')->assertRedirect('/discover/on-this-day')->assertStatus(301);
|
||||||
});
|
});
|
||||||
|
|||||||
41
tests/Unit/AcademyCoursesSitemapBuilderTest.php
Normal file
41
tests/Unit/AcademyCoursesSitemapBuilderTest.php
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Models\AcademyCourse;
|
||||||
|
use App\Services\Sitemaps\Builders\AcademyCoursesSitemapBuilder;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
|
uses(Tests\TestCase::class, RefreshDatabase::class);
|
||||||
|
|
||||||
|
it('builds sitemap entries only for published academy courses', function (): void {
|
||||||
|
config()->set('app.url', 'http://skinbase26.test');
|
||||||
|
config()->set('academy.enabled', true);
|
||||||
|
|
||||||
|
AcademyCourse::query()->create([
|
||||||
|
'title' => 'Published Academy Course',
|
||||||
|
'slug' => 'published-academy-course',
|
||||||
|
'excerpt' => 'Visible in sitemap.',
|
||||||
|
'access_level' => 'free',
|
||||||
|
'difficulty' => 'beginner',
|
||||||
|
'status' => 'published',
|
||||||
|
'published_at' => now()->subMinute(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
AcademyCourse::query()->create([
|
||||||
|
'title' => 'Draft Academy Course',
|
||||||
|
'slug' => 'draft-academy-course',
|
||||||
|
'excerpt' => 'Hidden from sitemap.',
|
||||||
|
'access_level' => 'free',
|
||||||
|
'difficulty' => 'beginner',
|
||||||
|
'status' => 'draft',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$items = app(AcademyCoursesSitemapBuilder::class)->items();
|
||||||
|
$locations = array_map(static fn ($item) => $item->loc, $items);
|
||||||
|
|
||||||
|
expect($locations)
|
||||||
|
->toContain(url('/academy/courses'))
|
||||||
|
->toContain(url('/academy/courses/published-academy-course'))
|
||||||
|
->not->toContain(url('/academy/courses/draft-academy-course'));
|
||||||
|
});
|
||||||
@@ -228,6 +228,40 @@ it('homepage payload includes the announcement prop', function (): void {
|
|||||||
expect($html)->toContain('"announcement":{"id":42');
|
expect($html)->toContain('"announcement":{"id":42');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('homepage renders stable intro copy and excludes footer utility text from snippets', function (): void {
|
||||||
|
$html = view('web.home', [
|
||||||
|
'seo' => [],
|
||||||
|
'useUnifiedSeo' => true,
|
||||||
|
'meta' => [],
|
||||||
|
'props' => [
|
||||||
|
'hero' => [
|
||||||
|
'title' => 'Featured Example',
|
||||||
|
'author' => 'CreatorName',
|
||||||
|
'url' => '/art/1/featured-example',
|
||||||
|
],
|
||||||
|
'announcement' => null,
|
||||||
|
'community_favorites' => [],
|
||||||
|
'hall_of_fame' => [],
|
||||||
|
'rising' => [],
|
||||||
|
'trending' => [],
|
||||||
|
'fresh' => [],
|
||||||
|
'collections_featured' => [],
|
||||||
|
'collections_trending' => [],
|
||||||
|
'collections_editorial' => [],
|
||||||
|
'collections_community' => [],
|
||||||
|
'world_spotlight' => null,
|
||||||
|
'groups' => [],
|
||||||
|
'tags' => [],
|
||||||
|
'creators' => [],
|
||||||
|
'news' => [],
|
||||||
|
],
|
||||||
|
])->render();
|
||||||
|
|
||||||
|
expect($html)
|
||||||
|
->toContain('Discover digital art, wallpapers, skins, and photography from a global creator community.')
|
||||||
|
->toContain('data-nosnippet');
|
||||||
|
});
|
||||||
|
|
||||||
it('preview sanitizes html content', function (): void {
|
it('preview sanitizes html content', function (): void {
|
||||||
$admin = adminUser();
|
$admin = adminUser();
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user