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
+
+
+

+
+
+
+
+
+
+
+
+
+
+
+ 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");
+ }
+}