Save workspace changes

This commit is contained in:
2026-04-18 17:02:56 +02:00
parent f02ea9a711
commit 87d60af5a9
4220 changed files with 1388603 additions and 1554 deletions

View File

@@ -0,0 +1,403 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Studio;
use App\Http\Controllers\Controller;
use App\Services\News\NewsService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use Illuminate\View\View;
use Inertia\Inertia;
use Inertia\Response;
use cPad\Plugins\News\Models\NewsArticle;
use cPad\Plugins\News\Models\NewsCategory;
use cPad\Plugins\News\Models\NewsTag;
final class StudioNewsController extends Controller
{
public function __construct(private readonly NewsService $news)
{
}
public function index(Request $request): Response
{
$this->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;
}
}