authorizeNews($request); return Inertia::render('Studio/StudioNewsIndex', [ 'title' => 'Newsroom', 'description' => 'Plan announcements, publish editorial stories, and connect articles to the rest of Nova.', 'listing' => $this->news->studioListing($request->only(['q', 'status', 'type', 'category_id', 'per_page', 'page'])), 'statusOptions' => $this->news->editorialStatusOptions(), 'typeOptions' => $this->news->articleTypeOptions(), 'categoryOptions' => $this->news->categoryOptions(), 'createUrl' => route('studio.news.create'), 'categoriesUrl' => route('studio.news.categories'), 'tagsUrl' => route('studio.news.tags'), ]); } public function create(Request $request): Response { $this->authorizeNews($request); return Inertia::render('Studio/StudioNewsEditor', [ 'title' => 'Create article', 'description' => 'Draft a new News story with editorial workflow, SEO metadata, and related entity links.', 'article' => null, 'typeOptions' => $this->news->articleTypeOptions(), 'statusOptions' => $this->news->editorialStatusOptions(), 'categoryOptions' => $this->news->categoryOptions(), 'tagOptions' => $this->news->tagOptions(), 'relationTypeOptions' => $this->news->relationTypeOptions(), 'storeUrl' => route('studio.news.store'), 'entitySearchUrl' => route('studio.news.entity-search'), 'categoriesUrl' => route('studio.news.categories'), 'tagsUrl' => route('studio.news.tags'), 'defaultAuthor' => $this->news->searchEntities('user', (string) $request->user()->username)[0] ?? null, ]); } public function store(Request $request): RedirectResponse { $this->authorizeNews($request); $article = $this->news->storeArticle($request->user(), $this->validateArticle($request)); return redirect()->route('studio.news.edit', ['article' => $article->id])->with('success', 'Article draft created.'); } public function edit(Request $request, NewsArticle $article): Response { $this->authorizeNews($request); return Inertia::render('Studio/StudioNewsEditor', [ 'title' => 'Edit article', 'description' => 'Refine the story, tune SEO, and attach related Nova entities before publishing.', 'article' => $this->news->mapStudioArticle($article, $request->user()), 'typeOptions' => $this->news->articleTypeOptions(), 'statusOptions' => $this->news->editorialStatusOptions(), 'categoryOptions' => $this->news->categoryOptions(), 'tagOptions' => $this->news->tagOptions(), 'relationTypeOptions' => $this->news->relationTypeOptions(), 'updateUrl' => route('studio.news.update', ['article' => $article->id]), 'previewUrl' => route('studio.news.preview', ['article' => $article->id]), 'publishUrl' => route('studio.news.publish', ['article' => $article->id]), 'archiveUrl' => route('studio.news.archive', ['article' => $article->id]), 'featureUrl' => route('studio.news.feature', ['article' => $article->id]), 'pinUrl' => route('studio.news.pin', ['article' => $article->id]), 'entitySearchUrl' => route('studio.news.entity-search'), 'categoriesUrl' => route('studio.news.categories'), 'tagsUrl' => route('studio.news.tags'), ]); } public function preview(Request $request, NewsArticle $article): View { $this->authorizeNews($request); $article->loadMissing(['author.profile', 'category', 'tags', 'relatedEntities']); $related = NewsArticle::with('author', 'category') ->published() ->when($article->category_id, fn ($query) => $query->where('category_id', $article->category_id)) ->where('id', '!=', $article->id) ->editorialOrder() ->limit(config('news.related_limit', 4)) ->get(); return view('news.show', [ 'article' => $article, 'related' => $related, 'relatedEntities' => $this->news->resolveRelatedEntities($article, $request->user()), 'previewMode' => true, 'previewCanonical' => route('studio.news.preview', ['article' => $article->id]), 'previewBackUrl' => route('studio.news.edit', ['article' => $article->id]), ] + $this->news->sidebarData()); } public function update(Request $request, NewsArticle $article): RedirectResponse { $this->authorizeNews($request); $this->news->updateArticle($article, $request->user(), $this->validateArticle($request, $article)); return back()->with('success', 'Article updated.'); } public function publish(Request $request, NewsArticle $article): RedirectResponse { $this->authorizeNews($request); $this->news->publish($article); return back()->with('success', 'Article published.'); } public function archive(Request $request, NewsArticle $article): RedirectResponse { $this->authorizeNews($request); $this->news->archive($article); return back()->with('success', 'Article archived.'); } public function feature(Request $request, NewsArticle $article): RedirectResponse { $this->authorizeNews($request); $updated = $this->news->toggleFeature($article); return back()->with('success', $updated->is_featured ? 'Article featured.' : 'Article removed from featured surface.'); } public function pin(Request $request, NewsArticle $article): RedirectResponse { $this->authorizeNews($request); $updated = $this->news->togglePin($article); return back()->with('success', $updated->is_pinned ? 'Article pinned.' : 'Article unpinned.'); } public function categories(Request $request): Response { $this->authorizeNews($request); return Inertia::render('Studio/StudioNewsTaxonomies', [ 'title' => 'News taxonomies', 'description' => 'Manage News categories and tags used across the editorial surface.', 'activeTab' => 'categories', 'categories' => NewsCategory::query() ->withCount('publishedArticles') ->ordered() ->get() ->map(fn (NewsCategory $category): array => [ 'id' => (int) $category->id, 'name' => (string) $category->name, 'slug' => (string) $category->slug, 'description' => (string) ($category->description ?? ''), 'position' => (int) $category->position, 'is_active' => (bool) $category->is_active, 'published_count' => (int) $category->published_articles_count, ]) ->all(), 'tags' => $this->tagPayload(), 'storeCategoryUrl' => route('studio.news.categories.store'), 'storeTagUrl' => route('studio.news.tags.store'), 'updateCategoryUrlPattern' => route('studio.news.categories.update', ['category' => '__CATEGORY__']), 'updateTagUrlPattern' => route('studio.news.tags.update', ['tag' => '__TAG__']), ]); } public function tags(Request $request): Response { $this->authorizeNews($request); return Inertia::render('Studio/StudioNewsTaxonomies', [ 'title' => 'News taxonomies', 'description' => 'Manage News categories and tags used across the editorial surface.', 'activeTab' => 'tags', 'categories' => NewsCategory::query() ->withCount('publishedArticles') ->ordered() ->get() ->map(fn (NewsCategory $category): array => [ 'id' => (int) $category->id, 'name' => (string) $category->name, 'slug' => (string) $category->slug, 'description' => (string) ($category->description ?? ''), 'position' => (int) $category->position, 'is_active' => (bool) $category->is_active, 'published_count' => (int) $category->published_articles_count, ]) ->all(), 'tags' => $this->tagPayload(), 'storeCategoryUrl' => route('studio.news.categories.store'), 'storeTagUrl' => route('studio.news.tags.store'), 'updateCategoryUrlPattern' => route('studio.news.categories.update', ['category' => '__CATEGORY__']), 'updateTagUrlPattern' => route('studio.news.tags.update', ['tag' => '__TAG__']), ]); } public function storeCategory(Request $request): RedirectResponse { $this->authorizeNews($request); $validated = $request->validate([ 'name' => ['required', 'string', 'max:120', 'unique:news_categories,name'], 'slug' => ['nullable', 'string', 'max:120', 'unique:news_categories,slug'], 'description' => ['nullable', 'string'], 'position' => ['nullable', 'integer', 'min:0', 'max:65535'], 'is_active' => ['nullable', 'boolean'], ]); NewsCategory::query()->create([ 'name' => trim((string) $validated['name']), 'slug' => NewsCategory::generateUniqueSlug((string) ($validated['slug'] ?? $validated['name'])), 'description' => $validated['description'] ?? null, 'position' => (int) ($validated['position'] ?? 0), 'is_active' => (bool) ($validated['is_active'] ?? true), ]); return back()->with('success', 'Category created.'); } public function updateCategory(Request $request, NewsCategory $category): RedirectResponse { $this->authorizeNews($request); $validated = $request->validate([ 'name' => ['required', 'string', 'max:120', Rule::unique('news_categories', 'name')->ignore($category->id)], 'slug' => ['nullable', 'string', 'max:120', Rule::unique('news_categories', 'slug')->ignore($category->id)], 'description' => ['nullable', 'string'], 'position' => ['nullable', 'integer', 'min:0', 'max:65535'], 'is_active' => ['nullable', 'boolean'], ]); $category->update([ 'name' => trim((string) $validated['name']), 'slug' => NewsCategory::generateUniqueSlug((string) ($validated['slug'] ?? $validated['name']), (int) $category->id), 'description' => $validated['description'] ?? null, 'position' => (int) ($validated['position'] ?? 0), 'is_active' => (bool) ($validated['is_active'] ?? true), ]); return back()->with('success', 'Category updated.'); } public function storeTag(Request $request): RedirectResponse { $this->authorizeNews($request); $validated = $request->validate([ 'name' => ['required', 'string', 'max:80', 'unique:news_tags,name'], 'slug' => ['nullable', 'string', 'max:80', 'unique:news_tags,slug'], ]); NewsTag::query()->create([ 'name' => trim((string) $validated['name']), 'slug' => $this->uniqueTagSlug((string) ($validated['slug'] ?? $validated['name'])), ]); return back()->with('success', 'Tag created.'); } public function updateTag(Request $request, NewsTag $tag): RedirectResponse { $this->authorizeNews($request); $validated = $request->validate([ 'name' => ['required', 'string', 'max:80', Rule::unique('news_tags', 'name')->ignore($tag->id)], 'slug' => ['nullable', 'string', 'max:80', Rule::unique('news_tags', 'slug')->ignore($tag->id)], ]); $tag->update([ 'name' => trim((string) $validated['name']), 'slug' => $this->uniqueTagSlug((string) ($validated['slug'] ?? $validated['name']), (int) $tag->id), ]); return back()->with('success', 'Tag updated.'); } public function entitySearch(Request $request): JsonResponse { $this->authorizeNews($request); $validated = $request->validate([ 'type' => ['required', Rule::in(array_column($this->news->relationTypeOptions(), 'value'))], 'q' => ['nullable', 'string', 'max:120'], ]); return response()->json([ 'items' => $this->news->searchEntities((string) $validated['type'], (string) ($validated['q'] ?? ''), $request->user()), ]); } private function authorizeNews(Request $request): void { abort_unless($request->user() && ($request->user()->isAdmin() || $request->user()->isModerator()), 403); } private function validateArticle(Request $request, ?NewsArticle $article = null): array { $validated = $request->validate([ 'title' => ['required', 'string', 'max:255'], 'slug' => ['nullable', 'string', 'max:255'], 'excerpt' => ['nullable', 'string', 'max:800'], 'content' => ['required', 'string', 'max:50000'], 'cover_image' => ['nullable', 'string', 'max:2048'], 'type' => ['required', Rule::in(array_column($this->news->articleTypeOptions(), 'value'))], 'category_id' => ['nullable', 'integer', 'exists:news_categories,id'], 'author_id' => ['nullable', 'integer', 'exists:users,id'], 'editorial_status' => ['required', Rule::in(array_column($this->news->editorialStatusOptions(), 'value'))], 'published_at' => ['nullable', 'date'], 'is_featured' => ['nullable', 'boolean'], 'is_pinned' => ['nullable', 'boolean'], 'tag_ids' => ['nullable', 'array'], 'tag_ids.*' => ['integer', 'exists:news_tags,id'], 'meta_title' => ['nullable', 'string', 'max:255'], 'meta_description' => ['nullable', 'string', 'max:300'], 'meta_keywords' => ['nullable', 'string', 'max:255'], 'canonical_url' => ['nullable', 'url', 'max:2048'], 'og_title' => ['nullable', 'string', 'max:255'], 'og_description' => ['nullable', 'string', 'max:300'], 'og_image' => ['nullable', 'string', 'max:2048'], 'relations' => ['nullable', 'array', 'max:12'], 'relations.*.entity_type' => ['required_with:relations', Rule::in(array_column($this->news->relationTypeOptions(), 'value'))], 'relations.*.entity_id' => ['required_with:relations', 'integer', 'min:1'], 'relations.*.context_label' => ['nullable', 'string', 'max:120'], ]); if (($validated['editorial_status'] ?? null) === NewsArticle::EDITORIAL_STATUS_SCHEDULED && empty($validated['published_at'])) { throw ValidationException::withMessages([ 'published_at' => 'Scheduled articles need a publish date and time.', ]); } return $validated; } private function tagPayload(): array { return NewsTag::query() ->withCount(['articles' => fn ($query) => $query->published()]) ->orderBy('name') ->get() ->map(fn (NewsTag $tag): array => [ 'id' => (int) $tag->id, 'name' => (string) $tag->name, 'slug' => (string) $tag->slug, 'published_count' => (int) $tag->articles_count, ]) ->all(); } private function uniqueTagSlug(string $source, ?int $ignoreId = null): string { $base = Str::slug($source); $slug = $base !== '' ? $base : 'tag'; $counter = 1; $query = NewsTag::query()->where('slug', $slug); if ($ignoreId !== null) { $query->where('id', '!=', $ignoreId); } while ($query->exists()) { $slug = ($base !== '' ? $base : 'tag') . '-' . $counter++; $query = NewsTag::query()->where('slug', $slug); if ($ignoreId !== null) { $query->where('id', '!=', $ignoreId); } } return $slug; } }