From 8df8a4ca3068f697644f00c81de135379a36cf56 Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Fri, 10 Jul 2026 17:41:06 +0100 Subject: [PATCH] feat(blog): custom hero, OG and card images for articles with admin crop tools Articles can now have a hero image uploaded in the admin. From one upload, three derivatives are generated with admin-adjustable crop selections: - OG image (1200x630, replaces TheOG-generated image while present) - blog card preview (1200x450, shown atop /blog cards) - article header (2048x1000, shown atop the article at up to 500px tall) Includes a dependency-free Filament ImageCropper field (Alpine), an ArticleImageService for scaling/cropping via Intervention Image, file lifecycle cleanup on replace/slug change/delete, stacked form labels, and the admin 'Articles' section renamed to 'Blog'. Co-Authored-By: Claude Fable 5 --- .../Forms/Components/ImageCropper.php | 71 +++++ app/Filament/Resources/ArticleResource.php | 61 +++- .../ArticleResource/Pages/CreateArticle.php | 10 +- .../ArticleResource/Pages/EditArticle.php | 25 +- .../ArticleResource/Pages/ListArticles.php | 2 + app/Models/Article.php | 27 ++ app/Services/ArticleImageService.php | 205 +++++++++++++ ...11307_add_hero_image_to_articles_table.php | 31 ++ ...504_add_header_image_to_articles_table.php | 29 ++ resources/views/article.blade.php | 20 ++ resources/views/blog.blade.php | 1 + .../components/blog/article-card.blade.php | 83 ++--- .../forms/components/image-cropper.blade.php | 246 +++++++++++++++ tests/Feature/ArticleImagesTest.php | 286 ++++++++++++++++++ 14 files changed, 1045 insertions(+), 52 deletions(-) create mode 100644 app/Filament/Forms/Components/ImageCropper.php create mode 100644 app/Services/ArticleImageService.php create mode 100644 database/migrations/2026_07_10_111307_add_hero_image_to_articles_table.php create mode 100644 database/migrations/2026_07_10_155504_add_header_image_to_articles_table.php create mode 100644 resources/views/filament/forms/components/image-cropper.blade.php create mode 100644 tests/Feature/ArticleImagesTest.php diff --git a/app/Filament/Forms/Components/ImageCropper.php b/app/Filament/Forms/Components/ImageCropper.php new file mode 100644 index 00000000..3e15af14 --- /dev/null +++ b/app/Filament/Forms/Components/ImageCropper.php @@ -0,0 +1,71 @@ +imageUrl = $url; + + return $this; + } + + /** + * The dimensions of the image that will be generated from the crop selection. + */ + public function targetDimensions(int|Closure $width, int|Closure $height): static + { + $this->targetWidth = $width; + $this->targetHeight = $height; + + return $this; + } + + /** + * Whether the source image has been changed but not saved yet, meaning the + * crop selection can't be made until the record is saved. + */ + public function hasPendingImage(bool|Closure $condition): static + { + $this->hasPendingImage = $condition; + + return $this; + } + + public function getImageUrl(): ?string + { + return $this->evaluate($this->imageUrl); + } + + public function getTargetWidth(): int + { + return $this->evaluate($this->targetWidth); + } + + public function getTargetHeight(): int + { + return $this->evaluate($this->targetHeight); + } + + public function getHasPendingImage(): bool + { + return (bool) $this->evaluate($this->hasPendingImage); + } +} diff --git a/app/Filament/Resources/ArticleResource.php b/app/Filament/Resources/ArticleResource.php index e2c07b99..fea8ddd9 100644 --- a/app/Filament/Resources/ArticleResource.php +++ b/app/Filament/Resources/ArticleResource.php @@ -2,23 +2,30 @@ namespace App\Filament\Resources; +use App\Filament\Forms\Components\ImageCropper; use App\Filament\Resources\ArticleResource\Actions\PreviewAction; use App\Filament\Resources\ArticleResource\Actions\PublishAction; use App\Filament\Resources\ArticleResource\Actions\UnpublishAction; use App\Filament\Resources\ArticleResource\Pages; use App\Models\Article; +use App\Services\ArticleImageService; use Filament\Actions; use Filament\Actions\ActionGroup; +use Filament\Forms\Components\FileUpload; use Filament\Forms\Components\MarkdownEditor; use Filament\Forms\Components\Textarea; use Filament\Forms\Components\TextInput; use Filament\Resources\Resource; +use Filament\Schemas\Components\Section; +use Filament\Schemas\Components\Utilities\Get; use Filament\Schemas\Components\Utilities\Set; use Filament\Schemas\Schema; use Filament\Tables\Columns\TextColumn; use Filament\Tables\Table; use Illuminate\Contracts\Database\Eloquent\Builder; +use Illuminate\Support\Arr; use Illuminate\Support\Str; +use Illuminate\Validation\Rule; class ArticleResource extends Resource { @@ -30,10 +37,13 @@ class ArticleResource extends Resource protected static \BackedEnum|string|null $navigationIcon = 'heroicon-o-newspaper'; + protected static ?string $navigationLabel = 'Blog'; + + protected static ?string $breadcrumb = 'Blog'; + public static function form(Schema $schema): Schema { return $schema - ->inlineLabel() ->columns(1) ->schema([ TextInput::make('title') @@ -67,6 +77,46 @@ public static function form(Schema $schema): Schema ->maxLength(400) ->columnSpanFull(), + Section::make('Hero image') + ->description('Shown at the top of the article and used to create the social sharing (OG) image and the blog card preview. When left empty, an OG image is generated automatically.') + ->columns(1) + ->schema([ + FileUpload::make('hero_image') + ->label('Image') + ->image() + ->disk('public') + ->directory('blog/heroes') + ->maxSize(10240) + ->rule( + Rule::dimensions() + ->minWidth(ArticleImageService::OG_WIDTH) + ->minHeight(ArticleImageService::OG_HEIGHT) + ) + ->live() + ->helperText('At least '.ArticleImageService::OG_WIDTH.'×'.ArticleImageService::OG_HEIGHT.'px — upload the largest version you have; it will be scaled down if needed.'), + + ImageCropper::make('og_image_crop') + ->label('Social (OG) image crop') + ->targetDimensions(ArticleImageService::OG_WIDTH, ArticleImageService::OG_HEIGHT) + ->imageUrl(fn (?Article $article) => $article?->getHeroImageUrl()) + ->hasPendingImage(fn (?Article $article, Get $get): bool => static::heroImageIsPending($article, $get)) + ->visible(fn (?Article $article) => filled($article?->hero_image)), + + ImageCropper::make('card_image_crop') + ->label('Blog card crop') + ->targetDimensions(ArticleImageService::CARD_WIDTH, ArticleImageService::CARD_HEIGHT) + ->imageUrl(fn (?Article $article) => $article?->getHeroImageUrl()) + ->hasPendingImage(fn (?Article $article, Get $get): bool => static::heroImageIsPending($article, $get)) + ->visible(fn (?Article $article) => filled($article?->hero_image)), + + ImageCropper::make('header_image_crop') + ->label('Article header crop') + ->targetDimensions(ArticleImageService::HEADER_WIDTH, ArticleImageService::HEADER_HEIGHT) + ->imageUrl(fn (?Article $article) => $article?->getHeroImageUrl()) + ->hasPendingImage(fn (?Article $article, Get $get): bool => static::heroImageIsPending($article, $get)) + ->visible(fn (?Article $article) => filled($article?->hero_image)), + ]), + MarkdownEditor::make('content') ->required() ->columnSpanFull(), @@ -113,6 +163,15 @@ public static function table(Table $table): Table ->defaultSort('published_at', 'desc'); } + /** + * Whether the hero image in the form state differs from the saved one, + * meaning crops can only be adjusted after saving. + */ + protected static function heroImageIsPending(?Article $article, Get $get): bool + { + return ! in_array($article?->hero_image, Arr::wrap($get('hero_image')), true); + } + public static function getRelations(): array { return [ diff --git a/app/Filament/Resources/ArticleResource/Pages/CreateArticle.php b/app/Filament/Resources/ArticleResource/Pages/CreateArticle.php index c933ecc9..ab640924 100644 --- a/app/Filament/Resources/ArticleResource/Pages/CreateArticle.php +++ b/app/Filament/Resources/ArticleResource/Pages/CreateArticle.php @@ -3,7 +3,7 @@ namespace App\Filament\Resources\ArticleResource\Pages; use App\Filament\Resources\ArticleResource; -use App\Services\OgImageService; +use App\Services\ArticleImageService; use Filament\Resources\Pages\CreateRecord; class CreateArticle extends CreateRecord @@ -12,13 +12,7 @@ class CreateArticle extends CreateRecord protected function afterCreate(): void { - $service = resolve(OgImageService::class); - - $ogImageUrl = $service->generate($this->record); - - $this->record->update([ - 'og_image' => $ogImageUrl, - ]); + resolve(ArticleImageService::class)->refreshImages($this->record); } protected function getRedirectUrl(): string diff --git a/app/Filament/Resources/ArticleResource/Pages/EditArticle.php b/app/Filament/Resources/ArticleResource/Pages/EditArticle.php index 723b8f1e..03ae4047 100644 --- a/app/Filament/Resources/ArticleResource/Pages/EditArticle.php +++ b/app/Filament/Resources/ArticleResource/Pages/EditArticle.php @@ -3,7 +3,7 @@ namespace App\Filament\Resources\ArticleResource\Pages; use App\Filament\Resources\ArticleResource; -use App\Services\OgImageService; +use App\Services\ArticleImageService; use Filament\Actions; use Filament\Forms\Components\DateTimePicker; use Filament\Forms\Components\Radio; @@ -14,16 +14,25 @@ class EditArticle extends EditRecord { protected static string $resource = ArticleResource::class; - protected function afterSave(): void + /** + * When the hero image is replaced or removed, the stored crop selections + * relate to the old image, so reset them to the default. + */ + protected function mutateFormDataBeforeSave(array $data): array { - $service = resolve(OgImageService::class); + if (array_key_exists('hero_image', $data) && $data['hero_image'] !== $this->record->hero_image) { + $data['og_image_crop'] = null; + $data['card_image_crop'] = null; + $data['header_image_crop'] = null; + } - // Always regenerate the OG image on update to ensure it's current - $ogImageUrl = $service->generate($this->record); + return $data; + } - $this->record->update([ - 'og_image' => $ogImageUrl, - ]); + protected function afterSave(): void + { + // Always regenerate the OG and card images on update to ensure they're current + resolve(ArticleImageService::class)->refreshImages($this->record); } protected function getHeaderActions(): array diff --git a/app/Filament/Resources/ArticleResource/Pages/ListArticles.php b/app/Filament/Resources/ArticleResource/Pages/ListArticles.php index 6cfc2e86..9b2619c2 100644 --- a/app/Filament/Resources/ArticleResource/Pages/ListArticles.php +++ b/app/Filament/Resources/ArticleResource/Pages/ListArticles.php @@ -10,6 +10,8 @@ class ListArticles extends ListRecords { protected static string $resource = ArticleResource::class; + protected static ?string $title = 'Blog'; + protected function getHeaderActions(): array { return [ diff --git a/app/Models/Article.php b/app/Models/Article.php index 4e4c44aa..c7311763 100644 --- a/app/Models/Article.php +++ b/app/Models/Article.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Services\ArticleImageService; use DateTime; use Illuminate\Contracts\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Attributes\Scope; @@ -18,6 +19,12 @@ class Article extends Model 'title', 'excerpt', 'og_image', + 'hero_image', + 'card_image', + 'header_image', + 'og_image_crop', + 'card_image_crop', + 'header_image_crop', 'content', 'published_at', ]; @@ -27,6 +34,15 @@ public function getRouteKeyName() return 'slug'; } + public function getHeroImageUrl(): ?string + { + if (! $this->hero_image) { + return null; + } + + return asset('storage/'.$this->hero_image); + } + /* |-------------------------------------------------------------------------- | Scopes @@ -98,12 +114,23 @@ protected static function boot() $article->author_id = auth()->id(); } }); + + static::updated(function (Article $article): void { + resolve(ArticleImageService::class)->pruneStaleImages($article); + }); + + static::deleting(function (Article $article): void { + resolve(ArticleImageService::class)->deleteImages($article); + }); } protected function casts(): array { return [ 'published_at' => 'datetime', + 'og_image_crop' => 'array', + 'card_image_crop' => 'array', + 'header_image_crop' => 'array', ]; } } diff --git a/app/Services/ArticleImageService.php b/app/Services/ArticleImageService.php new file mode 100644 index 00000000..76a5603d --- /dev/null +++ b/app/Services/ArticleImageService.php @@ -0,0 +1,205 @@ +hero_image && Storage::disk('public')->exists($article->hero_image)) { + $this->scaleDownHero($article); + + $article->update([ + 'og_image' => $this->generateCrop( + $article, + $article->og_image_crop, + self::OG_WIDTH, + self::OG_HEIGHT, + 'og-images/'.$article->slug.'.png', + ), + 'card_image' => $this->generateCrop( + $article, + $article->card_image_crop, + self::CARD_WIDTH, + self::CARD_HEIGHT, + 'blog/cards/'.$article->slug.'.jpg', + ), + 'header_image' => $this->generateCrop( + $article, + $article->header_image_crop, + self::HEADER_WIDTH, + self::HEADER_HEIGHT, + 'blog/headers/'.$article->slug.'.jpg', + ), + ]); + + return; + } + + $this->deleteDerivedImages($article); + + $article->update([ + 'og_image' => $this->ogImageService->generate($article), + 'card_image' => null, + 'header_image' => null, + ]); + } + + /** + * Delete image files that are no longer referenced after an update. + */ + public function pruneStaleImages(Article $article): void + { + $disk = Storage::disk('public'); + + if ($article->wasChanged('hero_image') && ($previousHero = $article->getOriginal('hero_image'))) { + $disk->delete($previousHero); + } + + if ($article->wasChanged('slug') && ($previousSlug = $article->getOriginal('slug'))) { + $disk->delete([ + 'og-images/'.$previousSlug.'.png', + 'blog/cards/'.$previousSlug.'.jpg', + 'blog/headers/'.$previousSlug.'.jpg', + ]); + } + } + + /** + * Delete all image files belonging to the article. + */ + public function deleteImages(Article $article): void + { + if ($article->hero_image) { + Storage::disk('public')->delete($article->hero_image); + } + + $this->deleteDerivedImages($article); + + $this->ogImageService->delete($article); + } + + /** + * Scale the hero image down in place if it's larger than we'd ever render it. + */ + protected function scaleDownHero(Article $article): void + { + $hero = ImageManager::gd()->read(Storage::disk('public')->path($article->hero_image)); + + if ($hero->width() > self::HERO_MAX_WIDTH) { + $hero->scaleDown(width: self::HERO_MAX_WIDTH)->save(); + } + } + + /** + * Crop a region out of the hero image and save it at the target size. + * + * @param array{x: int, y: int, width: int, height: int}|null $crop + */ + protected function generateCrop(Article $article, ?array $crop, int $targetWidth, int $targetHeight, string $path): string + { + $disk = Storage::disk('public'); + + $directory = dirname($path); + if (! $disk->exists($directory)) { + $disk->makeDirectory($directory); + } + + $hero = ImageManager::gd()->read($disk->path($article->hero_image)); + + $rect = $this->resolveCropRect($crop, $hero->width(), $hero->height(), $targetWidth / $targetHeight); + + $options = str_ends_with($path, '.png') ? [] : ['quality' => 90]; + + $hero + ->crop($rect['width'], $rect['height'], $rect['x'], $rect['y']) + ->resize($targetWidth, $targetHeight) + ->blendTransparency('ffffff') + ->save($disk->path($path), ...$options); + + return $disk->url($path); + } + + /** + * Clamp a stored crop selection to the image bounds, or fall back to a + * centered crop of the largest possible area at the target aspect ratio. + * + * @return array{x: int, y: int, width: int, height: int} + */ + protected function resolveCropRect(?array $crop, int $imageWidth, int $imageHeight, float $ratio): array + { + if ($this->isUsableCropRect($crop)) { + $width = min(max((int) $crop['width'], 10), $imageWidth); + $height = (int) round($width / $ratio); + + if ($height > $imageHeight) { + $height = $imageHeight; + $width = (int) round($height * $ratio); + } + + return [ + 'x' => min(max((int) $crop['x'], 0), $imageWidth - $width), + 'y' => min(max((int) $crop['y'], 0), $imageHeight - $height), + 'width' => $width, + 'height' => $height, + ]; + } + + $width = min($imageWidth, (int) floor($imageHeight * $ratio)); + $height = (int) floor($width / $ratio); + + return [ + 'x' => (int) floor(($imageWidth - $width) / 2), + 'y' => (int) floor(($imageHeight - $height) / 2), + 'width' => $width, + 'height' => $height, + ]; + } + + protected function isUsableCropRect(?array $crop): bool + { + return is_array($crop) + && isset($crop['x'], $crop['y'], $crop['width'], $crop['height']) + && is_numeric($crop['x']) + && is_numeric($crop['y']) + && is_numeric($crop['width']) + && is_numeric($crop['height']) + && (int) $crop['width'] > 0 + && (int) $crop['height'] > 0; + } + + protected function deleteDerivedImages(Article $article): void + { + Storage::disk('public')->delete([ + 'blog/cards/'.$article->slug.'.jpg', + 'blog/headers/'.$article->slug.'.jpg', + ]); + } +} diff --git a/database/migrations/2026_07_10_111307_add_hero_image_to_articles_table.php b/database/migrations/2026_07_10_111307_add_hero_image_to_articles_table.php new file mode 100644 index 00000000..340adcdf --- /dev/null +++ b/database/migrations/2026_07_10_111307_add_hero_image_to_articles_table.php @@ -0,0 +1,31 @@ +string('hero_image')->nullable()->after('og_image'); + $table->string('card_image')->nullable()->after('hero_image'); + $table->json('og_image_crop')->nullable()->after('card_image'); + $table->json('card_image_crop')->nullable()->after('og_image_crop'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('articles', function (Blueprint $table) { + $table->dropColumn(['hero_image', 'card_image', 'og_image_crop', 'card_image_crop']); + }); + } +}; diff --git a/database/migrations/2026_07_10_155504_add_header_image_to_articles_table.php b/database/migrations/2026_07_10_155504_add_header_image_to_articles_table.php new file mode 100644 index 00000000..cdb644e1 --- /dev/null +++ b/database/migrations/2026_07_10_155504_add_header_image_to_articles_table.php @@ -0,0 +1,29 @@ +string('header_image')->nullable()->after('card_image'); + $table->json('header_image_crop')->nullable()->after('card_image_crop'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('articles', function (Blueprint $table) { + $table->dropColumn(['header_image', 'header_image_crop']); + }); + } +}; diff --git a/resources/views/article.blade.php b/resources/views/article.blade.php index 071f8798..80161086 100644 --- a/resources/views/article.blade.php +++ b/resources/views/article.blade.php @@ -78,6 +78,26 @@ class="text-sm" {{-- Divider --}} + {{-- Header image --}} + @if ($article->header_image) + + @endif +
{{-- Content --}}
{{ $article->excerpt }} diff --git a/resources/views/components/blog/article-card.blade.php b/resources/views/components/blog/article-card.blade.php index 555a2396..33b106dd 100644 --- a/resources/views/components/blog/article-card.blade.php +++ b/resources/views/components/blog/article-card.blade.php @@ -2,6 +2,7 @@ 'title' => '', 'url' => '#', 'date' => null, + 'image' => null, ])
- {{-- Header --}} -
-
- {{-- Title --}} -

- {{ $title }} -

- {{-- Date --}} - @if ($date) - @php - $dateObject = \Carbon\Carbon::parse($date); - $formattedDate = $dateObject->format('F j, Y'); - @endphp + {{-- Preview image --}} + @if ($image) + + @endif -
-
- {{-- Content --}} -

- {{ $slot }} -

+ {{-- Arrow --}} +
+ +
+ {{-- Content --}} +

+ {{ $slot }} +

+
{{-- Blur decoration --}} diff --git a/resources/views/filament/forms/components/image-cropper.blade.php b/resources/views/filament/forms/components/image-cropper.blade.php new file mode 100644 index 00000000..26ebab80 --- /dev/null +++ b/resources/views/filament/forms/components/image-cropper.blade.php @@ -0,0 +1,246 @@ + + @php + $imageUrl = $getImageUrl(); + $targetWidth = $getTargetWidth(); + $targetHeight = $getTargetHeight(); + + $noteStyles = 'border-radius: 0.5rem; background: rgba(127, 127, 127, 0.08); padding: 1.5rem 1rem; text-align: center; font-size: 0.875rem; color: #6b7280;'; + $handleStyles = 'position: absolute; width: 0.875rem; height: 0.875rem; border-radius: 9999px; background: #fff; box-shadow: 0 0 0 1px #9ca3af, 0 1px 2px rgba(0, 0, 0, 0.3); touch-action: none;'; + @endphp + + @if ($getHasPendingImage()) +
+ The hero image has changed. Save the article, then adjust this + crop. +
+ @elseif (! $imageUrl) +
+ Upload a hero image to select a crop. +
+ @else +
+
+ Crop selection source + +
+ + + + +
+
+ +
+ + Drag to reposition, drag a corner to resize. Output: + {{ $targetWidth }}×{{ $targetHeight }}px. + + + +
+
+ @endif +
diff --git a/tests/Feature/ArticleImagesTest.php b/tests/Feature/ArticleImagesTest.php new file mode 100644 index 00000000..ff7c813c --- /dev/null +++ b/tests/Feature/ArticleImagesTest.php @@ -0,0 +1,286 @@ +makeDirectory(dirname($path)); + + ImageManager::gd() + ->create($width, $height) + ->fill('ff0000') + ->drawRectangle((int) ($width / 2), 0, function (RectangleFactory $rectangle) use ($width, $height): void { + $rectangle->size((int) ($width / 2), $height); + $rectangle->background('0000ff'); + }) + ->save(Storage::disk('public')->path($path)); + } + + protected function articleWithHero(array $attributes = []): Article + { + $article = Article::factory()->published()->create([ + 'hero_image' => 'blog/heroes/test-hero.png', + ...$attributes, + ]); + + $this->createHeroImage('blog/heroes/test-hero.png'); + + return $article; + } + + public function test_refresh_images_generates_og_card_and_header_crops_from_the_hero(): void + { + $article = $this->articleWithHero(); + + resolve(ArticleImageService::class)->refreshImages($article); + + $article->refresh(); + + Storage::disk('public')->assertExists("og-images/{$article->slug}.png"); + Storage::disk('public')->assertExists("blog/cards/{$article->slug}.jpg"); + Storage::disk('public')->assertExists("blog/headers/{$article->slug}.jpg"); + + $this->assertStringEndsWith("og-images/{$article->slug}.png", $article->og_image); + $this->assertStringEndsWith("blog/cards/{$article->slug}.jpg", $article->card_image); + $this->assertStringEndsWith("blog/headers/{$article->slug}.jpg", $article->header_image); + + $ogImage = ImageManager::gd()->read(Storage::disk('public')->path("og-images/{$article->slug}.png")); + $this->assertSame(ArticleImageService::OG_WIDTH, $ogImage->width()); + $this->assertSame(ArticleImageService::OG_HEIGHT, $ogImage->height()); + + $cardImage = ImageManager::gd()->read(Storage::disk('public')->path("blog/cards/{$article->slug}.jpg")); + $this->assertSame(ArticleImageService::CARD_WIDTH, $cardImage->width()); + $this->assertSame(ArticleImageService::CARD_HEIGHT, $cardImage->height()); + + $headerImage = ImageManager::gd()->read(Storage::disk('public')->path("blog/headers/{$article->slug}.jpg")); + $this->assertSame(ArticleImageService::HEADER_WIDTH, $headerImage->width()); + $this->assertSame(ArticleImageService::HEADER_HEIGHT, $headerImage->height()); + } + + public function test_refresh_images_honours_the_stored_crop_selections(): void + { + $article = $this->articleWithHero([ + // Entirely within the left (red) half of the 1600x800 hero + 'og_image_crop' => ['x' => 0, 'y' => 0, 'width' => 762, 'height' => 400], + ]); + + resolve(ArticleImageService::class)->refreshImages($article); + + $ogImage = ImageManager::gd()->read(Storage::disk('public')->path("og-images/{$article->slug}.png")); + + $this->assertSame('ff0000', $ogImage->pickColor(600, 315)->toHex()); + $this->assertSame('ff0000', $ogImage->pickColor(1190, 315)->toHex()); + } + + public function test_refresh_images_clamps_out_of_bounds_crop_selections(): void + { + $article = $this->articleWithHero([ + 'og_image_crop' => ['x' => 5000, 'y' => 5000, 'width' => 9000, 'height' => 9000], + ]); + + resolve(ArticleImageService::class)->refreshImages($article); + + $ogImage = ImageManager::gd()->read(Storage::disk('public')->path("og-images/{$article->slug}.png")); + + $this->assertSame(ArticleImageService::OG_WIDTH, $ogImage->width()); + $this->assertSame(ArticleImageService::OG_HEIGHT, $ogImage->height()); + } + + public function test_oversized_hero_images_are_scaled_down(): void + { + $article = Article::factory()->published()->create([ + 'hero_image' => 'blog/heroes/huge-hero.png', + ]); + + $this->createHeroImage('blog/heroes/huge-hero.png', 2500, 1000); + + resolve(ArticleImageService::class)->refreshImages($article); + + $hero = ImageManager::gd()->read(Storage::disk('public')->path('blog/heroes/huge-hero.png')); + + $this->assertSame(ArticleImageService::HERO_MAX_WIDTH, $hero->width()); + $this->assertSame((int) (ArticleImageService::HERO_MAX_WIDTH * 1000 / 2500), $hero->height()); + } + + public function test_refresh_images_generates_an_og_image_when_there_is_no_hero(): void + { + $article = Article::factory()->published()->create(); + + resolve(ArticleImageService::class)->refreshImages($article); + + $article->refresh(); + + Storage::disk('public')->assertExists("og-images/{$article->slug}.png"); + $this->assertStringEndsWith("og-images/{$article->slug}.png", $article->og_image); + $this->assertNull($article->card_image); + $this->assertNull($article->header_image); + } + + public function test_replacing_the_hero_image_deletes_the_old_file(): void + { + $article = $this->articleWithHero(); + + $this->createHeroImage('blog/heroes/new-hero.png'); + + $article->update(['hero_image' => 'blog/heroes/new-hero.png']); + + Storage::disk('public')->assertMissing('blog/heroes/test-hero.png'); + Storage::disk('public')->assertExists('blog/heroes/new-hero.png'); + } + + public function test_changing_the_slug_deletes_stale_generated_images(): void + { + $article = $this->articleWithHero(['slug' => 'old-slug', 'published_at' => null]); + + resolve(ArticleImageService::class)->refreshImages($article); + + Storage::disk('public')->assertExists('og-images/old-slug.png'); + Storage::disk('public')->assertExists('blog/cards/old-slug.jpg'); + Storage::disk('public')->assertExists('blog/headers/old-slug.jpg'); + + $article->update(['slug' => 'new-slug']); + + Storage::disk('public')->assertMissing('og-images/old-slug.png'); + Storage::disk('public')->assertMissing('blog/cards/old-slug.jpg'); + Storage::disk('public')->assertMissing('blog/headers/old-slug.jpg'); + } + + public function test_deleting_an_article_deletes_its_images(): void + { + $article = $this->articleWithHero(); + + resolve(ArticleImageService::class)->refreshImages($article); + + Storage::disk('public')->assertExists('blog/heroes/test-hero.png'); + Storage::disk('public')->assertExists("og-images/{$article->slug}.png"); + Storage::disk('public')->assertExists("blog/cards/{$article->slug}.jpg"); + Storage::disk('public')->assertExists("blog/headers/{$article->slug}.jpg"); + + $slug = $article->slug; + + $article->delete(); + + Storage::disk('public')->assertMissing('blog/heroes/test-hero.png'); + Storage::disk('public')->assertMissing("og-images/{$slug}.png"); + Storage::disk('public')->assertMissing("blog/cards/{$slug}.jpg"); + Storage::disk('public')->assertMissing("blog/headers/{$slug}.jpg"); + } + + public function test_blog_index_shows_the_card_image(): void + { + Article::factory()->published()->create([ + 'card_image' => '/storage/blog/cards/test-article.jpg', + ]); + + $this->get(route('blog')) + ->assertOk() + ->assertSee('/storage/blog/cards/test-article.jpg'); + } + + public function test_article_page_shows_the_header_image(): void + { + $article = $this->articleWithHero(); + + resolve(ArticleImageService::class)->refreshImages($article); + + $this->get(route('article', $article)) + ->assertOk() + ->assertSee("storage/blog/headers/{$article->slug}.jpg"); + } + + public function test_article_page_has_no_header_markup_without_a_hero_image(): void + { + $article = Article::factory()->published()->create(); + + $this->get(route('article', $article)) + ->assertOk() + ->assertDontSee('storage/blog/headers'); + } + + public function test_saving_an_article_with_a_hero_generates_the_custom_images(): void + { + $admin = User::factory()->create(['email' => 'admin@test.com']); + config(['filament.users' => ['admin@test.com']]); + + $article = $this->articleWithHero(['published_at' => null]); + + Livewire::actingAs($admin) + ->test(EditArticle::class, ['record' => $article->id]) + ->fillForm(['title' => 'Updated title']) + ->call('save') + ->assertHasNoFormErrors(); + + $article->refresh(); + + Storage::disk('public')->assertExists("blog/cards/{$article->slug}.jpg"); + Storage::disk('public')->assertExists("blog/headers/{$article->slug}.jpg"); + $this->assertStringEndsWith("og-images/{$article->slug}.png", $article->og_image); + $this->assertStringEndsWith("blog/cards/{$article->slug}.jpg", $article->card_image); + $this->assertStringEndsWith("blog/headers/{$article->slug}.jpg", $article->header_image); + + // The OG image is cropped from the hero rather than generated by TheOG + $ogImage = ImageManager::gd()->read(Storage::disk('public')->path("og-images/{$article->slug}.png")); + $this->assertContains($ogImage->pickColor(10, 315)->toHex(), ['ff0000', '0000ff']); + } + + public function test_removing_the_hero_resets_crops_and_reverts_to_generated_og_images(): void + { + $admin = User::factory()->create(['email' => 'admin@test.com']); + config(['filament.users' => ['admin@test.com']]); + + $article = $this->articleWithHero([ + 'published_at' => null, + 'og_image_crop' => ['x' => 0, 'y' => 0, 'width' => 762, 'height' => 400], + 'card_image_crop' => ['x' => 0, 'y' => 0, 'width' => 900, 'height' => 338], + 'header_image_crop' => ['x' => 0, 'y' => 0, 'width' => 1024, 'height' => 500], + ]); + + resolve(ArticleImageService::class)->refreshImages($article); + + Livewire::actingAs($admin) + ->test(EditArticle::class, ['record' => $article->id]) + ->fillForm(['hero_image' => null]) + ->call('save') + ->assertHasNoFormErrors(); + + $article->refresh(); + + $this->assertNull($article->hero_image); + $this->assertNull($article->og_image_crop); + $this->assertNull($article->card_image_crop); + $this->assertNull($article->header_image_crop); + $this->assertNull($article->card_image); + $this->assertNull($article->header_image); + $this->assertStringEndsWith("og-images/{$article->slug}.png", $article->og_image); + + Storage::disk('public')->assertMissing('blog/heroes/test-hero.png'); + Storage::disk('public')->assertMissing("blog/cards/{$article->slug}.jpg"); + Storage::disk('public')->assertMissing("blog/headers/{$article->slug}.jpg"); + Storage::disk('public')->assertExists("og-images/{$article->slug}.png"); + } +}