Files
SkinbaseNova/app/Http/Controllers/Settings/HomepageAnnouncementController.php

459 lines
20 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Http\Controllers\Settings;
use App\Http\Controllers\Controller;
use App\Http\Requests\Settings\UpsertHomepageAnnouncementRequest;
use App\Models\HomepageAnnouncement;
use App\Services\HomepageAnnouncementService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
use Inertia\Response;
class HomepageAnnouncementController extends Controller
{
private const BACKGROUND_WEBP_QUALITY = 84;
public function __construct(private readonly HomepageAnnouncementService $announcements)
{
}
public function index(Request $request): Response
{
$announcements = HomepageAnnouncement::query()
->latest('updated_at')
->paginate(20)
->withQueryString();
$announcements->getCollection()->transform(fn (HomepageAnnouncement $announcement): array => $this->serializeForIndex($announcement));
return Inertia::render('Admin/HomepageAnnouncements/Index', [
'announcements' => $announcements,
'createUrl' => route('admin.homepage-announcements.create'),
]);
}
public function create(): Response
{
$form = $this->blankForm();
return Inertia::render('Admin/HomepageAnnouncements/Form', [
'announcement' => $form,
'previewAnnouncement' => $this->announcements->previewPayload($form),
'options' => $this->options(),
'submitUrl' => route('admin.homepage-announcements.store'),
'previewUrl' => route('admin.homepage-announcements.preview'),
'indexUrl' => route('admin.homepage-announcements.index'),
'destroyUrl' => null,
]);
}
public function store(UpsertHomepageAnnouncementRequest $request): RedirectResponse
{
$actor = $this->currentActor($request);
$announcement = new HomepageAnnouncement();
$attributes = $this->persistedAttributes($request, $announcement);
$attributes['created_by'] = (int) $actor->id;
$attributes['updated_by'] = (int) $actor->id;
$announcement->forceFill($attributes)->save();
return redirect()
->route('admin.homepage-announcements.edit', ['homepageAnnouncement' => $announcement])
->with('success', 'Homepage announcement created.');
}
public function edit(HomepageAnnouncement $homepageAnnouncement): Response
{
$form = $this->serializeForForm($homepageAnnouncement);
return Inertia::render('Admin/HomepageAnnouncements/Form', [
'announcement' => $form,
'previewAnnouncement' => $this->announcements->toHomepagePayload($homepageAnnouncement),
'options' => $this->options(),
'submitUrl' => route('admin.homepage-announcements.update', ['homepageAnnouncement' => $homepageAnnouncement]),
'previewUrl' => route('admin.homepage-announcements.preview'),
'indexUrl' => route('admin.homepage-announcements.index'),
'destroyUrl' => route('admin.homepage-announcements.destroy', ['homepageAnnouncement' => $homepageAnnouncement]),
]);
}
public function update(UpsertHomepageAnnouncementRequest $request, HomepageAnnouncement $homepageAnnouncement): RedirectResponse
{
$actor = $this->currentActor($request);
$attributes = $this->persistedAttributes($request, $homepageAnnouncement);
$attributes['updated_by'] = (int) $actor->id;
$homepageAnnouncement->forceFill($attributes)->save();
return redirect()
->route('admin.homepage-announcements.edit', ['homepageAnnouncement' => $homepageAnnouncement])
->with('success', 'Homepage announcement updated.');
}
public function destroy(HomepageAnnouncement $homepageAnnouncement): RedirectResponse
{
$homepageAnnouncement->delete();
return redirect()
->route('admin.homepage-announcements.index')
->with('success', 'Homepage announcement deleted.');
}
public function preview(UpsertHomepageAnnouncementRequest $request): JsonResponse
{
$attributes = $this->persistedAttributes($request, null, false);
return response()->json([
'ok' => true,
'announcement' => $this->announcements->previewPayload($attributes),
]);
}
/**
* @return array<string, mixed>
*/
private function persistedAttributes(UpsertHomepageAnnouncementRequest $request, ?HomepageAnnouncement $announcement = null, bool $persistFile = true): array
{
$validated = $request->validated();
unset($validated['background_image_file'], $validated['remove_background_image']);
$validated = $this->announcements->sanitizeAttributes($validated);
$validated = $this->normalizeLinkAttributes($validated, 'primary');
$validated = $this->normalizeLinkAttributes($validated, 'secondary');
$currentBackground = $announcement?->background_image;
$backgroundImageFile = $this->backgroundImageUpload($request);
if ($request->boolean('remove_background_image')) {
if ($persistFile) {
$this->deleteStoredBackgroundIfLocal($currentBackground);
}
$validated['background_image'] = null;
} elseif ($persistFile && $backgroundImageFile instanceof UploadedFile) {
$this->deleteStoredBackgroundIfLocal($currentBackground);
$validated['background_image'] = $this->storeBackgroundImage($backgroundImageFile);
} elseif (! array_key_exists('background_image', $validated) && $announcement) {
$validated['background_image'] = $announcement->background_image;
}
$validated['background_type'] = filled($validated['background_image'] ?? null) ? 'image' : null;
return $validated;
}
/**
* @param array<string, mixed> $attributes
* @return array<string, mixed>
*/
private function normalizeLinkAttributes(array $attributes, string $prefix): array
{
$type = (string) ($attributes[$prefix . '_link_type'] ?? HomepageAnnouncement::LINK_TYPE_NONE);
if ($type === HomepageAnnouncement::LINK_TYPE_NONE) {
$attributes[$prefix . '_link_label'] = null;
$attributes[$prefix . '_link_url'] = null;
$attributes[$prefix . '_link_target_type'] = null;
$attributes[$prefix . '_link_target_id'] = null;
return $attributes;
}
$attributes[$prefix . '_link_label'] = filled($attributes[$prefix . '_link_label'] ?? null)
? trim((string) $attributes[$prefix . '_link_label'])
: null;
$attributes[$prefix . '_link_url'] = filled($attributes[$prefix . '_link_url'] ?? null)
? trim((string) $attributes[$prefix . '_link_url'])
: null;
$attributes[$prefix . '_link_target_id'] = filled($attributes[$prefix . '_link_target_id'] ?? null)
? (int) $attributes[$prefix . '_link_target_id']
: null;
if (in_array($type, [HomepageAnnouncement::LINK_TYPE_CUSTOM_URL, HomepageAnnouncement::LINK_TYPE_EXPLORE, HomepageAnnouncement::LINK_TYPE_UPLOAD], true)) {
$attributes[$prefix . '_link_target_type'] = null;
$attributes[$prefix . '_link_target_id'] = null;
return $attributes;
}
$attributes[$prefix . '_link_target_type'] = $type;
return $attributes;
}
private function deleteStoredBackgroundIfLocal(?string $path): void
{
$path = trim((string) $path);
if ($path === '' || str_starts_with($path, 'http://') || str_starts_with($path, 'https://') || str_starts_with($path, '/')) {
return;
}
$backgroundDisk = $this->announcements->backgroundImageDisk();
if (Storage::disk($backgroundDisk)->exists($path)) {
Storage::disk($backgroundDisk)->delete($path);
return;
}
if (Storage::disk('public')->exists($path)) {
Storage::disk('public')->delete($path);
}
}
private function backgroundImageUpload(UpsertHomepageAnnouncementRequest $request): ?UploadedFile
{
$file = $request->file('background_image_file');
if (! $file instanceof UploadedFile) {
return null;
}
$pathName = trim((string) $file->getPathname());
if ($file->isValid() && $pathName !== '' && is_file($pathName) && is_readable($pathName)) {
return $file;
}
throw ValidationException::withMessages([
'background_image_file' => $this->backgroundUploadErrorMessage($file),
]);
}
private function storeBackgroundImage(UploadedFile $file): string
{
$pathName = trim((string) $file->getPathname());
if ($pathName === '' || ! is_file($pathName) || ! is_readable($pathName)) {
throw ValidationException::withMessages([
'background_image_file' => $this->backgroundUploadErrorMessage($file),
]);
}
if (! function_exists('imagecreatefromstring') || ! function_exists('imagewebp')) {
throw ValidationException::withMessages([
'background_image_file' => 'The server is missing WebP image support. Enable the GD WebP extension to upload announcement backgrounds.',
]);
}
$binary = @file_get_contents($pathName);
if ($binary === false) {
throw ValidationException::withMessages([
'background_image_file' => 'The uploaded background image could not be opened for conversion. Please choose the file again and retry.',
]);
}
$image = @imagecreatefromstring($binary);
if (! $image instanceof \GdImage) {
throw ValidationException::withMessages([
'background_image_file' => 'The uploaded background image format could not be converted. Please use JPG, PNG, or WEBP.',
]);
}
try {
if (! imageistruecolor($image)) {
imagepalettetotruecolor($image);
}
imagealphablending($image, true);
imagesavealpha($image, true);
ob_start();
$converted = imagewebp($image, null, self::BACKGROUND_WEBP_QUALITY);
$webpBinary = ob_get_clean();
if (! $converted || ! is_string($webpBinary) || $webpBinary === '') {
throw ValidationException::withMessages([
'background_image_file' => 'The uploaded background image could not be converted to WebP. Please try a different image.',
]);
}
$storedPath = $this->announcements->backgroundImagePrefix() . '/' . pathinfo(Str::replace('\\', '/', $file->hashName()), PATHINFO_FILENAME) . '.webp';
Storage::disk($this->announcements->backgroundImageDisk())->put($storedPath, $webpBinary, ['visibility' => 'public']);
} finally {
imagedestroy($image);
}
return $storedPath;
}
private function backgroundUploadErrorMessage(UploadedFile $file): string
{
return match ($file->getError()) {
UPLOAD_ERR_INI_SIZE, UPLOAD_ERR_FORM_SIZE => 'The uploaded background image exceeds the server upload limit.',
UPLOAD_ERR_PARTIAL => 'The uploaded background image was only partially received. Please retry the upload.',
UPLOAD_ERR_NO_TMP_DIR => 'The server upload temp directory is unavailable. Check PHP upload temp configuration.',
UPLOAD_ERR_CANT_WRITE => 'The server could not write the uploaded background image to temporary storage.',
UPLOAD_ERR_EXTENSION => 'A PHP extension blocked the background image upload.',
default => 'The uploaded background image could not be read. Please choose the file again and retry.',
};
}
/**
* @return array<string, mixed>
*/
private function blankForm(): array
{
return [
'id' => null,
'title' => '',
'subtitle' => '',
'badge_text' => '',
'content_html' => '',
'type' => HomepageAnnouncement::TYPE_ANNOUNCEMENT,
'status' => HomepageAnnouncement::STATUS_DRAFT,
'is_active' => true,
'starts_at' => '',
'ends_at' => '',
'priority' => 0,
'is_dismissible' => true,
'dismiss_version' => 1,
'gradient_preset' => HomepageAnnouncement::GRADIENT_NOVA_AURORA,
'theme_preset' => 'launch',
'placement' => HomepageAnnouncement::PLACEMENT_HOMEPAGE_AFTER_FEATURED,
'text_color' => '',
'overlay_opacity' => 55,
'background_image' => '',
'background_image_url' => null,
'remove_background_image' => false,
'background_image_file' => null,
'primary_link_label' => '',
'primary_link_type' => HomepageAnnouncement::LINK_TYPE_NONE,
'primary_link_url' => '',
'primary_link_target_id' => '',
'secondary_link_label' => '',
'secondary_link_type' => HomepageAnnouncement::LINK_TYPE_NONE,
'secondary_link_url' => '',
'secondary_link_target_id' => '',
];
}
/**
* @return array<string, mixed>
*/
private function serializeForForm(HomepageAnnouncement $announcement): array
{
return [
'id' => (int) $announcement->id,
'title' => (string) $announcement->title,
'subtitle' => (string) ($announcement->subtitle ?? ''),
'badge_text' => (string) ($announcement->badge_text ?? ''),
'content_html' => (string) ($announcement->content_html ?? ''),
'type' => (string) $announcement->type,
'status' => (string) $announcement->status,
'is_active' => (bool) $announcement->is_active,
'starts_at' => optional($announcement->starts_at)?->format('Y-m-d\TH:i') ?? '',
'ends_at' => optional($announcement->ends_at)?->format('Y-m-d\TH:i') ?? '',
'priority' => (int) $announcement->priority,
'is_dismissible' => (bool) $announcement->is_dismissible,
'dismiss_version' => (int) $announcement->dismiss_version,
'gradient_preset' => (string) ($announcement->gradient_preset ?? ''),
'theme_preset' => (string) ($announcement->theme_preset ?? ''),
'placement' => (string) $announcement->placement,
'text_color' => (string) ($announcement->text_color ?? ''),
'overlay_opacity' => (int) ($announcement->overlay_opacity ?? 55),
'background_image' => (string) ($announcement->background_image ?? ''),
'background_image_url' => $this->announcements->toHomepagePayload($announcement)['background_image_url'] ?? null,
'remove_background_image' => false,
'background_image_file' => null,
'primary_link_label' => (string) ($announcement->primary_link_label ?? ''),
'primary_link_type' => (string) ($announcement->primary_link_type ?? HomepageAnnouncement::LINK_TYPE_NONE),
'primary_link_url' => (string) ($announcement->primary_link_url ?? ''),
'primary_link_target_id' => $announcement->primary_link_target_id ? (string) $announcement->primary_link_target_id : '',
'secondary_link_label' => (string) ($announcement->secondary_link_label ?? ''),
'secondary_link_type' => (string) ($announcement->secondary_link_type ?? HomepageAnnouncement::LINK_TYPE_NONE),
'secondary_link_url' => (string) ($announcement->secondary_link_url ?? ''),
'secondary_link_target_id' => $announcement->secondary_link_target_id ? (string) $announcement->secondary_link_target_id : '',
];
}
/**
* @return array<string, mixed>
*/
private function serializeForIndex(HomepageAnnouncement $announcement): array
{
return [
'id' => (int) $announcement->id,
'title' => (string) $announcement->title,
'type' => (string) $announcement->type,
'status' => (string) $announcement->status,
'is_active' => (bool) $announcement->is_active,
'priority' => (int) $announcement->priority,
'dismiss_version' => (int) $announcement->dismiss_version,
'placement' => (string) $announcement->placement,
'starts_at' => optional($announcement->starts_at)?->toIso8601String(),
'ends_at' => optional($announcement->ends_at)?->toIso8601String(),
'updated_at' => optional($announcement->updated_at)?->toIso8601String(),
'edit_url' => route('admin.homepage-announcements.edit', ['homepageAnnouncement' => $announcement]),
'destroy_url' => route('admin.homepage-announcements.destroy', ['homepageAnnouncement' => $announcement]),
'preview' => $this->announcements->toHomepagePayload($announcement),
];
}
/**
* @return array<string, array<int, array<string, string>>>
*/
private function options(): array
{
return [
'types' => [
['value' => HomepageAnnouncement::TYPE_ANNOUNCEMENT, 'label' => 'Announcement'],
['value' => HomepageAnnouncement::TYPE_LAUNCH, 'label' => 'Launch'],
['value' => HomepageAnnouncement::TYPE_NEWS, 'label' => 'News'],
['value' => HomepageAnnouncement::TYPE_WORLD, 'label' => 'World'],
['value' => HomepageAnnouncement::TYPE_EVENT, 'label' => 'Event'],
['value' => HomepageAnnouncement::TYPE_NOTICE, 'label' => 'Notice'],
['value' => HomepageAnnouncement::TYPE_MAINTENANCE, 'label' => 'Maintenance'],
],
'statuses' => [
['value' => HomepageAnnouncement::STATUS_DRAFT, 'label' => 'Draft'],
['value' => HomepageAnnouncement::STATUS_PUBLISHED, 'label' => 'Published'],
['value' => HomepageAnnouncement::STATUS_ARCHIVED, 'label' => 'Archived'],
],
'placements' => [
['value' => HomepageAnnouncement::PLACEMENT_HOMEPAGE_AFTER_FEATURED, 'label' => 'Homepage after Featured Artwork'],
],
'linkTypes' => [
['value' => HomepageAnnouncement::LINK_TYPE_NONE, 'label' => 'None'],
['value' => HomepageAnnouncement::LINK_TYPE_CUSTOM_URL, 'label' => 'Custom URL'],
['value' => HomepageAnnouncement::LINK_TYPE_NEWS, 'label' => 'News article'],
['value' => HomepageAnnouncement::LINK_TYPE_WORLD, 'label' => 'World'],
['value' => HomepageAnnouncement::LINK_TYPE_ARTWORK, 'label' => 'Artwork'],
['value' => HomepageAnnouncement::LINK_TYPE_COLLECTION, 'label' => 'Collection'],
['value' => HomepageAnnouncement::LINK_TYPE_GROUP, 'label' => 'Group'],
['value' => HomepageAnnouncement::LINK_TYPE_PROFILE, 'label' => 'Profile'],
['value' => HomepageAnnouncement::LINK_TYPE_EXPLORE, 'label' => 'Explore'],
['value' => HomepageAnnouncement::LINK_TYPE_UPLOAD, 'label' => 'Upload'],
],
'gradients' => [
['value' => HomepageAnnouncement::GRADIENT_NOVA_AURORA, 'label' => 'Nova Aurora'],
['value' => HomepageAnnouncement::GRADIENT_DEEP_SPACE, 'label' => 'Deep Space'],
['value' => HomepageAnnouncement::GRADIENT_SUNRISE, 'label' => 'Sunrise'],
['value' => HomepageAnnouncement::GRADIENT_OCEAN_GLOW, 'label' => 'Ocean Glow'],
['value' => HomepageAnnouncement::GRADIENT_SPRING_VIBES, 'label' => 'Spring Vibes'],
['value' => HomepageAnnouncement::GRADIENT_FANTASY_REALMS, 'label' => 'Fantasy Realms'],
['value' => HomepageAnnouncement::GRADIENT_MINIMAL_LIGHT, 'label' => 'Minimal Light'],
['value' => HomepageAnnouncement::GRADIENT_DARK_GLASS, 'label' => 'Dark Glass'],
],
'themes' => [
['value' => 'launch', 'label' => 'Launch'],
['value' => 'announcement', 'label' => 'Announcement'],
['value' => 'notice', 'label' => 'Notice'],
['value' => 'maintenance', 'label' => 'Maintenance'],
],
];
}
private function currentActor(Request $request): object
{
return $request->user('controlpanel') ?? $request->user() ?? abort(403, 'Admin access required.');
}
}