Add homepage announcement module
This commit is contained in:
451
app/Http/Controllers/Settings/HomepageAnnouncementController.php
Normal file
451
app/Http/Controllers/Settings/HomepageAnnouncementController.php
Normal file
@@ -0,0 +1,451 @@
|
||||
<?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;
|
||||
}
|
||||
|
||||
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 = 'homepage-announcements/' . pathinfo(Str::replace('\\', '/', $file->hashName()), PATHINFO_FILENAME) . '.webp';
|
||||
Storage::disk('public')->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.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\Settings;
|
||||
|
||||
use App\Models\HomepageAnnouncement;
|
||||
use App\Services\HomepageAnnouncementSanitizer;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class UpsertHomepageAnnouncementRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return (bool) ($this->user('controlpanel') ?? $this->user());
|
||||
}
|
||||
|
||||
protected function prepareForValidation(): void
|
||||
{
|
||||
$this->merge([
|
||||
'is_active' => $this->boolean('is_active'),
|
||||
'is_dismissible' => $this->boolean('is_dismissible', true),
|
||||
'remove_background_image' => $this->boolean('remove_background_image'),
|
||||
'priority' => $this->filled('priority') ? (int) $this->input('priority') : 0,
|
||||
'dismiss_version' => $this->filled('dismiss_version') ? (int) $this->input('dismiss_version') : 1,
|
||||
'overlay_opacity' => $this->filled('overlay_opacity') ? (int) $this->input('overlay_opacity') : 55,
|
||||
]);
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'title' => ['required', 'string', 'max:180'],
|
||||
'subtitle' => ['nullable', 'string', 'max:255'],
|
||||
'badge_text' => ['nullable', 'string', 'max:100'],
|
||||
'content_html' => ['nullable', 'string'],
|
||||
'type' => ['required', 'string', Rule::in(HomepageAnnouncement::types())],
|
||||
'status' => ['required', 'string', Rule::in(HomepageAnnouncement::statuses())],
|
||||
'is_active' => ['required', 'boolean'],
|
||||
'starts_at' => ['nullable', 'date'],
|
||||
'ends_at' => ['nullable', 'date', 'after_or_equal:starts_at'],
|
||||
'priority' => ['required', 'integer', 'min:-9999', 'max:9999'],
|
||||
'is_dismissible' => ['required', 'boolean'],
|
||||
'dismiss_version' => ['required', 'integer', 'min:1', 'max:999999'],
|
||||
'gradient_preset' => ['nullable', 'string', Rule::in(HomepageAnnouncement::gradientPresets())],
|
||||
'theme_preset' => ['nullable', 'string', 'max:80'],
|
||||
'placement' => ['required', 'string', Rule::in(HomepageAnnouncement::placements())],
|
||||
'text_color' => ['nullable', 'string', 'max:32'],
|
||||
'overlay_opacity' => ['nullable', 'integer', 'min:0', 'max:100'],
|
||||
'background_image' => ['nullable', 'string', 'max:2048'],
|
||||
'background_image_file' => ['nullable', 'file', 'image', 'mimes:jpeg,jpg,png,webp', 'max:5120'],
|
||||
'remove_background_image' => ['nullable', 'boolean'],
|
||||
|
||||
'primary_link_label' => ['nullable', 'string', 'max:80'],
|
||||
'primary_link_type' => ['nullable', 'string', Rule::in(HomepageAnnouncement::linkTypes())],
|
||||
'primary_link_url' => ['nullable', 'string', 'max:2048'],
|
||||
'primary_link_target_id' => ['nullable', 'integer', 'min:1'],
|
||||
|
||||
'secondary_link_label' => ['nullable', 'string', 'max:80'],
|
||||
'secondary_link_type' => ['nullable', 'string', Rule::in(HomepageAnnouncement::linkTypes())],
|
||||
'secondary_link_url' => ['nullable', 'string', 'max:2048'],
|
||||
'secondary_link_target_id' => ['nullable', 'integer', 'min:1'],
|
||||
];
|
||||
}
|
||||
|
||||
public function withValidator($validator): void
|
||||
{
|
||||
$validator->after(function ($validator): void {
|
||||
$sanitizer = app(HomepageAnnouncementSanitizer::class);
|
||||
|
||||
foreach (['primary', 'secondary'] as $prefix) {
|
||||
$type = (string) ($this->input($prefix . '_link_type') ?: HomepageAnnouncement::LINK_TYPE_NONE);
|
||||
$label = trim((string) $this->input($prefix . '_link_label', ''));
|
||||
$url = trim((string) $this->input($prefix . '_link_url', ''));
|
||||
$targetId = (int) $this->input($prefix . '_link_target_id', 0);
|
||||
|
||||
if ($url !== '' && ! $sanitizer->isSafeCustomUrl($url)) {
|
||||
$validator->errors()->add($prefix . '_link_url', 'Use a relative path starting with / or an https:// URL. Unsafe protocols are not allowed.');
|
||||
}
|
||||
|
||||
if ($type !== HomepageAnnouncement::LINK_TYPE_NONE && $label === '') {
|
||||
$validator->errors()->add($prefix . '_link_label', 'Provide a CTA label when this link is enabled.');
|
||||
}
|
||||
|
||||
if ($type === HomepageAnnouncement::LINK_TYPE_CUSTOM_URL && $url === '') {
|
||||
$validator->errors()->add($prefix . '_link_url', 'Provide a URL for custom links.');
|
||||
}
|
||||
|
||||
if (! in_array($type, [HomepageAnnouncement::LINK_TYPE_NONE, HomepageAnnouncement::LINK_TYPE_CUSTOM_URL, HomepageAnnouncement::LINK_TYPE_EXPLORE, HomepageAnnouncement::LINK_TYPE_UPLOAD], true)
|
||||
&& $url === ''
|
||||
&& $targetId < 1) {
|
||||
$validator->errors()->add($prefix . '_link_target_id', 'Provide a target id or a fallback URL for this CTA type.');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
197
app/Models/HomepageAnnouncement.php
Normal file
197
app/Models/HomepageAnnouncement.php
Normal file
@@ -0,0 +1,197 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
class HomepageAnnouncement extends Model
|
||||
{
|
||||
use SoftDeletes;
|
||||
|
||||
public const STATUS_DRAFT = 'draft';
|
||||
public const STATUS_PUBLISHED = 'published';
|
||||
public const STATUS_ARCHIVED = 'archived';
|
||||
|
||||
public const TYPE_ANNOUNCEMENT = 'announcement';
|
||||
public const TYPE_LAUNCH = 'launch';
|
||||
public const TYPE_NEWS = 'news';
|
||||
public const TYPE_WORLD = 'world';
|
||||
public const TYPE_EVENT = 'event';
|
||||
public const TYPE_NOTICE = 'notice';
|
||||
public const TYPE_MAINTENANCE = 'maintenance';
|
||||
|
||||
public const PLACEMENT_HOMEPAGE_AFTER_FEATURED = 'homepage_after_featured';
|
||||
|
||||
public const LINK_TYPE_NONE = 'none';
|
||||
public const LINK_TYPE_CUSTOM_URL = 'custom_url';
|
||||
public const LINK_TYPE_NEWS = 'news';
|
||||
public const LINK_TYPE_WORLD = 'world';
|
||||
public const LINK_TYPE_ARTWORK = 'artwork';
|
||||
public const LINK_TYPE_COLLECTION = 'collection';
|
||||
public const LINK_TYPE_GROUP = 'group';
|
||||
public const LINK_TYPE_PROFILE = 'profile';
|
||||
public const LINK_TYPE_EXPLORE = 'explore';
|
||||
public const LINK_TYPE_UPLOAD = 'upload';
|
||||
|
||||
public const GRADIENT_NOVA_AURORA = 'nova_aurora';
|
||||
public const GRADIENT_DEEP_SPACE = 'deep_space';
|
||||
public const GRADIENT_SUNRISE = 'sunrise';
|
||||
public const GRADIENT_OCEAN_GLOW = 'ocean_glow';
|
||||
public const GRADIENT_SPRING_VIBES = 'spring_vibes';
|
||||
public const GRADIENT_FANTASY_REALMS = 'fantasy_realms';
|
||||
public const GRADIENT_MINIMAL_LIGHT = 'minimal_light';
|
||||
public const GRADIENT_DARK_GLASS = 'dark_glass';
|
||||
|
||||
protected $table = 'homepage_announcements';
|
||||
|
||||
protected $fillable = [
|
||||
'title',
|
||||
'subtitle',
|
||||
'badge_text',
|
||||
'content_html',
|
||||
'type',
|
||||
'status',
|
||||
'is_active',
|
||||
'starts_at',
|
||||
'ends_at',
|
||||
'primary_link_label',
|
||||
'primary_link_type',
|
||||
'primary_link_url',
|
||||
'primary_link_target_type',
|
||||
'primary_link_target_id',
|
||||
'secondary_link_label',
|
||||
'secondary_link_type',
|
||||
'secondary_link_url',
|
||||
'secondary_link_target_type',
|
||||
'secondary_link_target_id',
|
||||
'background_type',
|
||||
'background_image',
|
||||
'gradient_preset',
|
||||
'theme_preset',
|
||||
'text_color',
|
||||
'overlay_opacity',
|
||||
'placement',
|
||||
'priority',
|
||||
'is_dismissible',
|
||||
'dismiss_version',
|
||||
'created_by',
|
||||
'updated_by',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_active' => 'boolean',
|
||||
'starts_at' => 'datetime',
|
||||
'ends_at' => 'datetime',
|
||||
'primary_link_target_id' => 'integer',
|
||||
'secondary_link_target_id' => 'integer',
|
||||
'overlay_opacity' => 'integer',
|
||||
'priority' => 'integer',
|
||||
'is_dismissible' => 'boolean',
|
||||
'dismiss_version' => 'integer',
|
||||
];
|
||||
|
||||
public static function statuses(): array
|
||||
{
|
||||
return [
|
||||
self::STATUS_DRAFT,
|
||||
self::STATUS_PUBLISHED,
|
||||
self::STATUS_ARCHIVED,
|
||||
];
|
||||
}
|
||||
|
||||
public static function types(): array
|
||||
{
|
||||
return [
|
||||
self::TYPE_ANNOUNCEMENT,
|
||||
self::TYPE_LAUNCH,
|
||||
self::TYPE_NEWS,
|
||||
self::TYPE_WORLD,
|
||||
self::TYPE_EVENT,
|
||||
self::TYPE_NOTICE,
|
||||
self::TYPE_MAINTENANCE,
|
||||
];
|
||||
}
|
||||
|
||||
public static function placements(): array
|
||||
{
|
||||
return [
|
||||
self::PLACEMENT_HOMEPAGE_AFTER_FEATURED,
|
||||
];
|
||||
}
|
||||
|
||||
public static function linkTypes(): array
|
||||
{
|
||||
return [
|
||||
self::LINK_TYPE_NONE,
|
||||
self::LINK_TYPE_CUSTOM_URL,
|
||||
self::LINK_TYPE_NEWS,
|
||||
self::LINK_TYPE_WORLD,
|
||||
self::LINK_TYPE_ARTWORK,
|
||||
self::LINK_TYPE_COLLECTION,
|
||||
self::LINK_TYPE_GROUP,
|
||||
self::LINK_TYPE_PROFILE,
|
||||
self::LINK_TYPE_EXPLORE,
|
||||
self::LINK_TYPE_UPLOAD,
|
||||
];
|
||||
}
|
||||
|
||||
public static function gradientPresets(): array
|
||||
{
|
||||
return [
|
||||
self::GRADIENT_NOVA_AURORA,
|
||||
self::GRADIENT_DEEP_SPACE,
|
||||
self::GRADIENT_SUNRISE,
|
||||
self::GRADIENT_OCEAN_GLOW,
|
||||
self::GRADIENT_SPRING_VIBES,
|
||||
self::GRADIENT_FANTASY_REALMS,
|
||||
self::GRADIENT_MINIMAL_LIGHT,
|
||||
self::GRADIENT_DARK_GLASS,
|
||||
];
|
||||
}
|
||||
|
||||
public function scopePublished(Builder $query): Builder
|
||||
{
|
||||
return $query->where('status', self::STATUS_PUBLISHED);
|
||||
}
|
||||
|
||||
public function scopeActive(Builder $query): Builder
|
||||
{
|
||||
return $query->where('is_active', true);
|
||||
}
|
||||
|
||||
public function scopeVisibleNow(Builder $query, ?Carbon $now = null): Builder
|
||||
{
|
||||
$now ??= now();
|
||||
|
||||
return $query
|
||||
->where(function (Builder $builder) use ($now): void {
|
||||
$builder->whereNull('starts_at')
|
||||
->orWhere('starts_at', '<=', $now);
|
||||
})
|
||||
->where(function (Builder $builder) use ($now): void {
|
||||
$builder->whereNull('ends_at')
|
||||
->orWhere('ends_at', '>=', $now);
|
||||
});
|
||||
}
|
||||
|
||||
public function scopeForPlacement(Builder $query, string $placement): Builder
|
||||
{
|
||||
return $query->where('placement', $placement);
|
||||
}
|
||||
|
||||
public function createdBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by');
|
||||
}
|
||||
|
||||
public function updatedBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'updated_by');
|
||||
}
|
||||
}
|
||||
31
app/Observers/HomepageAnnouncementObserver.php
Normal file
31
app/Observers/HomepageAnnouncementObserver.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Observers;
|
||||
|
||||
use App\Models\HomepageAnnouncement;
|
||||
use App\Services\HomepageAnnouncementService;
|
||||
|
||||
class HomepageAnnouncementObserver
|
||||
{
|
||||
public function saved(HomepageAnnouncement $announcement): void
|
||||
{
|
||||
app(HomepageAnnouncementService::class)->clearActiveCache();
|
||||
}
|
||||
|
||||
public function deleted(HomepageAnnouncement $announcement): void
|
||||
{
|
||||
app(HomepageAnnouncementService::class)->clearActiveCache();
|
||||
}
|
||||
|
||||
public function restored(HomepageAnnouncement $announcement): void
|
||||
{
|
||||
app(HomepageAnnouncementService::class)->clearActiveCache();
|
||||
}
|
||||
|
||||
public function forceDeleted(HomepageAnnouncement $announcement): void
|
||||
{
|
||||
app(HomepageAnnouncementService::class)->clearActiveCache();
|
||||
}
|
||||
}
|
||||
175
app/Services/HomepageAnnouncementSanitizer.php
Normal file
175
app/Services/HomepageAnnouncementSanitizer.php
Normal file
@@ -0,0 +1,175 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use DOMDocument;
|
||||
use DOMElement;
|
||||
use DOMNode;
|
||||
|
||||
class HomepageAnnouncementSanitizer
|
||||
{
|
||||
private const ALLOWED_TAGS = [
|
||||
'p', 'br', 'strong', 'b', 'em', 'i', 'a', 'ul', 'ol', 'li', 'h2', 'h3', 'blockquote',
|
||||
];
|
||||
|
||||
private const ALLOWED_ATTRS = [
|
||||
'a' => ['href', 'title', 'target', 'rel'],
|
||||
];
|
||||
|
||||
public function sanitizeHtml(?string $html): string
|
||||
{
|
||||
if ($html === null || trim($html) === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
$encodedHtml = mb_encode_numericentity(
|
||||
$html,
|
||||
[0x80, 0x10FFFF, 0, 0xFFFFFF],
|
||||
'UTF-8'
|
||||
);
|
||||
|
||||
$document = new DOMDocument('1.0', 'UTF-8');
|
||||
|
||||
libxml_use_internal_errors(true);
|
||||
$document->loadHTML(
|
||||
'<?xml encoding="UTF-8"><html><body>' . $encodedHtml . '</body></html>',
|
||||
LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD
|
||||
);
|
||||
libxml_clear_errors();
|
||||
|
||||
$body = $document->getElementsByTagName('body')->item(0);
|
||||
if (! $body instanceof DOMNode) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$this->cleanNode($body);
|
||||
|
||||
$innerHtml = '';
|
||||
foreach ($body->childNodes as $child) {
|
||||
$innerHtml .= $document->saveHTML($child);
|
||||
}
|
||||
|
||||
return trim(html_entity_decode($innerHtml, ENT_QUOTES | ENT_HTML5, 'UTF-8'));
|
||||
}
|
||||
|
||||
public function sanitizeCustomUrl(?string $url): ?string
|
||||
{
|
||||
$url = trim((string) $url);
|
||||
|
||||
if ($url === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! $this->isSafeCustomUrl($url)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $url;
|
||||
}
|
||||
|
||||
public function isSafeCustomUrl(?string $url): bool
|
||||
{
|
||||
$url = trim((string) $url);
|
||||
if ($url === '') {
|
||||
return true;
|
||||
}
|
||||
|
||||
$lower = strtolower($url);
|
||||
if (str_starts_with($lower, 'javascript:') || str_contains($lower, 'onerror=') || str_contains($lower, 'onclick=')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (str_starts_with($url, '/')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return str_starts_with($lower, 'https://');
|
||||
}
|
||||
|
||||
private function cleanNode(DOMNode $node): void
|
||||
{
|
||||
$toRemove = [];
|
||||
$toUnwrap = [];
|
||||
|
||||
foreach ($node->childNodes as $child) {
|
||||
if ($child->nodeType !== XML_ELEMENT_NODE) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! $child instanceof DOMElement) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$tag = strtolower($child->nodeName);
|
||||
|
||||
if (in_array($tag, ['script', 'style', 'iframe'], true)) {
|
||||
$toRemove[] = $child;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! in_array($tag, self::ALLOWED_TAGS, true)) {
|
||||
$toUnwrap[] = $child;
|
||||
continue;
|
||||
}
|
||||
|
||||
$allowedAttrs = self::ALLOWED_ATTRS[$tag] ?? [];
|
||||
$attrsToRemove = [];
|
||||
foreach ($child->attributes as $attribute) {
|
||||
if (! in_array($attribute->nodeName, $allowedAttrs, true)) {
|
||||
$attrsToRemove[] = $attribute->nodeName;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($attrsToRemove as $attributeName) {
|
||||
$child->removeAttribute($attributeName);
|
||||
}
|
||||
|
||||
if ($tag === 'a') {
|
||||
$href = trim($child->getAttribute('href'));
|
||||
if ($href === '' || ! $this->isSafeAnchorHref($href)) {
|
||||
$toUnwrap[] = $child;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (str_starts_with(strtolower($href), 'https://')) {
|
||||
$child->setAttribute('rel', 'noopener noreferrer');
|
||||
$child->setAttribute('target', '_blank');
|
||||
} else {
|
||||
$child->removeAttribute('target');
|
||||
$child->removeAttribute('rel');
|
||||
}
|
||||
}
|
||||
|
||||
$this->cleanNode($child);
|
||||
}
|
||||
|
||||
foreach ($toRemove as $element) {
|
||||
$node->removeChild($element);
|
||||
}
|
||||
|
||||
foreach ($toUnwrap as $element) {
|
||||
while ($element->firstChild) {
|
||||
$node->insertBefore($element->firstChild, $element);
|
||||
}
|
||||
|
||||
$node->removeChild($element);
|
||||
}
|
||||
}
|
||||
|
||||
private function isSafeAnchorHref(string $href): bool
|
||||
{
|
||||
$lower = strtolower(trim($href));
|
||||
|
||||
if (str_starts_with($lower, 'javascript:') || str_starts_with($lower, 'data:')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (str_starts_with($href, '/') || str_starts_with($href, '#')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return str_starts_with($lower, 'https://');
|
||||
}
|
||||
}
|
||||
238
app/Services/HomepageAnnouncementService.php
Normal file
238
app/Services/HomepageAnnouncementService.php
Normal file
@@ -0,0 +1,238 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\Collection;
|
||||
use App\Models\Group;
|
||||
use App\Models\HomepageAnnouncement;
|
||||
use App\Models\User;
|
||||
use App\Models\World;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use cPad\Plugins\News\Models\NewsArticle;
|
||||
|
||||
class HomepageAnnouncementService
|
||||
{
|
||||
public const ACTIVE_CACHE_KEY = 'skinbase:homepage:announcement:active';
|
||||
|
||||
public function __construct(private readonly HomepageAnnouncementSanitizer $sanitizer)
|
||||
{
|
||||
}
|
||||
|
||||
public function getActiveForHomepage(): ?HomepageAnnouncement
|
||||
{
|
||||
return Cache::remember(self::ACTIVE_CACHE_KEY, now()->addMinutes(5), function (): ?HomepageAnnouncement {
|
||||
return HomepageAnnouncement::query()
|
||||
->published()
|
||||
->active()
|
||||
->visibleNow()
|
||||
->forPlacement(HomepageAnnouncement::PLACEMENT_HOMEPAGE_AFTER_FEATURED)
|
||||
->orderByDesc('priority')
|
||||
->orderByDesc('starts_at')
|
||||
->first();
|
||||
});
|
||||
}
|
||||
|
||||
public function clearActiveCache(): void
|
||||
{
|
||||
Cache::forget(self::ACTIVE_CACHE_KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $attributes
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function sanitizeAttributes(array $attributes): array
|
||||
{
|
||||
foreach (['primary_link_url', 'secondary_link_url'] as $key) {
|
||||
$attributes[$key] = $this->sanitizer->sanitizeCustomUrl($attributes[$key] ?? null);
|
||||
}
|
||||
|
||||
$attributes['content_html'] = $this->sanitizer->sanitizeHtml($attributes['content_html'] ?? null);
|
||||
|
||||
return $attributes;
|
||||
}
|
||||
|
||||
public function toHomepagePayload(?HomepageAnnouncement $announcement): ?array
|
||||
{
|
||||
if (! $announcement) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => (int) $announcement->id,
|
||||
'dismiss_version' => max(1, (int) $announcement->dismiss_version),
|
||||
'title' => (string) $announcement->title,
|
||||
'subtitle' => $announcement->subtitle,
|
||||
'badge_text' => $announcement->badge_text,
|
||||
'content_html' => $announcement->content_html,
|
||||
'gradient_preset' => $announcement->gradient_preset,
|
||||
'theme_preset' => $announcement->theme_preset,
|
||||
'background_image_url' => $this->resolveBackgroundImageUrl($announcement->background_image),
|
||||
'is_dismissible' => (bool) $announcement->is_dismissible,
|
||||
'overlay_opacity' => (int) ($announcement->overlay_opacity ?? 55),
|
||||
'primary_link' => $this->buildLinkPayload($announcement, 'primary'),
|
||||
'secondary_link' => $this->buildLinkPayload($announcement, 'secondary'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $attributes
|
||||
*/
|
||||
public function previewPayload(array $attributes): ?array
|
||||
{
|
||||
$announcement = new HomepageAnnouncement();
|
||||
$announcement->forceFill($this->sanitizeAttributes($attributes));
|
||||
$announcement->id = 0;
|
||||
|
||||
return $this->toHomepagePayload($announcement);
|
||||
}
|
||||
|
||||
private function buildLinkPayload(HomepageAnnouncement $announcement, string $prefix): ?array
|
||||
{
|
||||
$label = trim((string) $announcement->getAttribute($prefix . '_link_label'));
|
||||
if ($label === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$url = $this->resolveLinkUrl($announcement, $prefix);
|
||||
if ($url === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'label' => $label,
|
||||
'url' => $url,
|
||||
];
|
||||
}
|
||||
|
||||
private function resolveLinkUrl(HomepageAnnouncement $announcement, string $prefix): ?string
|
||||
{
|
||||
$type = (string) ($announcement->getAttribute($prefix . '_link_type') ?? HomepageAnnouncement::LINK_TYPE_NONE);
|
||||
$url = $this->sanitizer->sanitizeCustomUrl($announcement->getAttribute($prefix . '_link_url'));
|
||||
$targetId = (int) ($announcement->getAttribute($prefix . '_link_target_id') ?? 0);
|
||||
|
||||
return match ($type) {
|
||||
HomepageAnnouncement::LINK_TYPE_NONE => null,
|
||||
HomepageAnnouncement::LINK_TYPE_CUSTOM_URL => $url,
|
||||
HomepageAnnouncement::LINK_TYPE_EXPLORE => Route::has('explore.index') ? route('explore.index') : ($url ?? '/explore'),
|
||||
HomepageAnnouncement::LINK_TYPE_UPLOAD => Route::has('upload') ? route('upload') : ($url ?? '/upload'),
|
||||
HomepageAnnouncement::LINK_TYPE_NEWS => $this->newsUrl($targetId) ?? $url,
|
||||
HomepageAnnouncement::LINK_TYPE_WORLD => $this->worldUrl($targetId) ?? $url,
|
||||
HomepageAnnouncement::LINK_TYPE_COLLECTION => $this->collectionUrl($targetId) ?? $url,
|
||||
HomepageAnnouncement::LINK_TYPE_GROUP => $this->groupUrl($targetId) ?? $url,
|
||||
HomepageAnnouncement::LINK_TYPE_PROFILE => $this->profileUrl($targetId) ?? $url,
|
||||
HomepageAnnouncement::LINK_TYPE_ARTWORK => $this->artworkUrl($targetId) ?? $url,
|
||||
default => $url,
|
||||
};
|
||||
}
|
||||
|
||||
private function resolveBackgroundImageUrl(?string $backgroundImage): ?string
|
||||
{
|
||||
$backgroundImage = trim((string) $backgroundImage);
|
||||
if ($backgroundImage === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (str_starts_with($backgroundImage, 'http://') || str_starts_with($backgroundImage, 'https://') || str_starts_with($backgroundImage, '/')) {
|
||||
return $backgroundImage;
|
||||
}
|
||||
|
||||
return Storage::disk('public')->url($backgroundImage);
|
||||
}
|
||||
|
||||
private function artworkUrl(int $artworkId): ?string
|
||||
{
|
||||
if ($artworkId < 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$artwork = Artwork::query()->find($artworkId);
|
||||
if (! $artwork) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function newsUrl(int $articleId): ?string
|
||||
{
|
||||
if ($articleId < 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$article = NewsArticle::query()->find($articleId);
|
||||
if (! $article) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return route('news.show', ['slug' => $article->slug]);
|
||||
}
|
||||
|
||||
private function worldUrl(int $worldId): ?string
|
||||
{
|
||||
if ($worldId < 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$world = World::query()->find($worldId);
|
||||
if (! $world) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (method_exists($world, 'publicUrl')) {
|
||||
return $world->publicUrl();
|
||||
}
|
||||
|
||||
return route('worlds.show', ['world' => $world->slug]);
|
||||
}
|
||||
|
||||
private function collectionUrl(int $collectionId): ?string
|
||||
{
|
||||
if ($collectionId < 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$collection = Collection::query()->with('user')->find($collectionId);
|
||||
if (! $collection || ! $collection->user?->username) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return route('profile.collections.show', [
|
||||
'username' => strtolower((string) $collection->user->username),
|
||||
'slug' => $collection->slug,
|
||||
]);
|
||||
}
|
||||
|
||||
private function groupUrl(int $groupId): ?string
|
||||
{
|
||||
if ($groupId < 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$group = Group::query()->find($groupId);
|
||||
if (! $group) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return route('groups.show', ['group' => $group]);
|
||||
}
|
||||
|
||||
private function profileUrl(int $userId): ?string
|
||||
{
|
||||
if ($userId < 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$user = User::query()->find($userId);
|
||||
if (! $user?->username) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return route('profile.show', ['username' => strtolower((string) $user->username)]);
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ namespace App\Services;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\Leaderboard;
|
||||
use App\Models\Tag;
|
||||
use App\Services\HomepageAnnouncementService;
|
||||
use App\Services\ArtworkSearchService;
|
||||
use App\Services\EarlyGrowth\EarlyGrowth;
|
||||
use App\Services\EarlyGrowth\GridFiller;
|
||||
@@ -57,6 +58,7 @@ final class HomepageService
|
||||
private readonly GroupDiscoveryService $groupDiscovery,
|
||||
private readonly LeaderboardService $leaderboards,
|
||||
private readonly WorldService $worlds,
|
||||
private readonly HomepageAnnouncementService $homepageAnnouncements,
|
||||
) {}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
@@ -68,9 +70,17 @@ final class HomepageService
|
||||
*/
|
||||
public function all(): array
|
||||
{
|
||||
return $this->guestPayloadCache()->remember(
|
||||
// Use a stale-while-revalidate pattern: serve fresh cache for a short
|
||||
// period and allow serving stale data while the cache is recalculated
|
||||
// in the background. This reduces latency for the homepage on cache
|
||||
// miss/expiration and keeps the heavy aggregation from blocking
|
||||
// responses.
|
||||
$ttl = $this->guestPayloadCacheTtl();
|
||||
$freshSeconds = min(30, max(5, (int) config('homepage.fresh_seconds', 30)));
|
||||
|
||||
return $this->guestPayloadCache()->flexible(
|
||||
$this->guestPayloadCacheKey(),
|
||||
$this->guestPayloadCacheTtl(),
|
||||
[$freshSeconds, $ttl],
|
||||
fn (): array => $this->buildGuestPayload(),
|
||||
);
|
||||
}
|
||||
@@ -119,6 +129,7 @@ final class HomepageService
|
||||
{
|
||||
return [
|
||||
'hero' => $this->getHeroArtwork(),
|
||||
'announcement' => $this->homepageAnnouncements->toHomepagePayload($this->homepageAnnouncements->getActiveForHomepage()),
|
||||
'community_favorites' => $this->getCommunityFavorites(),
|
||||
'hall_of_fame' => $this->getHallOfFame(),
|
||||
'rising' => $this->getRising(),
|
||||
@@ -171,6 +182,7 @@ final class HomepageService
|
||||
'is_logged_in' => true,
|
||||
'user_data' => $this->getUserData($user),
|
||||
'hero' => $this->getHeroArtwork(),
|
||||
'announcement' => $this->homepageAnnouncements->toHomepagePayload($this->homepageAnnouncements->getActiveForHomepage()),
|
||||
'community_favorites' => $this->getCommunityFavorites(),
|
||||
'hall_of_fame' => $this->getHallOfFame(),
|
||||
'for_you' => $this->getForYouPreview($user),
|
||||
|
||||
Reference in New Issue
Block a user