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 */ 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 $attributes * @return array */ 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 */ 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 */ 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 */ 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>> */ 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.'); } }