512 lines
20 KiB
PHP
512 lines
20 KiB
PHP
<?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'),
|
|
];
|
|
}
|
|
} |