433 lines
18 KiB
PHP
433 lines
18 KiB
PHP
<?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'),
|
|
'coverUploadUrl' => route('api.studio.news.media.upload'),
|
|
'coverDeleteUrl' => route('api.studio.news.media.destroy'),
|
|
'coverCdnBaseUrl' => rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/'),
|
|
'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(),
|
|
'coverUploadUrl' => route('api.studio.news.media.upload'),
|
|
'coverDeleteUrl' => route('api.studio.news.media.destroy'),
|
|
'coverCdnBaseUrl' => rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/'),
|
|
'updateUrl' => route('studio.news.update', ['article' => $article->id]),
|
|
'destroyUrl' => route('studio.news.destroy', ['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()),
|
|
'comments' => collect(),
|
|
'commentsCount' => 0,
|
|
'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 redirect()->route('studio.news.edit', ['article' => $article->id])->with('success', 'Article updated.');
|
|
}
|
|
|
|
public function destroy(Request $request, NewsArticle $article): RedirectResponse
|
|
{
|
|
$this->authorizeNews($request);
|
|
|
|
$this->news->deleteArticle($article);
|
|
|
|
return redirect()->route('studio.news.index')->with('success', 'Article moved to trash.');
|
|
}
|
|
|
|
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:500000'],
|
|
'cover_image' => ['nullable', 'string', 'max:2048'],
|
|
'type' => ['required', Rule::in(array_column($this->news->articleTypeOptions(), 'value'))],
|
|
'category_id' => ['required', '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'],
|
|
'comments_enabled' => ['nullable', 'boolean'],
|
|
'tag_ids' => ['nullable', 'array'],
|
|
'tag_ids.*' => ['integer', 'exists:news_tags,id'],
|
|
'new_tag_names' => ['nullable', 'array', 'max:12'],
|
|
'new_tag_names.*' => ['string', 'max:80'],
|
|
'meta_title' => ['nullable', 'string', 'max:255'],
|
|
'meta_description' => ['nullable', 'string', 'max:300'],
|
|
'meta_keywords' => ['nullable', 'string', 'max:255'],
|
|
'canonical_url' => ['nullable', 'string', 'max:2048', function (string $attribute, mixed $value, \Closure $fail): void {
|
|
if ($value === '' || $value === null) {
|
|
return;
|
|
}
|
|
$isAbsolute = filter_var($value, FILTER_VALIDATE_URL) !== false;
|
|
$isRelative = str_starts_with($value, '/');
|
|
if (! $isAbsolute && ! $isRelative) {
|
|
$fail('The canonical URL must be a valid URL or a relative path starting with /.');
|
|
}
|
|
}],
|
|
'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;
|
|
}
|
|
} |