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 */ 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 */ 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 */ 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 */ 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 */ 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 */ 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 */ 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 */ 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'), ]; } }