Implement academy analytics, billing, and web stories updates
This commit is contained in:
512
app/Http/Controllers/Settings/WorldWebStoryAdminController.php
Normal file
512
app/Http/Controllers/Settings/WorldWebStoryAdminController.php
Normal 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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user