From 4994d4a8ef09e9f333e9fa0517f38af5cf5e6605 Mon Sep 17 00:00:00 2001 From: Niek Rabelink Date: Sun, 1 Nov 2026 16:24:12 +0100 Subject: [PATCH] feat: replace raw image path input with interactive visual gallery and multi-image manager in admin and shop --- .../Controllers/Admin/ProductController.php | 48 +++- app/Services/MakerWorldService.php | 11 +- .../views/admin/makerworld/staging.blade.php | 4 + .../views/admin/products/create.blade.php | 245 +++++++++++++++++- resources/views/admin/products/edit.blade.php | 245 +++++++++++++++++- .../views/admin/products/index.blade.php | 7 +- resources/views/shop/show.blade.php | 109 +++++++- routes/web.php | 1 + tests/Feature/AdminContentTest.php | 83 ++++++ 9 files changed, 724 insertions(+), 29 deletions(-) diff --git a/app/Http/Controllers/Admin/ProductController.php b/app/Http/Controllers/Admin/ProductController.php index b86807d..75e2933 100644 --- a/app/Http/Controllers/Admin/ProductController.php +++ b/app/Http/Controllers/Admin/ProductController.php @@ -5,6 +5,7 @@ namespace App\Http\Controllers\Admin; use App\Http\Controllers\Controller; use App\Models\Category; use App\Models\Product; +use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Str; @@ -84,7 +85,9 @@ class ProductController extends Controller 'base_price' => 'required|numeric|min:0.01', 'short_description' => 'nullable|string|max:500', 'description' => 'required|string', - 'image_url' => 'nullable|string|max:500', + 'image_url' => 'nullable|string|max:2048', + 'gallery_images' => 'nullable|array', + 'gallery_images.*' => 'nullable|string|max:2048', 'dimensions' => 'nullable|string|max:100', 'weight_grams' => 'nullable|integer|min:0', 'print_time_hours' => 'nullable|numeric|min:0', @@ -95,6 +98,7 @@ class ProductController extends Controller $validated['slug'] = Str::slug($validated['name']); $validated['is_featured'] = $request->boolean('is_featured'); $validated['is_active'] = $request->boolean('is_active'); + $validated['gallery_images'] = $this->cleanGalleryImages($request->input('gallery_images', []), $validated['image_url'] ?? null); $validated['available_materials'] = ['pla', 'petg', 'silk', 'tpu']; $validated['available_colors'] = [ 'Zon Geel (Pikachu)', 'Vurig Rood (Charizard)', 'Hemelsblauw (Squirtle)', @@ -129,7 +133,9 @@ class ProductController extends Controller 'base_price' => 'required|numeric|min:0.01', 'short_description' => 'nullable|string|max:500', 'description' => 'required|string', - 'image_url' => 'nullable|string|max:500', + 'image_url' => 'nullable|string|max:2048', + 'gallery_images' => 'nullable|array', + 'gallery_images.*' => 'nullable|string|max:2048', 'dimensions' => 'nullable|string|max:100', 'weight_grams' => 'nullable|integer|min:0', 'print_time_hours' => 'nullable|numeric|min:0', @@ -139,6 +145,7 @@ class ProductController extends Controller $validated['is_featured'] = $request->boolean('is_featured'); $validated['is_active'] = $request->boolean('is_active'); + $validated['gallery_images'] = $this->cleanGalleryImages($request->input('gallery_images', []), $validated['image_url'] ?? null); $product->update($validated); @@ -146,6 +153,43 @@ class ProductController extends Controller ->with('success', "Product '{$product->name}' is succesvol bijgewerkt!"); } + /** + * Handle image upload for product gallery / cover. + */ + public function uploadImage(Request $request): JsonResponse + { + $request->validate([ + 'image' => 'required|image|mimes:jpeg,png,jpg,webp,gif|max:8192', + ]); + + $path = $request->file('image')->store('products', 'public'); + $url = '/storage/'.$path; + + return response()->json([ + 'success' => true, + 'url' => $url, + ]); + } + + /** + * Sanitize and filter gallery images. + * + * @param array $images + * @return array + */ + protected function cleanGalleryImages(array $images, ?string $coverUrl = null): array + { + $clean = []; + foreach ($images as $img) { + $img = trim((string) $img); + if ($img !== '' && $img !== $coverUrl && ! in_array($img, $clean, true)) { + $clean[] = $img; + } + } + + return $clean; + } + /** * Remove the specified product from storage. */ diff --git a/app/Services/MakerWorldService.php b/app/Services/MakerWorldService.php index f49d2b2..a3cda62 100644 --- a/app/Services/MakerWorldService.php +++ b/app/Services/MakerWorldService.php @@ -780,9 +780,14 @@ class MakerWorldService if (! $existingProduct->makerworld_collection_id) { $updates['makerworld_collection_id'] = $collection->id; } - if (empty($existingProduct->gallery_images) && ! empty($design['designExtension']['design_pictures'])) { - $formatted = $this->formatDesignForImport($design); - $updates['gallery_images'] = $formatted['gallery_images']; + if (empty($existingProduct->gallery_images)) { + $full = (! empty($design['designExtension']['design_pictures'])) + ? $design + : ($this->getDesignDetails($designId) ?? $design); + $formatted = $this->formatDesignForImport($full); + if (! empty($formatted['gallery_images'])) { + $updates['gallery_images'] = $formatted['gallery_images']; + } } if (empty($existingProduct->print_info)) { $formatted = $this->formatDesignForImport($design); diff --git a/resources/views/admin/makerworld/staging.blade.php b/resources/views/admin/makerworld/staging.blade.php index e071a40..bc04dbf 100644 --- a/resources/views/admin/makerworld/staging.blade.php +++ b/resources/views/admin/makerworld/staging.blade.php @@ -355,6 +355,10 @@ Publiceer naar Webshop + + + + + + + + +
+
+ + +
+

+
+ +
+ + +
+ + +
+
+ + + Hoofdafbeelding (Cover) + + + +
+ + +
+ + +
+ + +
+ +
+
+ + +
+
+ Extra Gallerij Foto's + +
+ + +
+ +
+ + +
+ +

Nog geen extra galerijfoto's

+

+ Voeg extra detailfoto's toe via "URL Plakken" of "Foto Uploaden". +

+
+
+ +
+
diff --git a/resources/views/admin/products/edit.blade.php b/resources/views/admin/products/edit.blade.php index 45b0f63..6e58e48 100644 --- a/resources/views/admin/products/edit.blade.php +++ b/resources/views/admin/products/edit.blade.php @@ -53,14 +53,249 @@
- - + +
-
- - + +
+ + + + + +
+
+
+ +

Productafbeeldingen & Fotogalerij

+ +
+

+ Alle foto's die vanuit MakerWorld zijn binnengehaald worden hier visueel getoond. Je kunt foto's toevoegen, wisselen van hoofdfoto of verwijderen. +

+
+ +
+ + + + + +
+
+ + +
+
+ + +
+

+
+ +
+ + +
+ + +
+
+ + + Hoofdafbeelding (Cover) + + + +
+ + +
+ + +
+ + +
+ +
+
+ + +
+
+ Extra Gallerij Foto's + +
+ + +
+ +
+ + +
+ +

Nog geen extra galerijfoto's

+

+ Voeg extra detailfoto's of printbedfoto's toe via "URL Plakken" of "Foto Uploaden". +

+
+
+ +
+
diff --git a/resources/views/admin/products/index.blade.php b/resources/views/admin/products/index.blade.php index 91d3904..7ceb9ea 100644 --- a/resources/views/admin/products/index.blade.php +++ b/resources/views/admin/products/index.blade.php @@ -122,8 +122,13 @@
-
+
{{ $product->name }} + @if(!empty($product->gallery_images) && count($product->gallery_images) > 0) + + +{{ count($product->gallery_images) }} + + @endif
diff --git a/resources/views/shop/show.blade.php b/resources/views/shop/show.blade.php index aa102cd..2b4cd89 100644 --- a/resources/views/shop/show.blade.php +++ b/resources/views/shop/show.blade.php @@ -55,22 +55,105 @@
-
-
- {{ $product->name }} -
- @if($product->is_featured) - - 🔥 - Populair - + @php + $gallery = []; + if (!empty($product->image_url)) { + $gallery[] = $product->image_url; + } + if (!empty($product->gallery_images) && is_array($product->gallery_images)) { + foreach ($product->gallery_images as $gImg) { + if (!empty($gImg) && !in_array($gImg, $gallery, true)) { + $gallery[] = $gImg; + } + } + } + if (empty($gallery)) { + $gallery[] = '/images/products/crystal-dragon.jpg'; + } + @endphp + + +
+ +
+
+ {{ $product->name }} + +
+ @if($product->is_featured) + + 🔥 + Populair + + @endif +
+ + + @if(count($gallery) > 1) +
+ + / {{ count($gallery) }} + +
+ + + + + @endif
+ + + @if(count($gallery) > 1) +
+ +
+ @endif
diff --git a/routes/web.php b/routes/web.php index 64c78a2..e7ae0cd 100644 --- a/routes/web.php +++ b/routes/web.php @@ -118,6 +118,7 @@ Route::prefix('admin')->name('admin.')->middleware(['auth', 'admin'])->group(fun Route::post('/content/test-mail', [ContentController::class, 'sendTestMail'])->name('content.test-mail'); // Producten Beheer + Route::post('/products/upload-image', [AdminProductController::class, 'uploadImage'])->name('products.upload-image'); Route::resource('products', AdminProductController::class)->except(['show']); // Categorieën Beheer diff --git a/tests/Feature/AdminContentTest.php b/tests/Feature/AdminContentTest.php index f8baee8..2d20c65 100644 --- a/tests/Feature/AdminContentTest.php +++ b/tests/Feature/AdminContentTest.php @@ -8,6 +8,8 @@ use App\Models\Product; use App\Models\Setting; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Http\UploadedFile; +use Illuminate\Support\Facades\Storage; use Tests\TestCase; class AdminContentTest extends TestCase @@ -174,4 +176,85 @@ class AdminContentTest extends TestCase $showResponse->assertStatus(200); $showResponse->assertSee('ORD-TEST-999'); } + + public function test_admin_can_update_product_images_and_gallery(): void + { + $category = Category::create([ + 'name' => 'Fidget Gadgets', + 'slug' => 'fidget-gadgets-gallery-test', + ]); + + $product = Product::create([ + 'category_id' => $category->id, + 'name' => 'Multi Foto Draak', + 'slug' => 'multi-foto-draak', + 'short_description' => 'Test met meerdere fotos', + 'description' => 'Test beschrijving met galerij', + 'base_price' => 19.95, + 'image_url' => 'https://cdn.example.com/cover.jpg', + 'gallery_images' => ['https://cdn.example.com/photo1.jpg', 'https://cdn.example.com/photo2.jpg'], + 'is_active' => true, + ]); + + // Edit page loads with visual image gallery + $editResponse = $this->actingAs($this->admin)->get(route('admin.products.edit', $product)); + $editResponse->assertStatus(200); + $editResponse->assertSee('Productafbeeldingen & Fotogalerij', false); + $editResponse->assertSee('Hoofdafbeelding (Cover)'); + + // Update product with new cover and extra gallery images + $updateResponse = $this->actingAs($this->admin)->put(route('admin.products.update', $product), [ + 'name' => 'Multi Foto Draak Bijgewerkt', + 'category_id' => $category->id, + 'base_price' => 21.50, + 'short_description' => 'Geüpdatet', + 'description' => 'Nieuwe omschrijving', + 'image_url' => 'https://cdn.example.com/new-cover.jpg', + 'gallery_images' => [ + 'https://cdn.example.com/photo1.jpg', + 'https://cdn.example.com/photo2.jpg', + 'https://cdn.example.com/photo3.jpg', + 'https://cdn.example.com/new-cover.jpg', // Should be excluded because it matches cover + ], + 'is_active' => 1, + ]); + + $updateResponse->assertRedirect(route('admin.products.index')); + + $product->refresh(); + $this->assertEquals('https://cdn.example.com/new-cover.jpg', $product->image_url); + $this->assertCount(3, $product->gallery_images); + $this->assertContains('https://cdn.example.com/photo3.jpg', $product->gallery_images); + $this->assertNotContains('https://cdn.example.com/new-cover.jpg', $product->gallery_images); + + // Storefront product details page shows the multi-image gallery + $shopResponse = $this->get(route('product.show', $product->slug)); + $shopResponse->assertStatus(200); + $shopResponse->assertSee('https://cdn.example.com/new-cover.jpg'); + $shopResponse->assertSee('https://cdn.example.com/photo1.jpg'); + $shopResponse->assertSee('https://cdn.example.com/photo3.jpg'); + } + + public function test_admin_can_upload_product_image(): void + { + Storage::fake('public'); + + $onePixelJpeg = base64_decode('/9j/4AAQSkZJRgABAQEASABIAAD/2wBDAP//////////////////////////////////////////////////////////////////////////////////////wgALCAABAAEBAREA/8QAFBABAAAAAAAAAAAAAAAAAAAAAP/aAAgBAQABPxA='); + $file = UploadedFile::fake()->createWithContent('test-print-photo.jpg', $onePixelJpeg); + + $response = $this->actingAs($this->admin)->postJson(route('admin.products.upload-image'), [ + 'image' => $file, + ]); + + $response->assertStatus(200); + $response->assertJson([ + 'success' => true, + ]); + + $url = $response->json('url'); + $this->assertStringStartsWith('/storage/products/', $url); + + $storagePath = str_replace('/storage/', '', $url); + Storage::disk('public')->assertExists($storagePath); + } }