feat: product screenshots gallery

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Doar Mihai
2026-10-17 16:00:38 +03:00
co-authored by Claude Sonnet 5.5
parent bf365d029f
commit a3894d2f37
9 changed files with 314 additions and 2 deletions
+26 -1
View File
@@ -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')
+9
View File
@@ -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.
*/
+22
View File
@@ -0,0 +1,22 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ProductScreenshot extends Model
{
protected $fillable = ['product_id', 'path', 'position'];
public function product(): BelongsTo
{
return $this->belongsTo(Product::class);
}
protected function url(): Attribute
{
return Attribute::get(fn () => asset('storage/'.$this->path));
}
}
@@ -0,0 +1,24 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('product_screenshots', function (Blueprint $table) {
$table->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');
}
};
@@ -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())
<div data-gallery
x-data="{ open: false, index: 0, urls: @js($urls),
show(i) { this.index = i; this.open = true },
next() { this.index = (this.index + 1) % this.urls.length },
prev() { this.index = (this.index - 1 + this.urls.length) % this.urls.length } }"
@keydown.escape.window="open = false"
@keydown.arrow-right.window="open && next()"
@keydown.arrow-left.window="open && prev()"
{{ $attributes }}>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-2">
<button type="button" @click="show(0)" class="sm:col-span-2 sm:row-span-3 block overflow-hidden rounded-lg border border-gray-200 dark:border-gray-700">
<img src="{{ $urls[0] }}" alt="{{ $product->title }} screenshot 1" class="w-full h-full object-cover aspect-video sm:aspect-auto">
</button>
@foreach($tiles as $i => $shot)
<button type="button" @click="show({{ $i + 1 }})" class="relative block overflow-hidden rounded-lg border border-gray-200 dark:border-gray-700">
<img src="{{ $shot->url }}" alt="{{ $product->title }} screenshot {{ $i + 2 }}" class="w-full h-full object-cover aspect-video">
@if($loop->last && $more > 0)
<span class="absolute inset-0 flex items-center justify-center bg-black/50 text-white font-semibold">+{{ $more }} more</span>
@endif
</button>
@endforeach
</div>
<div x-show="open" style="display: none" x-transition.opacity
class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-4"
@click.self="open = false" role="dialog" aria-modal="true">
<button type="button" @click="open = false" class="absolute top-4 right-4 text-white text-3xl leading-none" aria-label="Close">&times;</button>
<button type="button" x-show="urls.length > 1" @click="prev()" class="absolute left-4 text-white text-4xl" aria-label="Previous">&lsaquo;</button>
<img :src="urls[index]" alt="" class="max-h-full max-w-full object-contain">
<button type="button" x-show="urls.length > 1" @click="next()" class="absolute right-4 text-white text-4xl" aria-label="Next">&rsaquo;</button>
</div>
</div>
@endif
+28
View File
@@ -179,6 +179,34 @@
</div>
</div>
<!-- Screenshots -->
<div x-data="{ previews: [] }">
<label for="screenshots" class="block text-sm font-medium text-gray-700">
Screenshots
</label>
<div class="mt-1">
<input type="file"
id="screenshots"
name="screenshots[]"
accept="image/*"
multiple
@change="previews = Array.from($event.target.files).map(f => 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')
<p class="mt-2 text-sm text-red-600">{{ $message }}</p>
@enderror
@foreach($errors->get('screenshots.*') as $messages)
<p class="mt-2 text-sm text-red-600">{{ $messages[0] }}</p>
@endforeach
<p class="mt-2 text-sm text-gray-500">Add several images (JPEG, PNG, or GIF, max 2MB each).</p>
<div class="mt-3 flex flex-wrap gap-2" x-show="previews.length" style="display: none">
<template x-for="src in previews" :key="src">
<img :src="src" alt="" class="h-20 w-28 object-cover rounded border border-gray-300">
</template>
</div>
</div>
</div>
<!-- Info Alert -->
<div class="rounded-md bg-blue-50 p-4">
<div class="flex">
+46
View File
@@ -205,6 +205,52 @@
</div>
</div>
@if($product->screenshots->isNotEmpty())
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Current Screenshots:</label>
<div class="mt-1 grid grid-cols-2 sm:grid-cols-4 gap-3">
@foreach($product->screenshots as $shot)
<label class="block">
<img src="{{ $shot->url }}" alt="" class="h-24 w-full object-cover rounded border border-gray-300">
<span class="mt-1 flex items-center text-sm text-gray-700">
<input type="checkbox" name="remove_screenshots[]" value="{{ $shot->id }}"
class="h-4 w-4 text-primary-600 border-gray-300 rounded">
<span class="ml-2">Remove</span>
</span>
</label>
@endforeach
</div>
</div>
@endif
<!-- Screenshots -->
<div x-data="{ previews: [] }">
<label for="screenshots" class="block text-sm font-medium text-gray-700">
Screenshots
</label>
<div class="mt-1">
<input type="file"
id="screenshots"
name="screenshots[]"
accept="image/*"
multiple
@change="previews = Array.from($event.target.files).map(f => 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')
<p class="mt-2 text-sm text-red-600">{{ $message }}</p>
@enderror
@foreach($errors->get('screenshots.*') as $messages)
<p class="mt-2 text-sm text-red-600">{{ $messages[0] }}</p>
@endforeach
<p class="mt-2 text-sm text-gray-500">Add several images (JPEG, PNG, or GIF, max 2MB each).</p>
<div class="mt-3 flex flex-wrap gap-2" x-show="previews.length" style="display: none">
<template x-for="src in previews" :key="src">
<img :src="src" alt="" class="h-20 w-28 object-cover rounded border border-gray-300">
</template>
</div>
</div>
</div>
<!-- Info Alert -->
<div class="rounded-md bg-blue-50 p-4">
<div class="flex">
+1 -1
View File
@@ -118,7 +118,7 @@
</div>
<!-- Gallery -->
<!-- slot:gallery -->
<x-product.gallery :product="$product" class="mt-6" />
<div class="mt-6 grid grid-cols-1 lg:grid-cols-4 gap-6">
<!-- Main column -->
+115
View File
@@ -0,0 +1,115 @@
<?php
use App\Models\Product;
use App\Models\ProductScreenshot;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
uses(RefreshDatabase::class);
function shotPayload(array $overrides = []): array
{
return array_merge([
'title' => '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);
});