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 @@
+
+
+
+
+
URL.createObjectURL(f))"
+ class="block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-semibold file:bg-primary-50 file:text-primary-700 hover:file:bg-primary-100">
+ @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())
+
+
+
+
+ @endif
+
+
+
+
+
+
URL.createObjectURL(f))"
+ class="block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-semibold file:bg-primary-50 file:text-primary-700 hover:file:bg-primary-100">
+ @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);
+});