feat: product screenshots gallery
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
bf365d029f
commit
a3894d2f37
@@ -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')
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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">×</button>
|
||||
<button type="button" x-show="urls.length > 1" @click="prev()" class="absolute left-4 text-white text-4xl" aria-label="Previous">‹</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">›</button>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user