From a3894d2f37a44198e2c446fae1f82405e322d13b Mon Sep 17 00:00:00 2001 From: Doar Mihai Date: Sat, 17 Oct 2026 16:00:23 +0300 Subject: [PATCH] feat: product screenshots gallery Co-Authored-By: Claude Sonnet 5.5 --- app/Http/Controllers/ProductController.php | 27 +++- app/Models/Product.php | 9 ++ app/Models/ProductScreenshot.php | 22 ++++ ...10000_create_product_screenshots_table.php | 24 ++++ .../components/product/gallery.blade.php | 43 +++++++ resources/views/products/create.blade.php | 28 +++++ resources/views/products/edit.blade.php | 46 +++++++ resources/views/products/show.blade.php | 2 +- tests/Feature/ProductScreenshotsTest.php | 115 ++++++++++++++++++ 9 files changed, 314 insertions(+), 2 deletions(-) create mode 100644 app/Models/ProductScreenshot.php create mode 100644 database/migrations/2026_10_17_110000_create_product_screenshots_table.php create mode 100644 resources/views/components/product/gallery.blade.php create mode 100644 tests/Feature/ProductScreenshotsTest.php diff --git a/app/Http/Controllers/ProductController.php b/app/Http/Controllers/ProductController.php index f7624b6..43408ab 100644 --- a/app/Http/Controllers/ProductController.php +++ b/app/Http/Controllers/ProductController.php @@ -223,6 +223,10 @@ class ProductController extends Controller 'open_source' => 'boolean', 'eu_hosted' => 'boolean', 'eu_made' => 'boolean', + 'screenshots' => 'nullable|array', + 'screenshots.*' => 'image|mimes:jpeg,png,jpg,gif|max:2048', + 'remove_screenshots' => 'nullable|array', + 'remove_screenshots.*' => 'integer', ]; } @@ -231,7 +235,24 @@ class ProductController extends Controller */ protected function syncDetails(Request $request, Product $product): void { - // + // Only rows belonging to this product may be removed + $ids = array_filter((array) $request->input('remove_screenshots', [])); + if ($ids) { + $product->screenshots()->whereIn('id', $ids)->get()->each(function ($shot) { + Storage::disk('public')->delete($shot->path); + $shot->delete(); + }); + } + + if ($request->hasFile('screenshots')) { + $position = ($product->screenshots()->max('position') ?? -1) + 1; + foreach ($request->file('screenshots') as $file) { + $product->screenshots()->create([ + 'path' => $file->store('screenshots', 'public'), + 'position' => $position++, + ]); + } + } } /** @@ -246,6 +267,10 @@ class ProductController extends Controller Storage::disk('public')->delete($product->thumbnail); } + foreach ($product->screenshots as $shot) { + Storage::disk('public')->delete($shot->path); + } + $product->delete(); return redirect()->route('products.index') diff --git a/app/Models/Product.php b/app/Models/Product.php index 55e793c..d329425 100644 --- a/app/Models/Product.php +++ b/app/Models/Product.php @@ -7,6 +7,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Support\Str; class Product extends Model @@ -121,6 +122,14 @@ class Product extends Model return $query->exists(); } + /** + * Get the product's screenshots in display order. + */ + public function screenshots(): HasMany + { + return $this->hasMany(ProductScreenshot::class)->orderBy('position')->orderBy('id'); + } + /** * Get the user that owns the product. */ diff --git a/app/Models/ProductScreenshot.php b/app/Models/ProductScreenshot.php new file mode 100644 index 0000000..d5c3ed6 --- /dev/null +++ b/app/Models/ProductScreenshot.php @@ -0,0 +1,22 @@ +belongsTo(Product::class); + } + + protected function url(): Attribute + { + return Attribute::get(fn () => asset('storage/'.$this->path)); + } +} diff --git a/database/migrations/2026_10_17_110000_create_product_screenshots_table.php b/database/migrations/2026_10_17_110000_create_product_screenshots_table.php new file mode 100644 index 0000000..c68ff30 --- /dev/null +++ b/database/migrations/2026_10_17_110000_create_product_screenshots_table.php @@ -0,0 +1,24 @@ +id(); + $table->foreignId('product_id')->constrained()->cascadeOnDelete(); + $table->string('path'); + $table->unsignedInteger('position')->default(0); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('product_screenshots'); + } +}; diff --git a/resources/views/components/product/gallery.blade.php b/resources/views/components/product/gallery.blade.php new file mode 100644 index 0000000..3a1697e --- /dev/null +++ b/resources/views/components/product/gallery.blade.php @@ -0,0 +1,43 @@ +@props(['product']) + +@php + $shots = $product->screenshots->values(); + $urls = $shots->map->url->all(); + $tiles = $shots->slice(1, 3)->values(); + $more = max(0, $shots->count() - 4); +@endphp + +@if($shots->isNotEmpty()) +
+
+ + @foreach($tiles as $i => $shot) + + @endforeach +
+ + +
+@endif diff --git a/resources/views/products/create.blade.php b/resources/views/products/create.blade.php index de7cf34..d3640a4 100644 --- a/resources/views/products/create.blade.php +++ b/resources/views/products/create.blade.php @@ -179,6 +179,34 @@ + +
+ +
+ + @error('screenshots') +

{{ $message }}

+ @enderror + @foreach($errors->get('screenshots.*') as $messages) +

{{ $messages[0] }}

+ @endforeach +

Add several images (JPEG, PNG, or GIF, max 2MB each).

+ +
+
+
diff --git a/resources/views/products/edit.blade.php b/resources/views/products/edit.blade.php index ee54d82..398514a 100644 --- a/resources/views/products/edit.blade.php +++ b/resources/views/products/edit.blade.php @@ -205,6 +205,52 @@
+ @if($product->screenshots->isNotEmpty()) +
+ +
+ @foreach($product->screenshots as $shot) + + @endforeach +
+
+ @endif + + +
+ +
+ + @error('screenshots') +

{{ $message }}

+ @enderror + @foreach($errors->get('screenshots.*') as $messages) +

{{ $messages[0] }}

+ @endforeach +

Add several images (JPEG, PNG, or GIF, max 2MB each).

+ +
+
+
diff --git a/resources/views/products/show.blade.php b/resources/views/products/show.blade.php index 7f6a663..59efb01 100644 --- a/resources/views/products/show.blade.php +++ b/resources/views/products/show.blade.php @@ -118,7 +118,7 @@
- +
diff --git a/tests/Feature/ProductScreenshotsTest.php b/tests/Feature/ProductScreenshotsTest.php new file mode 100644 index 0000000..d961023 --- /dev/null +++ b/tests/Feature/ProductScreenshotsTest.php @@ -0,0 +1,115 @@ + 'Shot Product', + 'description' => 'A product description', + 'website_url' => 'https://example.com', + 'category_id' => makeCategory()->id, + ], $overrides); +} + +function makeShot(Product $product, string $name = 'a.jpg'): ProductScreenshot +{ + Storage::disk('public')->put("screenshots/{$product->id}-{$name}", 'x'); + + return $product->screenshots()->create(['path' => "screenshots/{$product->id}-{$name}"]); +} + +test('submitter uploads several screenshots', function () { + Storage::fake('public'); + $user = userWithRole('user'); + + $this->actingAs($user)->post(route('products.store'), shotPayload([ + 'screenshots' => [ + UploadedFile::fake()->image('a.jpg'), + UploadedFile::fake()->image('b.png'), + UploadedFile::fake()->image('c.jpg'), + ], + ]))->assertRedirect(); + + $product = Product::firstOrFail(); + expect($product->screenshots)->toHaveCount(3); + expect($product->screenshots->pluck('position')->all())->toBe([0, 1, 2]); + foreach ($product->screenshots as $shot) { + Storage::disk('public')->assertExists($shot->path); + } +}); + +test('screenshots must be images up to 2 MB', function () { + Storage::fake('public'); + $user = userWithRole('user'); + + $this->actingAs($user)->post(route('products.store'), shotPayload([ + 'screenshots' => [UploadedFile::fake()->create('doc.pdf', 100, 'application/pdf')], + ]))->assertSessionHasErrors('screenshots.0'); + + $this->actingAs($user)->post(route('products.store'), shotPayload([ + 'screenshots' => [UploadedFile::fake()->image('big.jpg')->size(3000)], + ]))->assertSessionHasErrors('screenshots.0'); + + expect(Product::count())->toBe(0); +}); + +test('owner removes a screenshot on edit', function () { + Storage::fake('public'); + $user = userWithRole('user'); + $product = Product::factory()->pending()->create(['user_id' => $user->id]); + $shot = makeShot($product); + + $this->actingAs($user)->put(route('products.update', $product), shotPayload([ + 'category_id' => $product->category_id, + 'remove_screenshots' => [$shot->id], + ]))->assertRedirect(); + + expect(ProductScreenshot::count())->toBe(0); + Storage::disk('public')->assertMissing($shot->path); +}); + +test('remove_screenshots ignores ids from another product', function () { + Storage::fake('public'); + $user = userWithRole('user'); + $product = Product::factory()->pending()->create(['user_id' => $user->id]); + $other = Product::factory()->pending()->create(); + $foreign = makeShot($other, 'f.jpg'); + + $this->actingAs($user)->put(route('products.update', $product), shotPayload([ + 'category_id' => $product->category_id, + 'remove_screenshots' => [$foreign->id], + ]))->assertRedirect()->assertSessionHasNoErrors(); + + expect($foreign->fresh())->not->toBeNull(); + Storage::disk('public')->assertExists($foreign->path); +}); + +test('deleting a product removes its screenshot files', function () { + Storage::fake('public'); + $user = userWithRole('user'); + $product = Product::factory()->pending()->create(['user_id' => $user->id]); + $shot = makeShot($product); + + $this->actingAs($user)->delete(route('products.destroy', $product))->assertRedirect(); + + Storage::disk('public')->assertMissing($shot->path); + expect(ProductScreenshot::count())->toBe(0); +}); + +test('show page renders gallery only when screenshots exist', function () { + Storage::fake('public'); + $product = Product::factory()->approved()->create(); + + $this->get(route('products.show', $product))->assertOk()->assertDontSee('data-gallery', false); + + makeShot($product); + + $this->get(route('products.show', $product))->assertOk()->assertSee('data-gallery', false); +});