Implement academy analytics, billing, and web stories updates

This commit is contained in:
2026-05-26 07:27:29 +02:00
parent 456c3d6bb0
commit 0b33a1b074
177 changed files with 27360 additions and 2685 deletions

View File

@@ -0,0 +1,470 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Settings;
use App\Http\Controllers\Controller;
use App\Models\AcademyContentMetricDaily;
use App\Models\AcademySearchLog;
use App\Services\Academy\AcademyAnalyticsContentResolver;
use App\Services\Academy\AcademyContentIntelligenceService;
use App\Services\Academy\AcademyPopularityService;
use App\Support\AcademyAnalytics\AcademyAnalyticsContentType;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use Inertia\Inertia;
use Inertia\Response;
final class AcademyAdminAnalyticsController extends Controller
{
public function __construct(
private readonly AcademyPopularityService $popularity,
private readonly AcademyAnalyticsContentResolver $resolver,
private readonly AcademyContentIntelligenceService $intelligence,
) {}
public function overview(Request $request): Response
{
[$from, $to, $range] = $this->resolveDateRange($request);
$summary = $this->metricsQuery($from, $to)
->selectRaw('sum(views) as views, sum(unique_visitors) as unique_visitors, sum(user_views) as user_views, sum(guest_views) as guest_views, sum(subscriber_views) as subscriber_views, sum(prompt_copies) as prompt_copies, sum(likes) as likes, sum(saves) as saves, sum(completions) as completions, sum(starts) as starts, sum(upgrade_clicks) as upgrade_clicks')
->first();
return Inertia::render('Admin/Academy/AnalyticsOverview', [
'nav' => $this->nav(),
'range' => $this->rangePayload($range, $from, $to),
'stats' => [
'views' => (int) ($summary?->views ?? 0),
'uniqueVisitors' => (int) ($summary?->unique_visitors ?? 0),
'userViews' => (int) ($summary?->user_views ?? 0),
'guestViews' => (int) ($summary?->guest_views ?? 0),
'subscriberViews' => (int) ($summary?->subscriber_views ?? 0),
'promptCopies' => (int) ($summary?->prompt_copies ?? 0),
'likes' => (int) ($summary?->likes ?? 0),
'saves' => (int) ($summary?->saves ?? 0),
'lessonCompletions' => (int) ($summary?->completions ?? 0),
'courseStarts' => (int) ($summary?->starts ?? 0),
'upgradeClicks' => (int) ($summary?->upgrade_clicks ?? 0),
],
'topContent' => $this->serializeContentRows($this->popularity->topContent($from, $to, 8)),
'topWeek' => $this->serializeContentRows($this->popularity->topContent(now()->subDays(6)->startOfDay(), now()->endOfDay(), 8)),
]);
}
public function content(Request $request): Response
{
return $this->renderContentPage($request, null, 'Content performance', 'Cross-module performance across prompts, lessons, courses, packs, and challenges.');
}
public function prompts(Request $request): Response
{
return $this->renderContentPage($request, AcademyAnalyticsContentType::PROMPT, 'Prompt analytics', 'Copy-heavy prompt performance, save rates, and upgrade interest.');
}
public function lessons(Request $request): Response
{
return $this->renderContentPage($request, AcademyAnalyticsContentType::LESSON, 'Lesson analytics', 'Lesson engagement, starts, completions, and drop-off signals.');
}
public function courses(Request $request): Response
{
return $this->renderContentPage($request, AcademyAnalyticsContentType::COURSE, 'Course analytics', 'Course views, starts, completion progress, and upgrade intent.');
}
public function search(Request $request): Response
{
[$from, $to, $range] = $this->resolveDateRange($request);
$searchQuery = AcademySearchLog::query()->whereBetween('created_at', [$from, $to]);
$searchLogs = (clone $searchQuery)->latest('created_at')->limit(500)->get();
return Inertia::render('Admin/Academy/AnalyticsSearch', [
'nav' => $this->nav(),
'range' => $this->rangePayload($range, $from, $to),
'summary' => [
'searches' => (int) (clone $searchQuery)->count(),
'zeroResultSearches' => (int) (clone $searchQuery)->where('results_count', 0)->count(),
'loggedInSearches' => (int) (clone $searchQuery)->where('is_logged_in', true)->count(),
'subscriberSearches' => (int) (clone $searchQuery)->where('is_subscriber', true)->count(),
'searchesWithClicks' => (int) (clone $searchQuery)->whereNotNull('clicked_content_id')->count(),
],
'topSearches' => (clone $searchQuery)
->selectRaw('normalized_query, max(query) as query, count(*) as searches, sum(results_count = 0) as zero_result_hits, avg(results_count) as avg_results, sum(case when clicked_content_id is not null then 1 else 0 end) as clicks')
->groupBy('normalized_query')
->orderByDesc('searches')
->limit(20)
->get()
->map(fn ($row): array => [
'query' => (string) ($row->query ?: $row->normalized_query),
'normalized_query' => (string) $row->normalized_query,
'searches' => (int) $row->searches,
'zero_result_hits' => (int) $row->zero_result_hits,
'avg_results' => round((float) $row->avg_results, 1),
'clicks' => (int) ($row->clicks ?? 0),
'click_through_rate' => (int) $row->searches > 0 ? round((((int) ($row->clicks ?? 0)) / (int) $row->searches) * 100, 1) : 0,
])
->all(),
'zeroResults' => (clone $searchQuery)
->selectRaw('normalized_query, max(query) as query, count(*) as searches')
->where('results_count', 0)
->groupBy('normalized_query')
->orderByDesc('searches')
->limit(20)
->get()
->map(fn ($row): array => [
'query' => (string) ($row->query ?: $row->normalized_query),
'searches' => (int) $row->searches,
])
->all(),
'lowClickThroughSearches' => (clone $searchQuery)
->selectRaw('normalized_query, max(query) as query, count(*) as searches, sum(case when clicked_content_id is not null then 1 else 0 end) as clicks, avg(results_count) as avg_results')
->groupBy('normalized_query')
->havingRaw('count(*) >= 2')
->orderByRaw('case when count(*) = 0 then 1 else (sum(case when clicked_content_id is not null then 1 else 0 end) * 1.0 / count(*)) end asc')
->limit(20)
->get()
->map(fn ($row): array => [
'query' => (string) ($row->query ?: $row->normalized_query),
'searches' => (int) $row->searches,
'clicks' => (int) ($row->clicks ?? 0),
'avg_results' => round((float) $row->avg_results, 1),
'click_through_rate' => (int) $row->searches > 0 ? round((((int) ($row->clicks ?? 0)) / (int) $row->searches) * 100, 1) : 0,
])
->all(),
'highestClickThroughSearches' => (clone $searchQuery)
->selectRaw('normalized_query, max(query) as query, count(*) as searches, sum(case when clicked_content_id is not null then 1 else 0 end) as clicks, avg(results_count) as avg_results')
->groupBy('normalized_query')
->havingRaw('count(*) >= 2')
->orderByRaw('(sum(case when clicked_content_id is not null then 1 else 0 end) * 1.0 / count(*)) desc')
->limit(20)
->get()
->map(fn ($row): array => [
'query' => (string) ($row->query ?: $row->normalized_query),
'searches' => (int) $row->searches,
'clicks' => (int) ($row->clicks ?? 0),
'avg_results' => round((float) $row->avg_results, 1),
'click_through_rate' => (int) $row->searches > 0 ? round((((int) ($row->clicks ?? 0)) / (int) $row->searches) * 100, 1) : 0,
])
->all(),
'searchesWithResultsNoClicks' => (clone $searchQuery)
->selectRaw('normalized_query, max(query) as query, count(*) as searches, avg(results_count) as avg_results')
->where('results_count', '>', 0)
->whereNull('clicked_content_id')
->groupBy('normalized_query')
->orderByDesc('searches')
->limit(20)
->get()
->map(fn ($row): array => [
'query' => (string) ($row->query ?: $row->normalized_query),
'searches' => (int) $row->searches,
'avg_results' => round((float) $row->avg_results, 1),
'clicks' => 0,
'click_through_rate' => 0,
])
->all(),
'topClickedResults' => (clone $searchQuery)
->selectRaw('clicked_content_type, clicked_content_id, count(*) as clicks')
->whereNotNull('clicked_content_type')
->whereNotNull('clicked_content_id')
->groupBy('clicked_content_type', 'clicked_content_id')
->orderByDesc('clicks')
->limit(20)
->get()
->map(fn ($row): array => [
'title' => $this->resolver->title((string) $row->clicked_content_type, (int) $row->clicked_content_id),
'content_type' => (string) $row->clicked_content_type,
'content_id' => (int) $row->clicked_content_id,
'clicks' => (int) $row->clicks,
])
->all(),
'filterUsage' => $this->summarizeSearchFilters($searchLogs),
'recentSearches' => (clone $searchQuery)
->latest('created_at')
->limit(25)
->get()
->map(fn (AcademySearchLog $log): array => [
'query' => (string) $log->query,
'results_count' => (int) $log->results_count,
'logged_in' => (bool) $log->is_logged_in,
'subscriber' => (bool) $log->is_subscriber,
'clicked_content_type' => $log->clicked_content_type,
'has_click' => $log->clicked_content_id !== null,
'created_at' => $log->created_at?->toISOString(),
])
->all(),
]);
}
public function intelligence(Request $request): Response
{
[$from, $to, $range] = $this->resolveDateRange($request, '30d');
$filters = [
'from' => $from,
'to' => $to,
'limit' => 25,
];
return Inertia::render('Admin/Academy/AnalyticsIntelligence', [
'nav' => $this->nav(),
'range' => $this->rangePayload($range, $from, $to),
'contentOpportunities' => $this->intelligence->getContentOpportunities($filters),
'searchGaps' => $this->intelligence->getSearchGaps($filters),
'promptInsights' => $this->intelligence->getPromptInsights($filters),
'lessonDropoffs' => $this->intelligence->getLessonDropoffs($filters),
'courseHealth' => $this->intelligence->getCourseHealth($filters),
'premiumInterest' => $this->intelligence->getPremiumInterest($filters),
'editorialRecommendations' => $this->intelligence->getEditorialRecommendations($filters),
]);
}
/**
* @param Collection<int, AcademySearchLog> $logs
* @return list<array<string, int|string>>
*/
private function summarizeSearchFilters(Collection $logs): array
{
$counts = [];
foreach ($logs as $log) {
$filters = is_array($log->filters) ? $log->filters : [];
foreach ($filters as $key => $value) {
if ($value === null || $value === '' || $key === 'q') {
continue;
}
$values = is_array($value) ? $value : [$value];
foreach ($values as $rawValue) {
$label = trim((string) $rawValue);
if ($label === '') {
continue;
}
$bucket = sprintf('%s:%s', $key, $label);
$counts[$bucket] = [
'filter' => (string) $key,
'value' => $label,
'uses' => (int) (($counts[$bucket]['uses'] ?? 0) + 1),
];
}
}
}
usort($counts, static fn (array $left, array $right): int => $right['uses'] <=> $left['uses']);
return array_slice(array_values($counts), 0, 20);
}
public function funnel(Request $request): Response
{
[$from, $to, $range] = $this->resolveDateRange($request);
$summary = $this->metricsQuery($from, $to)
->selectRaw('sum(unique_visitors) as unique_visitors, sum(premium_preview_views) as premium_preview_views, sum(upgrade_clicks) as upgrade_clicks, sum(starts) as starts, sum(completions) as completions')
->first();
$bestConverters = $this->metricsQuery($from, $to)
->selectRaw('content_type, content_id, sum(unique_visitors) as unique_visitors, sum(premium_preview_views) as premium_preview_views, sum(upgrade_clicks) as upgrade_clicks, sum(conversion_score) as conversion_score')
->groupBy('content_type', 'content_id')
->havingRaw('sum(upgrade_clicks) > 0')
->orderByDesc('conversion_score')
->limit(12)
->get();
return Inertia::render('Admin/Academy/AnalyticsFunnel', [
'nav' => $this->nav(),
'range' => $this->rangePayload($range, $from, $to),
'summary' => [
'academyVisitors' => (int) ($summary?->unique_visitors ?? 0),
'premiumPreviewViews' => (int) ($summary?->premium_preview_views ?? 0),
'upgradeClicks' => (int) ($summary?->upgrade_clicks ?? 0),
'starts' => (int) ($summary?->starts ?? 0),
'completions' => (int) ($summary?->completions ?? 0),
'checkoutStarts' => 0,
'subscriptions' => 0,
],
'bestConverters' => $this->serializeContentRows($bestConverters, includeConversion: true),
]);
}
private function renderContentPage(Request $request, ?string $forcedContentType, string $title, string $subtitle): Response
{
[$from, $to, $range] = $this->resolveDateRange($request);
$sort = (string) $request->query('sort', 'popularity_score');
$direction = strtolower((string) $request->query('direction', 'desc')) === 'asc' ? 'asc' : 'desc';
$access = trim((string) $request->query('access', ''));
$contentType = $forcedContentType ?: (trim((string) $request->query('content_type', '')) ?: null);
$query = $this->metricsQuery($from, $to)
->selectRaw('content_type, content_id, sum(views) as views, sum(unique_visitors) as unique_visitors, sum(engaged_views) as engaged_views, sum(likes) as likes, sum(saves) as saves, sum(prompt_copies) as prompt_copies, sum(starts) as starts, sum(completions) as completions, sum(upgrade_clicks) as upgrade_clicks, sum(popularity_score) as popularity_score, sum(conversion_score) as conversion_score')
->groupBy('content_type', 'content_id');
if ($contentType !== null) {
$query->where('content_type', $contentType);
}
$rows = $query->get();
$serializedRows = $this->serializeContentRows($rows, includeConversion: true)
->filter(function (array $row) use ($access): bool {
if ($access === '') {
return true;
}
return strtolower((string) ($row['access_level'] ?? '')) === strtolower($access);
})
->sortBy($sort, SORT_REGULAR, $direction === 'desc')
->values()
->all();
return Inertia::render('Admin/Academy/AnalyticsContent', [
'nav' => $this->nav(),
'range' => $this->rangePayload($range, $from, $to),
'title' => $title,
'subtitle' => $subtitle,
'filters' => [
'sort' => $sort,
'direction' => $direction,
'access' => $access,
'content_type' => $contentType,
],
'rows' => $serializedRows,
'contentTypeOptions' => [
['value' => '', 'label' => 'All content'],
['value' => AcademyAnalyticsContentType::PROMPT, 'label' => 'Prompts'],
['value' => AcademyAnalyticsContentType::LESSON, 'label' => 'Lessons'],
['value' => AcademyAnalyticsContentType::COURSE, 'label' => 'Courses'],
['value' => AcademyAnalyticsContentType::PROMPT_PACK, 'label' => 'Prompt packs'],
['value' => AcademyAnalyticsContentType::CHALLENGE, 'label' => 'Challenges'],
],
'sortOptions' => [
['value' => 'views', 'label' => 'Views'],
['value' => 'unique_visitors', 'label' => 'Unique visitors'],
['value' => 'likes', 'label' => 'Likes'],
['value' => 'saves', 'label' => 'Saves'],
['value' => 'prompt_copies', 'label' => 'Copies'],
['value' => 'completions', 'label' => 'Completions'],
['value' => 'upgrade_clicks', 'label' => 'Upgrade clicks'],
['value' => 'popularity_score', 'label' => 'Popularity score'],
['value' => 'conversion_score', 'label' => 'Conversion score'],
],
]);
}
private function metricsQuery(Carbon $from, Carbon $to)
{
return AcademyContentMetricDaily::query()
->whereBetween('date', [$from->toDateString(), $to->toDateString()]);
}
/**
* @param Collection<int, mixed> $rows
* @return Collection<int, array<string, mixed>>
*/
private function serializeContentRows(Collection $rows, bool $includeConversion = false): Collection
{
return $rows->map(function ($row) use ($includeConversion): array {
$contentType = (string) $row->content_type;
$contentId = $row->content_id ? (int) $row->content_id : null;
$title = $this->resolver->title($contentType, $contentId);
$accessLevel = $this->resolver->accessLevel($contentType, $contentId);
$uniqueVisitors = max(0, (int) ($row->unique_visitors ?? 0));
$promptCopies = max(0, (int) ($row->prompt_copies ?? 0));
$likes = max(0, (int) ($row->likes ?? 0));
$saves = max(0, (int) ($row->saves ?? 0));
$starts = max(0, (int) ($row->starts ?? 0));
$completions = max(0, (int) ($row->completions ?? 0));
$premiumPreviewViews = max(0, (int) ($row->premium_preview_views ?? 0));
$upgradeClicks = max(0, (int) ($row->upgrade_clicks ?? 0));
return [
'content_type' => $contentType,
'content_type_label' => (string) Str::of(str_replace('academy_', '', $contentType))->replace('_', ' ')->headline(),
'content_id' => $contentId,
'title' => $title,
'access_level' => $accessLevel,
'views' => (int) ($row->views ?? 0),
'unique_visitors' => $uniqueVisitors,
'engaged_views' => (int) ($row->engaged_views ?? 0),
'likes' => $likes,
'saves' => $saves,
'prompt_copies' => $promptCopies,
'starts' => $starts,
'completions' => $completions,
'upgrade_clicks' => $upgradeClicks,
'popularity_score' => round((float) ($row->popularity_score ?? 0), 2),
'conversion_score' => round((float) ($row->conversion_score ?? 0), 2),
'copy_rate' => $uniqueVisitors > 0 ? round(($promptCopies / $uniqueVisitors) * 100, 1) : 0,
'save_rate' => $uniqueVisitors > 0 ? round(($saves / $uniqueVisitors) * 100, 1) : 0,
'like_rate' => $uniqueVisitors > 0 ? round(($likes / $uniqueVisitors) * 100, 1) : 0,
'completion_rate' => $starts > 0 ? round(($completions / $starts) * 100, 1) : 0,
'upgrade_rate' => max(1, $premiumPreviewViews) > 0 ? round(($upgradeClicks / max(1, $premiumPreviewViews)) * 100, 1) : 0,
'trend' => ((float) ($row->popularity_score ?? 0)) >= 100 ? 'High momentum' : (((float) ($row->popularity_score ?? 0)) >= 25 ? 'Building' : 'Early'),
'include_conversion' => $includeConversion,
];
});
}
/**
* @return array{0: Carbon, 1: Carbon, 2: string}
*/
private function resolveDateRange(Request $request, string $defaultRange = '7d'): array
{
$range = trim((string) $request->query('range', $defaultRange));
return match ($range) {
'today' => [now()->startOfDay(), now()->endOfDay(), 'today'],
'yesterday' => [now()->subDay()->startOfDay(), now()->subDay()->endOfDay(), 'yesterday'],
'30d' => [now()->subDays(29)->startOfDay(), now()->endOfDay(), '30d'],
'90d' => [now()->subDays(89)->startOfDay(), now()->endOfDay(), '90d'],
'custom' => [
Carbon::parse((string) $request->query('from', now()->subDays(6)->toDateString()))->startOfDay(),
Carbon::parse((string) $request->query('to', now()->toDateString()))->endOfDay(),
'custom',
],
default => [now()->subDays(6)->startOfDay(), now()->endOfDay(), '7d'],
};
}
/**
* @return list<array<string, string|bool>>
*/
private function nav(): array
{
return [
['label' => 'Overview', 'href' => route('admin.academy.analytics.overview')],
['label' => 'Intelligence', 'href' => route('admin.academy.analytics.intelligence')],
['label' => 'Content', 'href' => route('admin.academy.analytics.content')],
['label' => 'Prompts', 'href' => route('admin.academy.analytics.prompts')],
['label' => 'Lessons', 'href' => route('admin.academy.analytics.lessons')],
['label' => 'Courses', 'href' => route('admin.academy.analytics.courses')],
['label' => 'Search', 'href' => route('admin.academy.analytics.search')],
['label' => 'Funnel', 'href' => route('admin.academy.analytics.funnel')],
];
}
/**
* @return array<string, mixed>
*/
private function rangePayload(string $activeRange, Carbon $from, Carbon $to): array
{
return [
'active' => $activeRange,
'from' => $from->toDateString(),
'to' => $to->toDateString(),
'options' => [
['value' => 'today', 'label' => 'Today'],
['value' => 'yesterday', 'label' => 'Yesterday'],
['value' => '7d', 'label' => 'Last 7 days'],
['value' => '30d', 'label' => 'Last 30 days'],
['value' => '90d', 'label' => 'Last 90 days'],
['value' => 'custom', 'label' => 'Custom range'],
],
];
}
}

View File

@@ -19,6 +19,7 @@ use App\Models\AcademyChallenge;
use App\Models\AcademyChallengeSubmission;
use App\Models\AcademyCourse;
use App\Models\AcademyCourseLesson;
use App\Models\AcademyCourseSection;
use App\Models\AcademyLesson;
use App\Models\AcademyLessonBlock;
use App\Models\AcademyLessonRevision;
@@ -26,6 +27,7 @@ use App\Models\AcademyPromptPack;
use App\Models\AcademyPromptPackItem;
use App\Models\AcademyPromptTemplate;
use App\Models\User;
use App\Services\Academy\AcademyAdminBillingOverviewService;
use App\Services\Academy\AcademyCacheService;
use App\Services\Academy\AcademyCourseLessonOrderingService;
use App\Services\Academy\AcademyLessonMarkdownRenderer;
@@ -38,6 +40,7 @@ use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
use Inertia\Response;
@@ -48,7 +51,13 @@ final class AcademyAdminController extends Controller
private const PROMPT_PREVIEW_PREFIX = 'academy-prompts/previews';
private const PROMPT_PREVIEW_VARIANT_WIDTHS = [
'thumb' => 480,
'md' => 960,
];
public function __construct(
private readonly AcademyAdminBillingOverviewService $billingOverview,
private readonly AcademyCacheService $cache,
private readonly AcademyCourseLessonOrderingService $courseLessonOrdering,
private readonly AcademyLessonMarkdownRenderer $lessonMarkdownRenderer,
@@ -56,6 +65,8 @@ final class AcademyAdminController extends Controller
public function dashboard(): Response
{
$billingSummary = $this->billingOverview->summary();
return Inertia::render('Admin/Academy/Dashboard', [
'stats' => [
'courses' => AcademyCourse::query()->count(),
@@ -65,11 +76,13 @@ final class AcademyAdminController extends Controller
'challenges' => AcademyChallenge::query()->count(),
'submissions' => AcademyChallengeSubmission::query()->count(),
'badges' => AcademyBadge::query()->count(),
'creator_subscribers' => 0,
'pro_subscribers' => 0,
'mrr' => 0,
'active_subscribers' => (int) ($billingSummary['active_subscribers'] ?? 0),
'creator_subscribers' => (int) ($billingSummary['creator_subscribers'] ?? 0),
'pro_subscribers' => (int) ($billingSummary['pro_subscribers'] ?? 0),
'grace_period_subscribers' => (int) ($billingSummary['grace_period_subscribers'] ?? 0),
],
'links' => [
'billing' => route('admin.academy.billing'),
'courses' => route('admin.academy.courses.index'),
'categories' => route('admin.academy.categories.index'),
'lessons' => route('admin.academy.lessons.index'),
@@ -83,6 +96,22 @@ final class AcademyAdminController extends Controller
]);
}
public function billing(): Response
{
$summary = $this->billingOverview->summary();
return Inertia::render('Admin/Academy/Billing', [
'summary' => $summary,
'planBreakdown' => $summary['plan_breakdown'] ?? [],
'recentEvents' => $this->billingOverview->recentEvents(),
'links' => [
'dashboard' => route('admin.academy.dashboard'),
'pricing' => route('academy.pricing'),
'account' => route('academy.billing.account'),
],
]);
}
public function categoriesIndex(): Response
{
return $this->renderIndex('categories');
@@ -100,13 +129,20 @@ final class AcademyAdminController extends Controller
public function coursesStore(UpsertAcademyCourseRequest $request): RedirectResponse
{
$course = new AcademyCourse;
$course->fill($this->persistCourseAttributes($request))->save();
$course = $this->saveCourseFromRequest($request);
$this->cache->clearAll();
return redirect()->route('admin.academy.courses.edit', ['academyCourse' => $course])->with('success', 'Academy course created.');
}
public function coursesStoreJson(UpsertAcademyCourseRequest $request): RedirectResponse
{
$course = $this->saveCourseFromRequest($request);
$this->cache->clearAll();
return redirect()->route('admin.academy.courses.edit', ['academyCourse' => $course])->with('success', 'Academy course created from JSON.');
}
public function coursesEdit(AcademyCourse $academyCourse): Response
{
return $this->renderForm('courses', $academyCourse);
@@ -114,12 +150,94 @@ final class AcademyAdminController extends Controller
public function coursesUpdate(UpsertAcademyCourseRequest $request, AcademyCourse $academyCourse): RedirectResponse
{
$academyCourse->fill($this->persistCourseAttributes($request, $academyCourse))->save();
$this->saveCourseFromRequest($request, $academyCourse);
$this->cache->clearAll();
return redirect()->route('admin.academy.courses.edit', ['academyCourse' => $academyCourse])->with('success', 'Academy course updated.');
}
public function coursesImportLessons(Request $request, AcademyCourse $academyCourse): RedirectResponse
{
$difficultyLevels = array_values(array_filter(array_map('strval', (array) config('academy.difficulty_levels', []))));
$validated = $request->validate([
'defaults' => ['nullable', 'array'],
'defaults.category_id' => ['nullable', 'integer', 'exists:academy_categories,id'],
'defaults.category_slug' => ['nullable', 'string', 'max:180'],
'defaults.category' => ['nullable', 'string', 'max:180'],
'defaults.difficulty' => ['nullable', 'string', Rule::in($difficultyLevels)],
'defaults.access_level' => ['nullable', 'string', Rule::in(['free', 'creator', 'pro'])],
'defaults.lesson_type' => ['nullable', 'string', 'max:80'],
'defaults.active' => ['nullable', 'boolean'],
'defaults.series_name' => ['nullable', 'string', 'max:120'],
'lessons' => ['required', 'array', 'min:1', 'max:250'],
'lessons.*.title' => ['required', 'string', 'max:180'],
'lessons.*.slug' => ['nullable', 'string', 'max:180'],
'lessons.*.goal' => ['nullable', 'string'],
'lessons.*.excerpt' => ['nullable', 'string'],
'lessons.*.category_id' => ['nullable', 'integer', 'exists:academy_categories,id'],
'lessons.*.category_slug' => ['nullable', 'string', 'max:180'],
'lessons.*.category' => ['nullable', 'string', 'max:180'],
'lessons.*.difficulty' => ['nullable', 'string', Rule::in($difficultyLevels)],
'lessons.*.access_level' => ['nullable', 'string', Rule::in(['free', 'creator', 'pro'])],
'lessons.*.lesson_type' => ['nullable', 'string', 'max:80'],
'lessons.*.active' => ['nullable', 'boolean'],
'lessons.*.series_name' => ['nullable', 'string', 'max:120'],
'lessons.*.tags' => ['nullable', 'array'],
'lessons.*.tags.*' => ['string', 'max:60'],
]);
$defaults = (array) ($validated['defaults'] ?? []);
$lessons = array_values((array) ($validated['lessons'] ?? []));
if ($lessons === []) {
throw ValidationException::withMessages([
'lessons' => 'Provide at least one lesson row to import.',
]);
}
DB::transaction(function () use ($academyCourse, $defaults, $lessons): void {
$reservedSlugs = AcademyLesson::query()
->pluck('slug')
->filter(fn ($slug): bool => is_string($slug) && trim($slug) !== '')
->map(fn ($slug): string => trim((string) $slug))
->values()
->all();
$nextOrder = (int) ((AcademyCourseLesson::query()->where('course_id', $academyCourse->id)->max('order_num') ?? -1) + 1);
foreach ($lessons as $lessonData) {
$attributes = $this->buildImportedCourseLessonAttributes($academyCourse, (array) $lessonData, $defaults, $reservedSlugs);
$lesson = new AcademyLesson;
$lesson->fill($attributes)->save();
AcademyCourseLesson::query()->create([
'course_id' => $academyCourse->id,
'lesson_id' => $lesson->id,
'section_id' => null,
'order_num' => $nextOrder,
'is_required' => true,
'access_override' => null,
'unlock_after_lesson_id' => null,
]);
$nextOrder++;
}
$this->courseLessonOrdering->syncCourse($academyCourse);
$academyCourse->forceFill([
'lessons_count_cache' => (int) AcademyCourseLesson::query()->where('course_id', $academyCourse->id)->count(),
])->save();
});
$this->cache->clearAll();
return redirect()->route('admin.academy.courses.edit', ['academyCourse' => $academyCourse])
->with('success', sprintf('%d lesson%s imported into the course.', count($lessons), count($lessons) === 1 ? '' : 's'));
}
public function coursesDestroy(AcademyCourse $academyCourse): RedirectResponse
{
$this->deleteStoredLessonCoverIfLocal((string) $academyCourse->cover_image);
@@ -484,12 +602,40 @@ final class AcademyAdminController extends Controller
private function renderIndex(string $resource): Response
{
$meta = $this->resourceMeta($resource);
$query = $meta['model']::query()->latest('updated_at');
$search = trim((string) request()->query('search', ''));
$query = $meta['model']::query();
if ($resource === 'courses') {
$query->withCount('courseLessons');
if ($search !== '') {
$query->where(function ($builder) use ($search): void {
$like = '%'.str_replace(['%', '_'], ['\\%', '\\_'], $search).'%';
$builder->where('title', 'like', $like)
->orWhere('slug', 'like', $like)
->orWhere('subtitle', 'like', $like)
->orWhere('excerpt', 'like', $like)
->orWhere('description', 'like', $like);
});
}
$query->orderByDesc('is_featured')
->orderBy('order_num')
->orderByDesc('updated_at')
->orderByDesc('id');
} else {
$query->latest('updated_at');
}
if ($resource === 'prompts') {
$query->with('category');
}
if ($resource === 'lessons') {
$query->with('courses:id,title');
}
$items = $query->paginate(25)->withQueryString();
$items->getCollection()->transform(fn (Model $model): array => $this->serializeIndexItem($resource, $model));
@@ -500,6 +646,45 @@ final class AcademyAdminController extends Controller
'items' => $items,
'columns' => $meta['columns'],
'createUrl' => route($meta['route_base'].'.create'),
'filters' => [
'search' => $search,
],
'summary' => $resource === 'courses' ? [
'total' => (int) $items->total(),
'published' => (int) (clone $meta['model']::query())->when($search !== '', function ($builder) use ($search): void {
$like = '%'.str_replace(['%', '_'], ['\\%', '\\_'], $search).'%';
$builder->where(function ($inner) use ($like): void {
$inner->where('title', 'like', $like)
->orWhere('slug', 'like', $like)
->orWhere('subtitle', 'like', $like)
->orWhere('excerpt', 'like', $like)
->orWhere('description', 'like', $like);
});
})->where('status', AcademyCourse::STATUS_PUBLISHED)->count(),
'featured' => (int) (clone $meta['model']::query())->when($search !== '', function ($builder) use ($search): void {
$like = '%'.str_replace(['%', '_'], ['\\%', '\\_'], $search).'%';
$builder->where(function ($inner) use ($like): void {
$inner->where('title', 'like', $like)
->orWhere('slug', 'like', $like)
->orWhere('subtitle', 'like', $like)
->orWhere('excerpt', 'like', $like)
->orWhere('description', 'like', $like);
});
})->where('is_featured', true)->count(),
'drafts' => (int) (clone $meta['model']::query())->when($search !== '', function ($builder) use ($search): void {
$like = '%'.str_replace(['%', '_'], ['\\%', '\\_'], $search).'%';
$builder->where(function ($inner) use ($like): void {
$inner->where('title', 'like', $like)
->orWhere('slug', 'like', $like)
->orWhere('subtitle', 'like', $like)
->orWhere('excerpt', 'like', $like)
->orWhere('description', 'like', $like);
});
})->where('status', AcademyCourse::STATUS_DRAFT)->count(),
] : null,
]);
}
@@ -538,6 +723,9 @@ final class AcademyAdminController extends Controller
'outlineSummary' => $record instanceof AcademyCourse && $record->exists
? $this->serializeCourseOutlineSummary($record)
: null,
'courseSections' => $record instanceof AcademyCourse && $record->exists
? $this->serializeCourseEditorSections($record)
: [],
'courseLessons' => $record instanceof AcademyCourse && $record->exists
? $this->serializeCourseEditorLessons($record)
: [],
@@ -547,9 +735,19 @@ final class AcademyAdminController extends Controller
'attachLessonUrl' => $record instanceof AcademyCourse && $record->exists
? route('admin.academy.courses.lessons.attach', ['academyCourse' => $record])
: null,
'importLessonsUrl' => $record instanceof AcademyCourse && $record->exists
? route('admin.academy.courses.lessons.import', ['academyCourse' => $record])
: null,
'sectionStoreUrl' => $record instanceof AcademyCourse && $record->exists
? route('admin.academy.courses.sections.store', ['academyCourse' => $record])
: null,
'reorderUrl' => $record instanceof AcademyCourse && $record->exists
? route('admin.academy.courses.reorder', ['academyCourse' => $record])
: null,
'courseImportUrl' => $record instanceof AcademyCourse && ! $record->exists
? route('admin.academy.courses.import-json')
: null,
'lessonCategoryOptions' => $this->categoriesForEditor('lesson'),
];
}
@@ -656,7 +854,7 @@ final class AcademyAdminController extends Controller
'singular' => 'lesson',
'subtitle' => 'Create and publish Academy lessons.',
'route_base' => 'admin.academy.lessons',
'columns' => ['title', 'difficulty', 'access_level', 'featured', 'active'],
'columns' => ['title', 'course_names', 'course_order', 'difficulty', 'access_level', 'active'],
'fields' => [
['name' => 'category_id', 'label' => 'Category', 'type' => 'select', 'options' => $this->categoryOptions('lesson')],
['name' => 'course_ids', 'label' => 'Courses', 'type' => 'multiselect', 'options' => $this->courseOptions()],
@@ -694,6 +892,10 @@ final class AcademyAdminController extends Controller
['name' => 'negative_prompt', 'label' => 'Negative Prompt', 'type' => 'textarea'],
['name' => 'usage_notes', 'label' => 'Usage Notes', 'type' => 'textarea'],
['name' => 'workflow_notes', 'label' => 'Workflow Notes', 'type' => 'textarea'],
['name' => 'documentation', 'label' => 'Documentation JSON', 'type' => 'json'],
['name' => 'placeholders', 'label' => 'Placeholders JSON', 'type' => 'json'],
['name' => 'helper_prompts', 'label' => 'Helper Prompts JSON', 'type' => 'json'],
['name' => 'prompt_variants', 'label' => 'Prompt Variants JSON', 'type' => 'json'],
['name' => 'difficulty', 'label' => 'Difficulty', 'type' => 'select', 'options' => $this->difficultyOptions()],
['name' => 'access_level', 'label' => 'Access', 'type' => 'select', 'options' => $this->accessOptions()],
['name' => 'aspect_ratio', 'label' => 'Aspect Ratio', 'type' => 'text'],
@@ -785,10 +987,17 @@ final class AcademyAdminController extends Controller
'courses' => [
'id' => (int) $model->id,
'title' => (string) $model->title,
'slug' => (string) $model->slug,
'subtitle' => (string) ($model->subtitle ?? ''),
'excerpt' => (string) ($model->excerpt ?? ''),
'cover_image_url' => $this->resolveLessonCoverImageUrl((string) ($model->cover_image ?? '')),
'lessons_count' => (int) ($model->lessons_count_cache ?? $model->course_lessons_count ?? 0),
'difficulty' => (string) $model->difficulty,
'access_level' => (string) $model->access_level,
'status' => (string) $model->status,
'is_featured' => (bool) $model->is_featured,
'published_at' => optional($model->published_at)->toIso8601String(),
'updated_at' => optional($model->updated_at)->toIso8601String(),
'edit_url' => route('admin.academy.courses.edit', ['academyCourse' => $model]),
'destroy_url' => route('admin.academy.courses.destroy', ['academyCourse' => $model]),
'builder_url' => route('admin.academy.courses.builder.edit', ['academyCourse' => $model]),
@@ -805,6 +1014,8 @@ final class AcademyAdminController extends Controller
'lessons' => [
'id' => (int) $model->id,
'title' => (string) $model->title,
'course_names' => $model->courses->pluck('title')->filter()->values()->all(),
'course_order' => $model->course_order,
'difficulty' => (string) $model->difficulty,
'access_level' => (string) $model->access_level,
'featured' => (bool) $model->featured,
@@ -941,6 +1152,10 @@ final class AcademyAdminController extends Controller
'negative_prompt' => (string) ($record->negative_prompt ?? ''),
'usage_notes' => (string) ($record->usage_notes ?? ''),
'workflow_notes' => (string) ($record->workflow_notes ?? ''),
'documentation' => $this->encodePrettyJsonForForm($record->documentation),
'placeholders' => $this->encodePrettyJsonForForm($record->placeholders),
'helper_prompts' => $this->encodePrettyJsonForForm($record->helper_prompts),
'prompt_variants' => $this->encodePrettyJsonForForm($record->prompt_variants),
'difficulty' => (string) ($record->difficulty ?? 'beginner'),
'access_level' => (string) ($record->access_level ?? 'free'),
'aspect_ratio' => (string) ($record->aspect_ratio ?? ''),
@@ -1464,9 +1679,46 @@ final class AcademyAdminController extends Controller
return $validated;
}
private function saveCourseFromRequest(UpsertAcademyCourseRequest $request, ?AcademyCourse $course = null): AcademyCourse
{
$course ??= new AcademyCourse;
$course->fill($this->persistCourseAttributes($request, $course))->save();
return $course;
}
/**
* @return array<string, mixed>
*/
/**
* @return array<int, array<string, mixed>>
*/
private function serializeCourseEditorSections(AcademyCourse $course): array
{
$course->loadMissing(['sections']);
return $course->sections
->sortBy([['order_num', 'asc'], ['id', 'asc']])
->values()
->map(fn (AcademyCourseSection $section): array => [
'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),
'update_url' => route('admin.academy.courses.sections.update', [
'academyCourse' => $course,
'academyCourseSection' => $section,
]),
'destroy_url' => route('admin.academy.courses.sections.destroy', [
'academyCourse' => $course,
'academyCourseSection' => $section,
]),
])
->all();
}
/**
* @return array<int, array<string, mixed>>
*/
@@ -1479,12 +1731,17 @@ final class AcademyAdminController extends Controller
->values()
->map(function (AcademyCourseLesson $courseLesson) use ($course): array {
$lesson = $courseLesson->lesson;
$publicationMeta = $this->serializeLessonPublicationMeta($lesson instanceof AcademyLesson ? $lesson : null);
return [
return array_merge([
'id' => (int) $courseLesson->id,
'lesson_id' => (int) $courseLesson->lesson_id,
'title' => (string) ($lesson?->title ?? 'Untitled lesson'),
'slug' => (string) ($lesson?->slug ?? ''),
'cover_image' => (string) ($lesson?->cover_image ?? ''),
'cover_image_url' => $lesson instanceof AcademyLesson
? $this->resolveLessonCoverImageUrl((string) ($lesson->cover_image ?: $lesson->article_cover_image ?? ''))
: null,
'section_id' => $courseLesson->section_id ? (int) $courseLesson->section_id : null,
'section_title' => (string) ($courseLesson->section?->title ?? ''),
'order_num' => (int) ($courseLesson->order_num ?? 0),
@@ -1493,6 +1750,7 @@ final class AcademyAdminController extends Controller
'is_required' => (bool) $courseLesson->is_required,
'difficulty' => (string) ($lesson?->difficulty ?? ''),
'access_level' => (string) ($lesson?->access_level ?? ''),
'active' => (bool) ($lesson?->active ?? false),
'destroy_url' => route('admin.academy.courses.lessons.destroy', [
'academyCourse' => $course,
'academyCourseLesson' => $courseLesson,
@@ -1500,42 +1758,208 @@ final class AcademyAdminController extends Controller
'edit_url' => $lesson instanceof AcademyLesson
? route('admin.academy.lessons.edit', ['academyLesson' => $lesson])
: null,
];
], $publicationMeta);
})
->all();
}
/**
* @param array<string, mixed> $lessonData
* @param array<string, mixed> $defaults
* @param array<int, string> $reservedSlugs
* @return array<string, mixed>
*/
private function buildImportedCourseLessonAttributes(AcademyCourse $course, array $lessonData, array $defaults, array &$reservedSlugs): array
{
$title = trim((string) ($lessonData['title'] ?? ''));
$slugSource = $this->nullableTrimmedString($lessonData['slug'] ?? null) ?? $title;
$excerpt = $this->nullableTrimmedString($lessonData['excerpt'] ?? null)
?? $this->nullableTrimmedString($lessonData['goal'] ?? null);
$difficulty = $this->nullableTrimmedString($lessonData['difficulty'] ?? null)
?? $this->nullableTrimmedString($defaults['difficulty'] ?? null)
?? $this->nullableTrimmedString($course->difficulty)
?? 'beginner';
$accessLevel = $this->nullableTrimmedString($lessonData['access_level'] ?? null)
?? $this->nullableTrimmedString($defaults['access_level'] ?? null)
?? 'free';
$lessonType = $this->nullableTrimmedString($lessonData['lesson_type'] ?? null)
?? $this->nullableTrimmedString($defaults['lesson_type'] ?? null)
?? 'article';
$seriesName = $this->nullableTrimmedString($lessonData['series_name'] ?? null)
?? $this->nullableTrimmedString($defaults['series_name'] ?? null)
?? $this->nullableTrimmedString($course->title);
$active = array_key_exists('active', $lessonData)
? (bool) $lessonData['active']
: (array_key_exists('active', $defaults) ? (bool) $defaults['active'] : false);
return [
'category_id' => $this->resolveImportedLessonCategoryId($lessonData, $defaults),
'title' => $title,
'slug' => $this->reserveImportedLessonSlug($slugSource, $reservedSlugs),
'lesson_number' => null,
'course_order' => null,
'series_name' => $seriesName,
'excerpt' => $excerpt,
'content' => null,
'content_markdown' => null,
'difficulty' => $difficulty,
'access_level' => $accessLevel,
'lesson_type' => $lessonType,
'cover_image' => null,
'article_cover_image' => null,
'tags' => collect((array) ($lessonData['tags'] ?? []))
->map(fn ($tag): string => trim((string) $tag))
->filter(fn (string $tag): bool => $tag !== '')
->values()
->all(),
'video_url' => null,
'reading_minutes' => 5,
'featured' => false,
'active' => $active,
'published_at' => null,
'seo_title' => null,
'seo_description' => $excerpt,
];
}
/**
* @param array<string, mixed> $lessonData
* @param array<string, mixed> $defaults
*/
private function resolveImportedLessonCategoryId(array $lessonData, array $defaults): ?int
{
foreach ([$lessonData, $defaults] as $source) {
if ($source === []) {
continue;
}
$categoryId = $source['category_id'] ?? null;
if ($categoryId !== null && AcademyCategory::query()->where('type', 'lesson')->whereKey((int) $categoryId)->exists()) {
return (int) $categoryId;
}
$categorySlug = $this->nullableTrimmedString($source['category_slug'] ?? null);
if ($categorySlug !== null) {
$category = AcademyCategory::query()->where('type', 'lesson')->where('slug', $categorySlug)->first();
if ($category instanceof AcademyCategory) {
return (int) $category->id;
}
}
$categoryName = $this->nullableTrimmedString($source['category'] ?? null);
if ($categoryName !== null) {
$category = AcademyCategory::query()->where('type', 'lesson')->whereRaw('lower(name) = ?', [Str::lower($categoryName)])->first();
if ($category instanceof AcademyCategory) {
return (int) $category->id;
}
}
}
return null;
}
/**
* @param array<int, string> $reservedSlugs
*/
private function reserveImportedLessonSlug(string $source, array &$reservedSlugs): string
{
$base = Str::slug($source);
if ($base === '') {
$base = 'academy-lesson';
}
$candidate = $base;
$suffix = 2;
while (in_array($candidate, $reservedSlugs, true)) {
$candidate = $base.'-'.$suffix;
$suffix++;
}
$reservedSlugs[] = $candidate;
return $candidate;
}
/**
* @return array<int, array<string, mixed>>
*/
private function categoriesForEditor(string $type): array
{
return AcademyCategory::query()
->where('type', $type)
->orderBy('order_num')
->orderBy('name')
->get()
->map(fn (AcademyCategory $category): array => $this->serializeCategoryOption($category))
->values()
->all();
}
/**
* @return array<int, array<string, mixed>>
*/
private function serializeCourseAvailableLessons(AcademyCourse $course): array
{
$course->loadMissing(['courseLessons']);
$attachedLessonIds = $course->courseLessons
->pluck('lesson_id')
->map(fn ($id): int => (int) $id)
->flip()
->all();
return AcademyLesson::query()
->whereDoesntHave('courseLessons')
->with('category')
->orderBy('title')
->get()
->map(fn (AcademyLesson $lesson): array => [
'id' => (int) $lesson->id,
'title' => (string) $lesson->title,
'slug' => (string) $lesson->slug,
'difficulty' => (string) $lesson->difficulty,
'access_level' => (string) $lesson->access_level,
'active' => (bool) $lesson->active,
'category' => $lesson->category ? (string) $lesson->category->name : '',
'attached' => isset($attachedLessonIds[(int) $lesson->id]),
])
->map(function (AcademyLesson $lesson): array {
$publicationMeta = $this->serializeLessonPublicationMeta($lesson);
return array_merge([
'id' => (int) $lesson->id,
'title' => (string) $lesson->title,
'slug' => (string) $lesson->slug,
'cover_image' => (string) ($lesson->cover_image ?? ''),
'cover_image_url' => $this->resolveLessonCoverImageUrl((string) ($lesson->cover_image ?: $lesson->article_cover_image ?? '')),
'difficulty' => (string) $lesson->difficulty,
'access_level' => (string) $lesson->access_level,
'active' => (bool) $lesson->active,
'category' => $lesson->category ? (string) $lesson->category->name : '',
'edit_url' => route('admin.academy.lessons.edit', ['academyLesson' => $lesson]),
'attached' => false,
], $publicationMeta);
})
->values()
->all();
}
/**
* @return array<string, string|null>
*/
private function serializeLessonPublicationMeta(?AcademyLesson $lesson): array
{
$publishedAt = $lesson?->published_at instanceof Carbon
? $lesson->published_at->copy()
: null;
if (! $publishedAt) {
return [
'published_at' => null,
'publication_state' => 'draft',
'publication_label' => 'Unscheduled',
];
}
if ($publishedAt->isFuture()) {
return [
'published_at' => $publishedAt->toIso8601String(),
'publication_state' => 'scheduled',
'publication_label' => 'Publishes '.$publishedAt->format('Y-m-d H:i'),
];
}
return [
'published_at' => $publishedAt->toIso8601String(),
'publication_state' => 'published',
'publication_label' => 'Published',
];
}
private function serializeCourseOutlineSummary(AcademyCourse $course): array
{
$course->loadMissing(['sections', 'courseLessons']);
@@ -1734,6 +2158,10 @@ final class AcademyAdminController extends Controller
$validated['category_id'] = $this->resolveOrCreatePromptCategoryId($newCategoryName);
}
$validated['documentation'] = $this->normalizePromptDocumentation($validated['documentation'] ?? null);
$validated['placeholders'] = $this->normalizePromptPlaceholders($validated['placeholders'] ?? null);
$validated['helper_prompts'] = $this->normalizePromptHelperPrompts($validated['helper_prompts'] ?? null);
$validated['prompt_variants'] = $this->normalizePromptVariants($validated['prompt_variants'] ?? null);
$validated['tool_notes'] = $this->normalizePromptToolNotes((array) ($validated['tool_notes'] ?? []));
$previousToolNotes = $this->normalizePromptToolNotes((array) ($prompt?->tool_notes ?? []));
@@ -1803,6 +2231,172 @@ final class AcademyAdminController extends Controller
->all();
}
private function encodePrettyJsonForForm(mixed $value): string
{
if ($value === null || $value === [] || $value === '') {
return '';
}
return (string) json_encode($value, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
}
/**
* @return array<string, mixed>|null
*/
private function normalizePromptDocumentation(mixed $documentation): ?array
{
if (! is_array($documentation)) {
return null;
}
$listFields = ['best_for', 'how_to_use', 'required_inputs', 'workflow', 'tips', 'common_mistakes', 'data_accuracy_notes'];
$normalized = [
'summary' => $this->nullableTrimmedString($documentation['summary'] ?? null),
'display_notes' => $this->nullableTrimmedString($documentation['display_notes'] ?? null),
];
foreach ($listFields as $field) {
$normalized[$field] = $this->normalizePromptStringList($documentation[$field] ?? []);
}
$hasContent = $normalized['summary'] !== null
|| $normalized['display_notes'] !== null
|| collect($listFields)->contains(fn (string $field): bool => $normalized[$field] !== []);
return $hasContent ? $normalized : null;
}
/**
* @return array<int, array<string, mixed>>
*/
private function normalizePromptPlaceholders(mixed $placeholders): array
{
if (! is_array($placeholders)) {
return [];
}
return collect($placeholders)
->filter(static fn ($placeholder): bool => is_array($placeholder))
->map(function (array $placeholder): array {
return [
'key' => $this->nullableTrimmedString($placeholder['key'] ?? null),
'label' => $this->nullableTrimmedString($placeholder['label'] ?? null),
'description' => $this->nullableTrimmedString($placeholder['description'] ?? null),
'required' => filter_var($placeholder['required'] ?? false, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE) ?? false,
'example' => $this->normalizePromptJsonValue($placeholder['example'] ?? null),
'default' => $this->normalizePromptJsonValue($placeholder['default'] ?? null),
'type' => $this->nullableTrimmedString($placeholder['type'] ?? null),
];
})
->filter(function (array $placeholder): bool {
return collect([
$placeholder['key'] ?? null,
$placeholder['label'] ?? null,
$placeholder['description'] ?? null,
$placeholder['example'] ?? null,
$placeholder['default'] ?? null,
$placeholder['type'] ?? null,
])->contains(fn ($item): bool => $item !== null && $item !== '' && $item !== []);
})
->values()
->all();
}
/**
* @return array<int, array<string, mixed>>
*/
private function normalizePromptHelperPrompts(mixed $helperPrompts): array
{
if (! is_array($helperPrompts)) {
return [];
}
return collect($helperPrompts)
->filter(static fn ($helperPrompt): bool => is_array($helperPrompt))
->map(function (array $helperPrompt): array {
return [
'title' => $this->nullableTrimmedString($helperPrompt['title'] ?? null),
'type' => $this->nullableTrimmedString($helperPrompt['type'] ?? null) ?? 'other',
'description' => $this->nullableTrimmedString($helperPrompt['description'] ?? null),
'prompt' => $this->nullableTrimmedString($helperPrompt['prompt'] ?? null),
'expected_output' => $this->nullableTrimmedString($helperPrompt['expected_output'] ?? null) ?? 'text',
'active' => filter_var($helperPrompt['active'] ?? true, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE) ?? true,
];
})
->filter(function (array $helperPrompt): bool {
return collect([
$helperPrompt['title'] ?? null,
$helperPrompt['description'] ?? null,
$helperPrompt['prompt'] ?? null,
])->contains(fn ($item): bool => $item !== null && $item !== '');
})
->values()
->all();
}
/**
* @return array<int, array<string, mixed>>
*/
private function normalizePromptVariants(mixed $variants): array
{
if (! is_array($variants)) {
return [];
}
return collect($variants)
->filter(static fn ($variant): bool => is_array($variant))
->map(function (array $variant): array {
return [
'title' => $this->nullableTrimmedString($variant['title'] ?? null),
'slug' => $this->nullableTrimmedString($variant['slug'] ?? null),
'description' => $this->nullableTrimmedString($variant['description'] ?? null),
'prompt' => $this->nullableTrimmedString($variant['prompt'] ?? null),
'negative_prompt' => $this->nullableTrimmedString($variant['negative_prompt'] ?? null),
'recommended' => filter_var($variant['recommended'] ?? false, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE) ?? false,
'recommended_for' => $this->normalizePromptStringList($variant['recommended_for'] ?? []),
'risk_notes' => $this->normalizePromptStringList($variant['risk_notes'] ?? []),
'active' => filter_var($variant['active'] ?? true, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE) ?? true,
];
})
->filter(function (array $variant): bool {
return collect([
$variant['title'] ?? null,
$variant['description'] ?? null,
$variant['prompt'] ?? null,
$variant['negative_prompt'] ?? null,
])->contains(fn ($item): bool => $item !== null && $item !== '');
})
->values()
->all();
}
/**
* @return array<int, string>
*/
private function normalizePromptStringList(mixed $value): array
{
if (! is_array($value)) {
$value = $value === null ? [] : [$value];
}
return collect($value)
->map(fn ($item): string => trim((string) $item))
->filter(static fn (string $item): bool => $item !== '')
->values()
->all();
}
private function normalizePromptJsonValue(mixed $value): mixed
{
if (! is_string($value)) {
return $value;
}
$trimmed = trim($value);
return $trimmed !== '' ? $trimmed : null;
}
/**
* @param array<int, mixed> $notes
* @return array<int, array<string, string>>
@@ -1966,6 +2560,23 @@ final class AcademyAdminController extends Controller
$storedPath = self::PROMPT_PREVIEW_PREFIX.'/'.pathinfo(Str::replace('\\', '/', $file->hashName()), PATHINFO_FILENAME).'.webp';
Storage::disk($this->promptPreviewImageDisk())->put($storedPath, $webpBinary, ['visibility' => 'public']);
$sourceWidth = imagesx($image);
$sourceHeight = imagesy($image);
foreach (self::PROMPT_PREVIEW_VARIANT_WIDTHS as $variant => $targetWidth) {
$variantBinary = $this->encodePromptPreviewVariant($image, $targetWidth, $sourceWidth, $sourceHeight);
if ($variantBinary === null) {
continue;
}
Storage::disk($this->promptPreviewImageDisk())->put(
$this->promptPreviewVariantPath($storedPath, $variant),
$variantBinary,
['visibility' => 'public']
);
}
} finally {
imagedestroy($image);
}
@@ -1973,6 +2584,62 @@ final class AcademyAdminController extends Controller
return $storedPath;
}
private function encodePromptPreviewVariant(\GdImage $source, int $targetWidth, int $sourceWidth, int $sourceHeight): ?string
{
if ($sourceWidth <= $targetWidth || $sourceWidth < 1 || $sourceHeight < 1) {
return null;
}
$targetHeight = max(1, (int) round(($sourceHeight / $sourceWidth) * $targetWidth));
$variant = imagecreatetruecolor($targetWidth, $targetHeight);
if (! $variant instanceof \GdImage) {
throw ValidationException::withMessages([
'preview_image_file' => 'The uploaded preview image could not be resized. Please try a different image.',
]);
}
imagealphablending($variant, false);
imagesavealpha($variant, true);
$transparent = imagecolorallocatealpha($variant, 0, 0, 0, 127);
imagefilledrectangle($variant, 0, 0, $targetWidth, $targetHeight, $transparent);
imagecopyresampled($variant, $source, 0, 0, 0, 0, $targetWidth, $targetHeight, $sourceWidth, $sourceHeight);
try {
ob_start();
$converted = imagewebp($variant, null, self::PROMPT_PREVIEW_WEBP_QUALITY);
$webpBinary = ob_get_clean();
if (! $converted || ! is_string($webpBinary) || $webpBinary === '') {
throw ValidationException::withMessages([
'preview_image_file' => 'The uploaded preview image could not be converted to WebP. Please try a different image.',
]);
}
return $webpBinary;
} finally {
imagedestroy($variant);
}
}
private function promptPreviewVariantPath(string $path, string $variant): string
{
$directory = pathinfo($path, PATHINFO_DIRNAME);
$filename = pathinfo($path, PATHINFO_FILENAME);
$baseFilename = preg_replace('/-(thumb|md)$/', '', $filename) ?? $filename;
return sprintf('%s/%s-%s.webp', $directory, $baseFilename, $variant);
}
private function canonicalPromptPreviewPath(string $path): string
{
$directory = pathinfo($path, PATHINFO_DIRNAME);
$filename = pathinfo($path, PATHINFO_FILENAME);
$baseFilename = preg_replace('/-(thumb|md)$/', '', $filename) ?? $filename;
return sprintf('%s/%s.webp', $directory, $baseFilename);
}
private function deleteStoredPromptPreviewIfLocal(?string $path): void
{
$path = trim((string) $path);
@@ -1985,10 +2652,14 @@ final class AcademyAdminController extends Controller
}
$disk = $this->promptPreviewImageDisk();
$basePath = $this->canonicalPromptPreviewPath($path);
$paths = [
$basePath,
$this->promptPreviewVariantPath($basePath, 'thumb'),
$this->promptPreviewVariantPath($basePath, 'md'),
];
if (Storage::disk($disk)->exists($path)) {
Storage::disk($disk)->delete($path);
}
Storage::disk($disk)->delete(array_values(array_unique($paths)));
}
private function promptPreviewImageUploadErrorMessage(UploadedFile $file): string

View File

@@ -26,6 +26,11 @@ final class AcademyLessonMediaApiController extends Controller
private const ASSET_CACHE_TTL_MINUTES = 15;
private const RESPONSIVE_VARIANT_WIDTHS = [
'thumb' => 480,
'md' => 960,
];
private ?ImageManager $manager = null;
public function __construct()
@@ -68,6 +73,18 @@ final class AcademyLessonMediaApiController extends Controller
'slot' => $slot,
'path' => $stored['path'],
'url' => $this->publicUrlForPath($stored['path']),
'thumb_path' => $stored['thumb_path'],
'thumb_url' => $this->publicUrlForPath($stored['thumb_path']),
'thumb_width' => $stored['thumb_width'],
'thumb_height' => $stored['thumb_height'],
'medium_path' => $stored['medium_path'],
'medium_url' => $stored['medium_path'] !== '' ? $this->publicUrlForPath($stored['medium_path']) : null,
'medium_width' => $stored['medium_width'],
'medium_height' => $stored['medium_height'],
'srcset' => $this->buildResponsiveSrcset([
['path' => $stored['thumb_path'], 'width' => $stored['thumb_width']],
['path' => $stored['medium_path'], 'width' => $stored['medium_width']],
]),
'width' => $stored['width'],
'height' => $stored['height'],
'mime_type' => 'image/webp',
@@ -161,7 +178,7 @@ final class AcademyLessonMediaApiController extends Controller
}
/**
* @return array{path:string,width:int,height:int,size_bytes:int}
* @return array{path:string,thumb_path:string,thumb_width:int,thumb_height:int,medium_path:string,medium_width:int|null,medium_height:int|null,width:int,height:int,size_bytes:int}
*/
private function storeMediaFile(UploadedFile $file, string $slot): array
{
@@ -202,14 +219,99 @@ final class AcademyLessonMediaApiController extends Controller
));
}
$image = $this->manager->read($raw)->scaleDown(width: $constraints['max_width'], height: $constraints['max_height']);
$encoded = (string) $image->encode(new WebpEncoder(85));
$encodedImage = $this->encodeScaledMedia($raw, $constraints['max_width'], $constraints['max_height']);
$encoded = $encodedImage['binary'];
$hash = hash('sha256', $encoded);
$path = $this->mediaPath($hash, $slot);
$disk = Storage::disk($this->mediaDiskName());
$written = $disk->put($path, $encoded, [
$this->writeMediaBinary($disk, $path, $encoded);
$thumbVariant = $this->storeResponsiveVariant(
$disk,
$raw,
$constraints,
$path,
'thumb',
self::RESPONSIVE_VARIANT_WIDTHS['thumb'],
$encodedImage['width'],
$encodedImage['height'],
);
$mediumVariant = $this->storeResponsiveVariant(
$disk,
$raw,
$constraints,
$path,
'md',
self::RESPONSIVE_VARIANT_WIDTHS['md'],
$encodedImage['width'],
$encodedImage['height'],
);
return [
'path' => $path,
'thumb_path' => $thumbVariant['path'] ?? $path,
'thumb_width' => $thumbVariant['width'] ?? $encodedImage['width'],
'thumb_height' => $thumbVariant['height'] ?? $encodedImage['height'],
'medium_path' => $mediumVariant['path'] ?? '',
'medium_width' => $mediumVariant['width'] ?? null,
'medium_height' => $mediumVariant['height'] ?? null,
'width' => $encodedImage['width'],
'height' => $encodedImage['height'],
'size_bytes' => strlen($encoded),
];
}
/**
* @return array{binary:string,width:int,height:int}
*/
private function encodeScaledMedia(string $raw, int $maxWidth, int $maxHeight): array
{
$image = $this->manager->read($raw)->scaleDown(width: $maxWidth, height: $maxHeight);
$encoded = (string) $image->encode(new WebpEncoder(85));
if ($encoded === '') {
throw new RuntimeException('Unable to encode image to WebP.');
}
return [
'binary' => $encoded,
'width' => (int) $image->width(),
'height' => (int) $image->height(),
];
}
/**
* @param array{max_width:int,max_height:int} $constraints
* @return array{path:string,width:int,height:int}|null
*/
private function storeResponsiveVariant($disk, string $raw, array $constraints, string $path, string $variant, int $targetWidth, int $sourceWidth, int $sourceHeight): ?array
{
if ($sourceWidth <= $targetWidth && $sourceHeight <= $constraints['max_height']) {
return null;
}
$encodedVariant = $this->encodeScaledMedia($raw, $targetWidth, $constraints['max_height']);
if ($encodedVariant['width'] >= $sourceWidth && $encodedVariant['height'] >= $sourceHeight) {
return null;
}
$variantPath = $this->responsiveVariantPath($path, $variant);
$this->writeMediaBinary($disk, $variantPath, $encodedVariant['binary']);
return [
'path' => $variantPath,
'width' => $encodedVariant['width'],
'height' => $encodedVariant['height'],
];
}
private function writeMediaBinary($disk, string $path, string $binary): void
{
$written = $disk->put($path, $binary, [
'visibility' => 'public',
'CacheControl' => 'public, max-age=31536000, immutable',
'ContentType' => 'image/webp',
@@ -218,13 +320,6 @@ final class AcademyLessonMediaApiController extends Controller
if ($written !== true) {
throw new RuntimeException('Unable to store image in object storage.');
}
return [
'path' => $path,
'width' => (int) $image->width(),
'height' => (int) $image->height(),
'size_bytes' => strlen($encoded),
];
}
private function authorizeStaff(Request $request): void
@@ -255,6 +350,54 @@ final class AcademyLessonMediaApiController extends Controller
return rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/') . '/' . ltrim($path, '/');
}
/**
* @param array<int, array{path:string,width:int|null}> $variants
*/
private function buildResponsiveSrcset(array $variants): ?string
{
$entries = collect($variants)
->filter(function (array $variant): bool {
return trim((string) ($variant['path'] ?? '')) !== '' && (int) ($variant['width'] ?? 0) > 0;
})
->unique(fn (array $variant): string => trim((string) ($variant['path'] ?? '')))
->map(fn (array $variant): string => sprintf('%s %dw', $this->publicUrlForPath((string) $variant['path']), (int) $variant['width']))
->values()
->all();
return $entries !== [] ? implode(', ', $entries) : null;
}
private function responsiveVariantPath(string $path, string $variant): string
{
$directory = pathinfo($path, PATHINFO_DIRNAME);
$filename = pathinfo($path, PATHINFO_FILENAME);
return sprintf(
'%s/%s-%s.webp',
$directory === '.' ? '' : $directory,
preg_replace('/-(thumb|md)$/', '', $filename) ?? $filename,
$variant,
);
}
private function canonicalMediaPath(string $path): string
{
$directory = pathinfo($path, PATHINFO_DIRNAME);
$filename = pathinfo($path, PATHINFO_FILENAME);
$baseFilename = preg_replace('/-(thumb|md)$/', '', $filename) ?? $filename;
return sprintf(
'%s/%s.webp',
$directory === '.' ? '' : $directory,
$baseFilename,
);
}
private function isResponsiveVariantPath(string $path): bool
{
return preg_match('/-(thumb|md)\.webp$/i', $path) === 1;
}
private function academyAssetManifest(): Collection
{
return Cache::remember($this->academyAssetCacheKey(), now()->addMinutes(self::ASSET_CACHE_TTL_MINUTES), function (): Collection {
@@ -262,6 +405,7 @@ final class AcademyLessonMediaApiController extends Controller
return collect($disk->allFiles('academy/lessons'))
->filter(fn (string $path): bool => Str::endsWith(Str::lower($path), ['.webp', '.jpg', '.jpeg', '.png']))
->reject(fn (string $path): bool => $this->isResponsiveVariantPath($path))
->map(function (string $path) use ($disk): array {
$modifiedAt = null;
@@ -323,7 +467,14 @@ final class AcademyLessonMediaApiController extends Controller
return;
}
Storage::disk($this->mediaDiskName())->delete($trimmed);
$basePath = $this->canonicalMediaPath($trimmed);
$paths = [
$basePath,
$this->responsiveVariantPath($basePath, 'thumb'),
$this->responsiveVariantPath($basePath, 'md'),
];
Storage::disk($this->mediaDiskName())->delete(array_values(array_unique($paths)));
}
private function normalizeSlot(mixed $slot): string
@@ -346,8 +497,8 @@ final class AcademyLessonMediaApiController extends Controller
}
return [
'min_width' => 1200,
'min_height' => 630,
'min_width' => 600,
'min_height' => 315,
'max_width' => 2200,
'max_height' => 1400,
];

View File

@@ -0,0 +1,512 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Settings;
use App\Http\Controllers\Controller;
use App\Models\World;
use App\Models\WorldWebStory;
use App\Models\WorldWebStoryPage;
use App\Services\WebStories\WorldWebStoryAssetService;
use App\Services\WebStories\WorldWebStoryGenerator;
use App\Services\WebStories\WorldWebStoryValidationService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
use Inertia\Response;
final class WorldWebStoryAdminController extends Controller
{
private const PER_PAGE = 20;
public function __construct(
private readonly WorldWebStoryGenerator $generator,
private readonly WorldWebStoryAssetService $assets,
private readonly WorldWebStoryValidationService $validation,
) {
}
public function index(Request $request): Response
{
$filters = [
'q' => trim((string) $request->query('q', '')),
'status' => trim((string) $request->query('status', 'all')),
];
$stories = WorldWebStory::query()
->with('world')
->when($filters['q'] !== '', function ($query) use ($filters): void {
$query->where(function ($nested) use ($filters): void {
$nested->where('title', 'like', '%' . $filters['q'] . '%')
->orWhere('slug', 'like', '%' . $filters['q'] . '%')
->orWhereHas('world', fn ($worldQuery) => $worldQuery->where('title', 'like', '%' . $filters['q'] . '%')->orWhere('slug', 'like', '%' . $filters['q'] . '%'));
});
})
->when($filters['status'] !== 'all', fn ($query) => $query->where('status', $filters['status']))
->orderByDesc('published_at')
->orderByDesc('updated_at')
->paginate(self::PER_PAGE)
->withQueryString()
->through(fn (WorldWebStory $story): array => $this->mapStoryListItem($story));
return Inertia::render('Moderation/WorldWebStoriesIndex', [
'title' => 'World Web Stories',
'stories' => $stories,
'filters' => $filters,
'stats' => [
'total' => WorldWebStory::query()->count(),
'published' => WorldWebStory::query()->where('status', WorldWebStory::STATUS_PUBLISHED)->count(),
'draft' => WorldWebStory::query()->where('status', WorldWebStory::STATUS_DRAFT)->count(),
'hidden' => WorldWebStory::query()->where('noindex', true)->orWhere('active', false)->count(),
],
'worldOptions' => $this->worldOptions(),
'endpoints' => [
'index' => route('admin.web-stories.index'),
'create' => route('admin.web-stories.create'),
'editPattern' => route('admin.web-stories.edit', ['story' => '__STORY__']),
'destroyPattern' => route('admin.web-stories.destroy', ['story' => '__STORY__']),
'publishPattern' => route('admin.web-stories.publish', ['story' => '__STORY__']),
'unpublishPattern' => route('admin.web-stories.unpublish', ['story' => '__STORY__']),
'generatePattern' => route('admin.web-stories.generate', ['world' => '__WORLD__']),
],
])->rootView('moderation');
}
public function create(): Response
{
return Inertia::render('Moderation/WorldWebStoryEditor', [
'story' => $this->blankStoryPayload(),
'worldOptions' => $this->worldOptions(),
'endpoints' => $this->editorEndpoints(),
'isNew' => true,
])->rootView('moderation');
}
public function store(Request $request): RedirectResponse
{
$attributes = $this->validatedStoryAttributes($request);
$story = new WorldWebStory();
$story->fill($attributes + [
'created_by' => (int) $request->user()->id,
'updated_by' => (int) $request->user()->id,
]);
$this->normalizeStatusTimestamps($story);
$this->assertPublishedStateIsValid($story);
$story->save();
return redirect()->route('admin.web-stories.edit', ['story' => $story])->with('success', 'Web story created.');
}
public function edit(WorldWebStory $story): Response
{
$story->load(['world', 'orderedPages.artwork']);
return Inertia::render('Moderation/WorldWebStoryEditor', [
'story' => $this->mapStoryEditorPayload($story),
'worldOptions' => $this->worldOptions(),
'endpoints' => $this->editorEndpoints($story),
'isNew' => false,
])->rootView('moderation');
}
public function update(Request $request, WorldWebStory $story): RedirectResponse
{
$story->fill($this->validatedStoryAttributes($request) + [
'updated_by' => (int) $request->user()->id,
]);
$this->normalizeStatusTimestamps($story);
$this->assertPublishedStateIsValid($story);
$story->save();
return back()->with('success', 'Web story updated.');
}
public function destroy(WorldWebStory $story): JsonResponse
{
$story->delete();
return response()->json([
'ok' => true,
'message' => 'Web story deleted.',
]);
}
public function storePage(Request $request, WorldWebStory $story): JsonResponse
{
$attributes = $this->validatedPageAttributes($request, $story, null);
$page = $story->pages()->create($attributes);
return response()->json([
'ok' => true,
'message' => 'Page created.',
'page' => $this->mapPage($page->fresh('artwork')),
]);
}
public function updatePage(Request $request, WorldWebStory $story, WorldWebStoryPage $page): JsonResponse
{
abort_unless((int) $page->story_id === (int) $story->id, 404);
$page->fill($this->validatedPageAttributes($request, $story, $page));
$page->save();
return response()->json([
'ok' => true,
'message' => 'Page updated.',
'page' => $this->mapPage($page->fresh('artwork')),
]);
}
public function destroyPage(WorldWebStory $story, WorldWebStoryPage $page): JsonResponse
{
abort_unless((int) $page->story_id === (int) $story->id, 404);
$page->delete();
return response()->json([
'ok' => true,
'message' => 'Page deleted.',
]);
}
public function reorderPages(Request $request, WorldWebStory $story): JsonResponse
{
$validated = $request->validate([
'page_ids' => ['required', 'array', 'min:1'],
'page_ids.*' => ['integer'],
]);
$ids = collect($validated['page_ids'])->map(fn ($id): int => (int) $id)->values();
$pages = $story->orderedPages()->whereIn('id', $ids)->get()->keyBy('id');
abort_unless($pages->count() === $ids->count(), 422);
foreach ($ids as $index => $id) {
$pages[$id]->forceFill(['position' => $index + 1])->save();
}
return response()->json([
'ok' => true,
'message' => 'Page order updated.',
]);
}
public function generateFromWorld(Request $request, World $world): JsonResponse
{
$validated = $request->validate([
'force' => ['nullable', 'boolean'],
'publish' => ['nullable', 'boolean'],
'dry_run' => ['nullable', 'boolean'],
'pages' => ['nullable', 'integer', 'min:5', 'max:10'],
]);
$result = $this->generator->generateFromWorld(
$world,
$request->user(),
(int) ($validated['pages'] ?? 7),
(bool) ($validated['force'] ?? false),
(bool) ($validated['publish'] ?? false),
(bool) ($validated['dry_run'] ?? false),
);
return response()->json([
'ok' => true,
'message' => $result['created'] ? 'Web story draft generated.' : 'Web story draft regenerated.',
'story' => [
'id' => $result['story']->id,
'slug' => $result['story']->slug,
'edit_url' => $result['story']->exists ? route('admin.web-stories.edit', ['story' => $result['story']->id]) : null,
],
'validation' => $result['validation'],
]);
}
public function publish(WorldWebStory $story): JsonResponse
{
$this->assets->buildAssets($story, force: false);
$story->refresh()->load('orderedPages');
$this->validation->assertPublishable($story);
$story->forceFill([
'status' => WorldWebStory::STATUS_PUBLISHED,
'published_at' => $story->published_at ?: now(),
])->save();
return response()->json([
'ok' => true,
'message' => 'Web story published.',
]);
}
public function unpublish(WorldWebStory $story): JsonResponse
{
$story->forceFill([
'status' => WorldWebStory::STATUS_DRAFT,
'published_at' => null,
])->save();
return response()->json([
'ok' => true,
'message' => 'Web story reverted to draft.',
]);
}
/**
* @return array<string, mixed>
*/
private function validatedStoryAttributes(Request $request, ?WorldWebStory $story = null): array
{
$validated = $request->validate([
'world_id' => ['nullable', 'integer', Rule::exists('worlds', 'id')],
'slug' => ['required', 'string', 'max:120', Rule::unique('world_web_stories', 'slug')->ignore($story?->id)],
'title' => ['required', 'string', 'max:255'],
'subtitle' => ['nullable', 'string', 'max:255'],
'excerpt' => ['nullable', 'string', 'max:400'],
'description' => ['nullable', 'string', 'max:2000'],
'seo_title' => ['nullable', 'string', 'max:255'],
'seo_description' => ['nullable', 'string', 'max:400'],
'poster_portrait_path' => ['nullable', 'string', 'max:2048'],
'poster_square_path' => ['nullable', 'string', 'max:2048'],
'publisher_logo_path' => ['nullable', 'string', 'max:2048'],
'status' => ['required', Rule::in([WorldWebStory::STATUS_DRAFT, WorldWebStory::STATUS_PUBLISHED, WorldWebStory::STATUS_ARCHIVED])],
'featured' => ['required', 'boolean'],
'active' => ['required', 'boolean'],
'noindex' => ['required', 'boolean'],
'published_at' => ['nullable', 'date'],
'starts_at' => ['nullable', 'date'],
'ends_at' => ['nullable', 'date', 'after_or_equal:starts_at'],
]);
return $validated;
}
/**
* @return array<string, mixed>
*/
private function validatedPageAttributes(Request $request, WorldWebStory $story, ?WorldWebStoryPage $page): array
{
$validated = $request->validate([
'artwork_id' => ['nullable', 'integer', Rule::exists('artworks', 'id')],
'position' => ['nullable', 'integer', 'min:1'],
'layout' => ['required', Rule::in([
WorldWebStoryPage::LAYOUT_COVER,
WorldWebStoryPage::LAYOUT_ARTWORK,
WorldWebStoryPage::LAYOUT_CREATOR,
WorldWebStoryPage::LAYOUT_MOOD,
WorldWebStoryPage::LAYOUT_COLLECTION,
WorldWebStoryPage::LAYOUT_CTA,
])],
'background_type' => ['required', Rule::in([
WorldWebStoryPage::BACKGROUND_IMAGE,
WorldWebStoryPage::BACKGROUND_VIDEO,
WorldWebStoryPage::BACKGROUND_GRADIENT,
])],
'background_path' => ['nullable', 'string', 'max:2048'],
'background_mobile_path' => ['nullable', 'string', 'max:2048'],
'headline' => ['nullable', 'string', 'max:255'],
'body' => ['nullable', 'string', 'max:180'],
'cta_label' => ['nullable', 'string', 'max:120'],
'cta_url' => ['nullable', 'string', 'max:2048'],
'alt_text' => ['required', 'string', 'max:255'],
'caption' => ['nullable', 'string', 'max:120'],
'credit_text' => ['nullable', 'string', 'max:255'],
'text_position' => ['required', Rule::in(['top', 'center', 'bottom'])],
'overlay_strength' => ['required', 'integer', 'min:0', 'max:100'],
'animation' => ['nullable', Rule::in(['fade-in', 'fly-in-bottom', 'pulse', 'pan-left', 'pan-right'])],
'active' => ['required', 'boolean'],
]);
$validated['position'] = (int) ($validated['position'] ?? ($story->orderedPages()->max('position') + ($page ? 0 : 1) ?: 1));
$pageErrors = $this->validation->validatePagePayload($validated);
if ($pageErrors !== []) {
throw ValidationException::withMessages($pageErrors);
}
return $validated;
}
private function normalizeStatusTimestamps(WorldWebStory $story): void
{
if ((string) $story->status === WorldWebStory::STATUS_PUBLISHED && $story->published_at === null) {
$story->published_at = now();
}
if ((string) $story->status === WorldWebStory::STATUS_DRAFT) {
$story->published_at = null;
}
}
private function assertPublishedStateIsValid(WorldWebStory $story): void
{
if ((string) $story->status !== WorldWebStory::STATUS_PUBLISHED) {
return;
}
$story->loadMissing('orderedPages');
$this->validation->assertPublishable($story);
}
/**
* @return array<int, array{value:int,label:string,description:string}>
*/
private function worldOptions(): array
{
return World::query()
->orderByDesc('published_at')
->orderBy('title')
->limit(200)
->get(['id', 'title', 'slug'])
->map(fn (World $world): array => [
'value' => (int) $world->id,
'label' => (string) $world->title,
'description' => (string) $world->slug,
])
->all();
}
/**
* @return array<string, mixed>
*/
private function blankStoryPayload(): array
{
return [
'id' => null,
'world_id' => null,
'slug' => '',
'title' => '',
'subtitle' => '',
'excerpt' => '',
'description' => '',
'seo_title' => '',
'seo_description' => '',
'poster_portrait_path' => '',
'poster_square_path' => '',
'publisher_logo_path' => $this->assets->defaultPublisherLogoPath(),
'status' => WorldWebStory::STATUS_DRAFT,
'featured' => false,
'active' => true,
'noindex' => false,
'published_at' => null,
'starts_at' => null,
'ends_at' => null,
'world' => null,
'pages' => [],
'public_url' => null,
'validation' => ['valid' => false, 'errors' => [], 'warnings' => [], 'page_count' => 0],
];
}
/**
* @return array<string, mixed>
*/
private function mapStoryEditorPayload(WorldWebStory $story): array
{
return [
'id' => (int) $story->id,
'world_id' => $story->world_id ? (int) $story->world_id : null,
'slug' => (string) $story->slug,
'title' => (string) $story->title,
'subtitle' => (string) ($story->subtitle ?? ''),
'excerpt' => (string) ($story->excerpt ?? ''),
'description' => (string) ($story->description ?? ''),
'seo_title' => (string) ($story->seo_title ?? ''),
'seo_description' => (string) ($story->seo_description ?? ''),
'poster_portrait_path' => (string) ($story->poster_portrait_path ?? ''),
'poster_square_path' => (string) ($story->poster_square_path ?? ''),
'publisher_logo_path' => (string) ($story->publisher_logo_path ?? ''),
'status' => (string) $story->status,
'featured' => (bool) $story->featured,
'active' => (bool) $story->active,
'noindex' => (bool) $story->noindex,
'published_at' => optional($story->published_at)?->toIso8601String(),
'starts_at' => optional($story->starts_at)?->toIso8601String(),
'ends_at' => optional($story->ends_at)?->toIso8601String(),
'world' => $story->world ? [
'id' => (int) $story->world->id,
'title' => (string) $story->world->title,
'slug' => (string) $story->world->slug,
] : null,
'pages' => $story->orderedPages->map(fn (WorldWebStoryPage $page): array => $this->mapPage($page))->all(),
'public_url' => route('web-stories.show', ['slug' => $story->slug]),
'validation' => $this->validation->validate($story),
];
}
/**
* @return array<string, mixed>
*/
private function mapStoryListItem(WorldWebStory $story): array
{
return [
'id' => (int) $story->id,
'slug' => (string) $story->slug,
'title' => (string) $story->title,
'excerpt' => (string) ($story->excerpt ?? ''),
'status' => (string) $story->status,
'active' => (bool) $story->active,
'noindex' => (bool) $story->noindex,
'featured' => (bool) $story->featured,
'page_count' => (int) ($story->pages()->count()),
'published_at' => optional($story->published_at)?->toIso8601String(),
'poster_portrait_url' => $story->posterPortraitUrl(),
'world' => $story->world ? [
'id' => (int) $story->world->id,
'title' => (string) $story->world->title,
'slug' => (string) $story->world->slug,
] : null,
'public_url' => route('web-stories.show', ['slug' => $story->slug]),
];
}
/**
* @return array<string, mixed>
*/
private function mapPage(WorldWebStoryPage $page): array
{
return [
'id' => (int) $page->id,
'artwork_id' => $page->artwork_id ? (int) $page->artwork_id : null,
'position' => (int) $page->position,
'layout' => (string) $page->layout,
'background_type' => (string) $page->background_type,
'background_path' => (string) ($page->background_path ?? ''),
'background_mobile_path' => (string) ($page->background_mobile_path ?? ''),
'headline' => (string) ($page->headline ?? ''),
'body' => (string) ($page->body ?? ''),
'cta_label' => (string) ($page->cta_label ?? ''),
'cta_url' => (string) ($page->cta_url ?? ''),
'alt_text' => (string) ($page->alt_text ?? ''),
'caption' => (string) ($page->caption ?? ''),
'credit_text' => (string) ($page->credit_text ?? ''),
'text_position' => (string) ($page->text_position ?? 'bottom'),
'overlay_strength' => (int) ($page->overlay_strength ?? 35),
'animation' => (string) ($page->animation ?? ''),
'active' => (bool) $page->active,
'background_url' => $page->backgroundUrl(),
];
}
/**
* @return array<string, string>
*/
private function editorEndpoints(?WorldWebStory $story = null): array
{
return [
'store' => route('admin.web-stories.store'),
'update' => $story ? route('admin.web-stories.update', ['story' => $story]) : '',
'destroy' => $story ? route('admin.web-stories.destroy', ['story' => $story]) : '',
'pagesStore' => $story ? route('admin.web-stories.pages.store', ['story' => $story]) : '',
'pagesUpdatePattern' => $story ? route('admin.web-stories.pages.update', ['story' => $story, 'page' => '__PAGE__']) : '',
'pagesDestroyPattern' => $story ? route('admin.web-stories.pages.destroy', ['story' => $story, 'page' => '__PAGE__']) : '',
'pagesReorder' => $story ? route('admin.web-stories.pages.reorder', ['story' => $story]) : '',
'publish' => $story ? route('admin.web-stories.publish', ['story' => $story]) : '',
'unpublish' => $story ? route('admin.web-stories.unpublish', ['story' => $story]) : '',
'generateFromWorldPattern' => route('admin.web-stories.generate', ['world' => '__WORLD__']),
'index' => route('admin.web-stories.index'),
];
}
}